Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(backend, frontend): upload avatar #165

Merged
merged 18 commits into from
Mar 12, 2025
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
8 changes: 7 additions & 1 deletion backend/src/chat/chat.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,15 @@ import { AuthModule } from '../auth/auth.module';
import { UserService } from 'src/user/user.service';
import { PubSub } from 'graphql-subscriptions';
import { JwtCacheModule } from 'src/jwt-cache/jwt-cache.module';
import { UploadModule } from 'src/upload/upload.module';

@Module({
imports: [TypeOrmModule.forFeature([Chat, User]), AuthModule, JwtCacheModule],
imports: [
TypeOrmModule.forFeature([Chat, User]),
AuthModule,
JwtCacheModule,
UploadModule,
],
providers: [
ChatResolver,
ChatProxyService,
Expand Down
8 changes: 8 additions & 0 deletions backend/src/user/dto/upload-avatar.input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Field, InputType } from '@nestjs/graphql';
import { FileUpload, GraphQLUpload } from 'graphql-upload-minimal';

@InputType()
export class UploadAvatarInput {
@Field(() => GraphQLUpload)
file: Promise<FileUpload>;
}
4 changes: 4 additions & 0 deletions backend/src/user/user.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export class User extends SystemBaseModel {
@Column()
password: string;

@Field({ nullable: true })
@Column({ nullable: true })
avatarUrl?: string;

@Field()
@Column({ unique: true })
@IsEmail()
Expand Down
2 changes: 2 additions & 0 deletions backend/src/user/user.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { AuthModule } from 'src/auth/auth.module';
import { MailModule } from 'src/mail/mail.module';
import { UploadModule } from 'src/upload/upload.module';

@Module({
imports: [
TypeOrmModule.forFeature([User]),
JwtModule,
AuthModule,
MailModule,
UploadModule,
],
providers: [UserResolver, UserService, DateScalar],
exports: [UserService],
Expand Down
47 changes: 47 additions & 0 deletions backend/src/user/user.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { Logger } from '@nestjs/common';
import { EmailConfirmationResponse } from 'src/auth/auth.resolver';
import { ResendEmailInput } from './dto/resend-email.input';
import { FileUpload, GraphQLUpload } from 'graphql-upload-minimal';

@ObjectType()
class LoginResponse {
Expand All @@ -28,6 +29,15 @@ class LoginResponse {
refreshToken: string;
}

@ObjectType()
class AvatarUploadResponse {
@Field()
success: boolean;

@Field()
avatarUrl: string;
}

@Resolver(() => User)
export class UserResolver {
constructor(
Expand Down Expand Up @@ -73,4 +83,41 @@ export class UserResolver {
Logger.log('me id:', id);
return this.userService.getUser(id);
}

/**
* Upload a new avatar for the authenticated user
* Uses validateAndBufferFile to ensure the image meets requirements
*/
@Mutation(() => AvatarUploadResponse)
async uploadAvatar(
@GetUserIdFromToken() userId: string,
@Args('file', { type: () => GraphQLUpload }) file: Promise<FileUpload>,
): Promise<AvatarUploadResponse> {
try {
const updatedUser = await this.userService.updateAvatar(userId, file);
return {
success: true,
avatarUrl: updatedUser.avatarUrl,
};
} catch (error) {
// Log the error
Logger.error(
`Avatar upload failed: ${error.message}`,
error.stack,
'UserResolver',
);

// Rethrow the exception to be handled by the GraphQL error handler
throw error;
}
}

/**
* Get the avatar URL for a user
*/
@Query(() => String, { nullable: true })
async getUserAvatar(@Args('userId') userId: string): Promise<string | null> {
const user = await this.userService.getUser(userId);
return user ? user.avatarUrl : null;
}
}
30 changes: 30 additions & 0 deletions backend/src/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ import { Injectable } from '@nestjs/common';
import { User } from './user.model';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { FileUpload } from 'graphql-upload-minimal';
import { UploadService } from '../upload/upload.service';
import { validateAndBufferFile } from 'src/common/security/file_check';

@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
private readonly uploadService: UploadService,
) {}

// Method to get all chats of a user
Expand All @@ -25,9 +29,35 @@ export class UserService {

return user;
}

async getUser(id: string): Promise<User> | null {
return await this.userRepository.findOneBy({
id,
});
}

/**
* Updates the user's avatar
* @param userId User ID
* @param file File upload
* @returns Updated user object
*/
async updateAvatar(userId: string, file: Promise<FileUpload>): Promise<User> {
// Get the user
const user = await this.userRepository.findOneBy({ id: userId });
if (!user) {
throw new Error('User not found');
}

// Validate and convert file to buffer
const uploadedFile = await file;
const { buffer, mimetype } = await validateAndBufferFile(uploadedFile);

// Upload the validated buffer to storage
const result = await this.uploadService.upload(buffer, mimetype, 'avatars');

// Update the user's avatar URL
user.avatarUrl = result.url;
return this.userRepository.save(user);
}
}
8 changes: 8 additions & 0 deletions codefox-common/src/common-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ export const getModelStatusPath = (): string => {
return modelStatusPath;
};

//Media Directory
export const getMediaDir = (): string =>
ensureDir(path.join(getRootDir(), 'media'));
export const getMediaPath = (modelName: string): string =>
path.join(getModelsDir(), modelName);
export const getMediaAvatarsDir = (): string =>
ensureDir(path.join(getMediaDir(), 'avatars'));
Comment on lines +48 to +53
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Consistent implementation of media directory utilities.

The new media directory utility functions follow the established pattern in the codebase and use the existing ensureDir helper for creating directories when needed.

One minor issue:


🏁 Script executed:

#!/bin/bash
# Check if getMediaPath is using the correct base directory

# The function references getModelsDir instead of getMediaDir
echo "Possible inconsistency in getMediaPath implementation:"
grep -n "getMediaPath" codefox-common/src/common-path.ts

Length of output: 237


Media Directory Path Inconsistency – Action Needed

The new media directory utility functions are consistent overall, but there’s a minor discrepancy: the getMediaPath function uses getModelsDir instead of the expected getMediaDir (as used in getMediaAvatarsDir). Please confirm whether this is intentional; if not, update the function to use getMediaDir for consistency with the media utilities pattern.

Suggested diff:

- export const getMediaPath = (modelName: string): string =>
-   path.join(getModelsDir(), modelName);
+ export const getMediaPath = (modelName: string): string =>
+   path.join(getMediaDir(), modelName);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const getMediaDir = (): string =>
ensureDir(path.join(getRootDir(), 'media'));
export const getMediaPath = (modelName: string): string =>
path.join(getModelsDir(), modelName);
export const getMediaAvatarsDir = (): string =>
ensureDir(path.join(getMediaDir(), 'avatars'));
export const getMediaDir = (): string =>
ensureDir(path.join(getRootDir(), 'media'));
export const getMediaPath = (modelName: string): string =>
path.join(getMediaDir(), modelName);
export const getMediaAvatarsDir = (): string =>
ensureDir(path.join(getMediaDir(), 'avatars'));


// Models Directory
export const getModelsDir = (): string =>
ensureDir(path.join(getRootDir(), 'models'));
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/app/(main)/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import UserSetting from '@/components/settings/settings';
import { UserSettingsBar } from '@/components/user-settings-bar';

export default function Page() {
return <UserSetting />;
}
64 changes: 64 additions & 0 deletions frontend/src/app/api/media/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { NextRequest } from 'next/server';
import fs from 'fs/promises'; // Use promises API
import path from 'path';
import { getMediaDir } from 'codefox-common';

export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
try {
const mediaDir = getMediaDir();
const filePath = path.join(mediaDir, ...params.path);
const normalizedPath = path.normalize(filePath);

if (!normalizedPath.startsWith(mediaDir)) {
console.error('Possible directory traversal attempt:', filePath);
return new Response('Access denied', { status: 403 });
}

// File extension allowlist
const contentTypeMap: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
};

const ext = path.extname(filePath).toLowerCase();
if (!contentTypeMap[ext]) {
return new Response('Forbidden file type', { status: 403 });
}

// File existence and size check
let fileStat;
try {
fileStat = await fs.stat(filePath);
} catch (err) {
return new Response('File not found', { status: 404 });
}

if (fileStat.size > 10 * 1024 * 1024) {
// 10MB limit
return new Response('File too large', { status: 413 });
}

// Read and return the file
const fileBuffer = await fs.readFile(filePath);
return new Response(fileBuffer, {
headers: {
'Content-Type': contentTypeMap[ext],
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'public, max-age=31536000',
},
});
} catch (error) {
console.error('Error serving media file:', error);
const errorMessage =
process.env.NODE_ENV === 'development'
? `Error serving file: ${error.message}`
: 'An error occurred while serving the file';

return new Response(errorMessage, { status: 500 });
}
}
7 changes: 7 additions & 0 deletions frontend/src/app/api/runProject/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import * as path from 'path';
import * as net from 'net';
import * as fs from 'fs';
import { getProjectPath } from 'codefox-common';
import { useMutation } from '@apollo/client/react/hooks/useMutation';
import { toast } from 'sonner';
import { UPDATE_PROJECT_PHOTO_URL } from '@/graphql/request';
import { TLS } from '@/utils/const';
import os from 'os';

const isWindows = os.platform() === 'win32';
import { URL_PROTOCOL_PREFIX } from '@/utils/const';

// Persist container state to file system to recover after service restarts
Expand Down
Loading
Loading