diff --git a/.github/workflows/deploy-main.yml b/.github/workflows/deploy-main.yml index 7aeab28..c1cc08a 100644 --- a/.github/workflows/deploy-main.yml +++ b/.github/workflows/deploy-main.yml @@ -118,12 +118,17 @@ jobs: MP_JWT_VERIFY_PUBLICKEY: ${{ secrets.MP_JWT_VERIFY_PUBLICKEY }} GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }} GHCR_TOKEN: ${{ secrets.GHCR_READ_TOKEN }} + MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }} + MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} + MINIO_ENDPOINT: ${{ secrets.MINIO_ENDPOINT }} + MINIO_PUBLIC_URL: ${{ secrets.MINIO_PUBLIC_URL }} + MINIO_BUCKET: ${{ secrets.MINIO_BUCKET }} with: host: ${{ secrets.HOSTINGER_HOST }} username: ${{ secrets.HOSTINGER_USER }} key: ${{ secrets.HOSTINGER_SSH_KEY }} port: ${{ secrets.HOSTINGER_PORT || 22 }} - envs: FRONTEND_IMAGE,BACKEND_IMAGE,POSTGRES_USER,POSTGRES_PASSWORD,DB_URL,MP_JWT_VERIFY_ISSUER,SMALLRYE_JWT_SIGN_KEY,MP_JWT_VERIFY_PUBLICKEY,GHCR_USERNAME,GHCR_TOKEN + envs: FRONTEND_IMAGE,BACKEND_IMAGE,POSTGRES_USER,POSTGRES_PASSWORD,DB_URL,MP_JWT_VERIFY_ISSUER,SMALLRYE_JWT_SIGN_KEY,MP_JWT_VERIFY_PUBLICKEY,GHCR_USERNAME,GHCR_TOKEN,MINIO_ROOT_USER,MINIO_ROOT_PASSWORD,MINIO_ENDPOINT,MINIO_PUBLIC_URL,MINIO_BUCKET script: | set -eu APP_DIR="${{ secrets.HOSTINGER_APP_PATH }}" @@ -137,6 +142,11 @@ jobs: echo "FRONTEND_IMAGE=${FRONTEND_IMAGE}" echo "BACKEND_IMAGE=${BACKEND_IMAGE}" echo "MP_JWT_VERIFY_ISSUER=${MP_JWT_VERIFY_ISSUER}" + echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}" + echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}" + echo "MINIO_ENDPOINT=${MINIO_ENDPOINT}" + echo "MINIO_PUBLIC_URL=${MINIO_PUBLIC_URL}" + echo "MINIO_BUCKET=${MINIO_BUCKET}" printf 'SMALLRYE_JWT_SIGN_KEY="%s"\n' "$SMALLRYE_JWT_SIGN_KEY" printf 'MP_JWT_VERIFY_PUBLICKEY="%s"\n' "$MP_JWT_VERIFY_PUBLICKEY" diff --git a/backend/pom.xml b/backend/pom.xml index 3d00ed8..0a69cf4 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -79,6 +79,11 @@ io.quarkus quarkus-hibernate-orm + + io.minio + minio + 8.5.17 + io.quarkus quarkus-junit diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/UserUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/UserUseCaseConfig.java index b9caa12..687ca0d 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/UserUseCaseConfig.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/UserUseCaseConfig.java @@ -8,6 +8,8 @@ import net.beetechgroup.beetask.usecase.auth.refresh.RefreshTokenUseCase; import net.beetechgroup.beetask.usecase.user.create.CreateUserUseCase; import net.beetechgroup.beetask.usecase.user.profile.GetUserProfileUseCase; import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileUseCase; +import net.beetechgroup.beetask.usecase.user.uploadphoto.UploadUserPhotoUseCase; +import net.beetechgroup.beetask.usecase.repository.StorageRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository; import io.smallrye.jwt.auth.principal.JWTParser; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -46,4 +48,9 @@ public class UserUseCaseConfig { public UpdateUserProfileUseCase updateUserProfileUseCase(UserRepository userRepository) { return new UpdateUserProfileUseCase(userRepository); } + + @Produces + public UploadUserPhotoUseCase uploadUserPhotoUseCase(UserRepository userRepository, StorageRepository storageRepository) { + return new UploadUserPhotoUseCase(userRepository, storageRepository); + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/storage/MinioStorageService.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/storage/MinioStorageService.java new file mode 100644 index 0000000..9d83a72 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/storage/MinioStorageService.java @@ -0,0 +1,76 @@ +package net.beetechgroup.beetask.frameworks.storage; + +import io.minio.BucketExistsArgs; +import io.minio.MakeBucketArgs; +import io.minio.MinioClient; +import io.minio.PutObjectArgs; +import io.minio.SetBucketPolicyArgs; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import net.beetechgroup.beetask.usecase.repository.StorageRepository; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; + +import java.io.InputStream; + +@ApplicationScoped +public class MinioStorageService implements StorageRepository { + private static final Logger LOGGER = Logger.getLogger(MinioStorageService.class); + + @ConfigProperty(name = "app.storage.endpoint") + String endpoint; + + @ConfigProperty(name = "app.storage.access-key") + String accessKey; + + @ConfigProperty(name = "app.storage.secret-key") + String secretKey; + + @ConfigProperty(name = "app.storage.bucket") + String bucket; + + @ConfigProperty(name = "app.storage.public-url") + String publicUrl; + + private MinioClient minioClient; + + @PostConstruct + void init() { + minioClient = MinioClient.builder() + .endpoint(endpoint) + .credentials(accessKey, secretKey) + .build(); + + try { + boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build()); + if (!exists) { + minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()); + String publicPolicy = """ + {"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::%s/*"]}]} + """.formatted(bucket); + minioClient.setBucketPolicy(SetBucketPolicyArgs.builder().bucket(bucket).config(publicPolicy).build()); + LOGGER.infof("Bucket '%s' created with public read policy", bucket); + } + } catch (Exception e) { + LOGGER.warnf("Could not initialize MinIO bucket '%s': %s", bucket, e.getMessage()); + } + } + + @Override + public String upload(String key, InputStream data, String contentType, long size) { + try { + minioClient.putObject(PutObjectArgs.builder() + .bucket(bucket) + .object(key) + .stream(data, size, -1) + .contentType(contentType) + .build()); + String url = publicUrl + "/" + bucket + "/" + key; + LOGGER.infof("Uploaded object '%s' to bucket '%s'", key, bucket); + return url; + } catch (Exception e) { + LOGGER.errorf("Failed to upload object '%s': %s", key, e.getMessage()); + throw new RuntimeException("Falha ao fazer upload da imagem", e); + } + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/auth/AuthController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/auth/AuthController.java index d47fa44..59c9305 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/auth/AuthController.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/auth/AuthController.java @@ -22,6 +22,10 @@ import net.beetechgroup.beetask.usecase.user.create.CreateUserUseCase; import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileInput; import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileOutput; import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileUseCase; +import net.beetechgroup.beetask.usecase.user.uploadphoto.UploadUserPhotoInput; +import net.beetechgroup.beetask.usecase.user.uploadphoto.UploadUserPhotoOutput; +import net.beetechgroup.beetask.usecase.user.uploadphoto.UploadUserPhotoUseCase; +import org.jboss.resteasy.reactive.MultipartForm; import io.quarkus.security.Authenticated; import io.quarkus.security.identity.SecurityIdentity; @@ -52,6 +56,9 @@ public class AuthController { @Inject UpdateUserProfileUseCase updateUserProfileUseCase; + @Inject + UploadUserPhotoUseCase uploadUserPhotoUseCase; + @Inject SecurityIdentity securityIdentity; @@ -65,6 +72,20 @@ public class AuthController { return getUserProfileUseCase.execute(email); } + @Path("/profile/photo") + @POST + @Authenticated + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "Upload profile photo", description = "Uploads a profile photo (max 5 MB) to MinIO") + public PhotoUploadResponse uploadPhoto(@MultipartForm PhotoUploadRequest request) { + String email = securityIdentity.getPrincipal().getName(); + LOGGER.infof("Photo upload requested for user %s", email); + UploadUserPhotoInput input = AuthControllerMapper.toUploadUserPhotoInput(request, email); + UploadUserPhotoOutput output = uploadUserPhotoUseCase.execute(input); + LOGGER.infof("Photo uploaded successfully for user %s", email); + return AuthControllerMapper.toPhotoUploadResponse(output); + } + @Path("/profile") @PUT @Authenticated diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/auth/AuthControllerMapper.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/auth/AuthControllerMapper.java index 128bf96..11d616d 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/auth/AuthControllerMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/auth/AuthControllerMapper.java @@ -7,6 +7,11 @@ import net.beetechgroup.beetask.usecase.user.create.CreateUserInput; import net.beetechgroup.beetask.usecase.user.create.CreateUserOutput; import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileInput; import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileOutput; +import net.beetechgroup.beetask.usecase.user.uploadphoto.UploadUserPhotoInput; +import net.beetechgroup.beetask.usecase.user.uploadphoto.UploadUserPhotoOutput; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; import java.util.stream.Collectors; public class AuthControllerMapper { @@ -49,4 +54,17 @@ public class AuthControllerMapper { public static UpdateProfileResponse toUpdateProfileResponse(UpdateUserProfileOutput output) { return new UpdateProfileResponse(output.name(), output.email(), output.photo()); } + + public static UploadUserPhotoInput toUploadUserPhotoInput(PhotoUploadRequest request, String email) { + try { + InputStream data = Files.newInputStream(request.photo.uploadedFile()); + return new UploadUserPhotoInput(email, data, request.photo.contentType(), request.photo.size()); + } catch (IOException e) { + throw new RuntimeException("Falha ao ler o arquivo de foto", e); + } + } + + public static PhotoUploadResponse toPhotoUploadResponse(UploadUserPhotoOutput output) { + return new PhotoUploadResponse(output.photoUrl()); + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/auth/PhotoUploadRequest.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/auth/PhotoUploadRequest.java new file mode 100644 index 0000000..f217f70 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/auth/PhotoUploadRequest.java @@ -0,0 +1,10 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.auth; + +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.multipart.FileUpload; + +public class PhotoUploadRequest { + + @RestForm("photo") + public FileUpload photo; +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/auth/PhotoUploadResponse.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/auth/PhotoUploadResponse.java new file mode 100644 index 0000000..963bf83 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/auth/PhotoUploadResponse.java @@ -0,0 +1,3 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.auth; + +public record PhotoUploadResponse(String photoUrl) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/auth/login/LoginUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/auth/login/LoginUseCase.java index d11cffb..d73c6ef 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/auth/login/LoginUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/auth/login/LoginUseCase.java @@ -77,7 +77,7 @@ public class LoginUseCase { return new LoginOutput( user.getName(), user.getEmail(), - Objects.nonNull(user.getPhoto()) ? user.getPhoto() : "https://ui-avatars.com/api/?name=" + user.getName().replace(" ", "+") + "&background=random", + user.getPhoto(), token, refreshToken, TokenConstants.ACCESS_TOKEN_EXPIRY_SECONDS, diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/auth/refresh/RefreshTokenUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/auth/refresh/RefreshTokenUseCase.java index caea0db..9047d3b 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/auth/refresh/RefreshTokenUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/auth/refresh/RefreshTokenUseCase.java @@ -84,7 +84,7 @@ public class RefreshTokenUseCase { return new LoginOutput( user.getName(), user.getEmail(), - Objects.nonNull(user.getPhoto()) ? user.getPhoto() : "https://ui-avatars.com/api/?name=" + user.getName().replace(" ", "+") + "&background=random", + user.getPhoto(), newToken, newRefreshToken, TokenConstants.ACCESS_TOKEN_EXPIRY_SECONDS, diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/StorageRepository.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/StorageRepository.java new file mode 100644 index 0000000..3b5a044 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/StorageRepository.java @@ -0,0 +1,7 @@ +package net.beetechgroup.beetask.usecase.repository; + +import java.io.InputStream; + +public interface StorageRepository { + String upload(String key, InputStream data, String contentType, long size); +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java index f229aa3..0f2ee25 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskMapper.java @@ -33,10 +33,7 @@ public class CreateTaskMapper { } private static String getPhotoUrl(String name, String photo) { - if (Objects.nonNull(photo) && !photo.isBlank()) { - return photo; - } - return "https://ui-avatars.com/api/?name=" + name.replace(" ", "+") + "&background=random"; + return (Objects.nonNull(photo) && !photo.isBlank()) ? photo : null; } private static TaskHistoryItemOutput toTaskHistoryItemOutput(TaskHistoryItem taskHistoryItem) { diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/profile/GetUserProfileUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/profile/GetUserProfileUseCase.java index f785660..62bcbe2 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/profile/GetUserProfileUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/profile/GetUserProfileUseCase.java @@ -47,7 +47,7 @@ public class GetUserProfileUseCase { return new UserProfileOutput( user.getName(), user.getEmail(), - Objects.nonNull(user.getPhoto()) ? user.getPhoto() : "https://ui-avatars.com/api/?name=" + user.getName().replace(" ", "+") + "&background=random", + user.getPhoto(), orgs ); } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/update/UpdateUserProfileMapper.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/update/UpdateUserProfileMapper.java index 4866f4c..5ce0a58 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/update/UpdateUserProfileMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/update/UpdateUserProfileMapper.java @@ -2,14 +2,9 @@ package net.beetechgroup.beetask.usecase.user.update; import net.beetechgroup.beetask.entities.User; -import java.util.Objects; - public class UpdateUserProfileMapper { public static UpdateUserProfileOutput toOutput(User user) { - String photo = Objects.nonNull(user.getPhoto()) - ? user.getPhoto() - : "https://ui-avatars.com/api/?name=" + user.getName().replace(" ", "+") + "&background=random"; - return new UpdateUserProfileOutput(user.getName(), user.getEmail(), photo); + return new UpdateUserProfileOutput(user.getName(), user.getEmail(), user.getPhoto()); } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/update/UpdateUserProfileUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/update/UpdateUserProfileUseCase.java index 912333a..03b04b6 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/update/UpdateUserProfileUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/update/UpdateUserProfileUseCase.java @@ -4,8 +4,6 @@ import net.beetechgroup.beetask.entities.User; import net.beetechgroup.beetask.usecase.repository.UserRepository; import org.jboss.logging.Logger; -import java.util.Objects; -import java.util.Optional; public class UpdateUserProfileUseCase { private static final Logger LOGGER = Logger.getLogger(UpdateUserProfileUseCase.class); @@ -37,10 +35,6 @@ public class UpdateUserProfileUseCase { LOGGER.infof("Profile updated for user %d: name=%s, email=%s", saved.getId(), saved.getName(), saved.getEmail()); - String photo = Objects.nonNull(saved.getPhoto()) - ? saved.getPhoto() - : "https://ui-avatars.com/api/?name=" + saved.getName().replace(" ", "+") + "&background=random"; - - return new UpdateUserProfileOutput(saved.getName(), saved.getEmail(), photo); + return new UpdateUserProfileOutput(saved.getName(), saved.getEmail(), saved.getPhoto()); } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/uploadphoto/UploadUserPhotoInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/uploadphoto/UploadUserPhotoInput.java new file mode 100644 index 0000000..4f01831 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/uploadphoto/UploadUserPhotoInput.java @@ -0,0 +1,5 @@ +package net.beetechgroup.beetask.usecase.user.uploadphoto; + +import java.io.InputStream; + +public record UploadUserPhotoInput(String email, InputStream data, String contentType, long size) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/uploadphoto/UploadUserPhotoOutput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/uploadphoto/UploadUserPhotoOutput.java new file mode 100644 index 0000000..25927b7 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/uploadphoto/UploadUserPhotoOutput.java @@ -0,0 +1,3 @@ +package net.beetechgroup.beetask.usecase.user.uploadphoto; + +public record UploadUserPhotoOutput(String photoUrl) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/uploadphoto/UploadUserPhotoUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/uploadphoto/UploadUserPhotoUseCase.java new file mode 100644 index 0000000..c23219b --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/uploadphoto/UploadUserPhotoUseCase.java @@ -0,0 +1,43 @@ +package net.beetechgroup.beetask.usecase.user.uploadphoto; + +import net.beetechgroup.beetask.entities.User; +import net.beetechgroup.beetask.usecase.repository.StorageRepository; +import net.beetechgroup.beetask.usecase.repository.UserRepository; +import org.jboss.logging.Logger; + +public class UploadUserPhotoUseCase { + private static final Logger LOGGER = Logger.getLogger(UploadUserPhotoUseCase.class); + private static final long MAX_SIZE_BYTES = 5L * 1024 * 1024; + + private final UserRepository userRepository; + private final StorageRepository storageRepository; + + public UploadUserPhotoUseCase(UserRepository userRepository, StorageRepository storageRepository) { + this.userRepository = userRepository; + this.storageRepository = storageRepository; + } + + public UploadUserPhotoOutput execute(UploadUserPhotoInput input) { + if (input.size() > MAX_SIZE_BYTES) { + throw new RuntimeException("Foto deve ter no máximo 5 MB"); + } + if (!input.contentType().startsWith("image/")) { + throw new RuntimeException("O arquivo deve ser uma imagem"); + } + + User user = userRepository.findByEmail(input.email()) + .orElseThrow(() -> { + LOGGER.warnf("Photo upload failed: user %s not found", input.email()); + return new RuntimeException("Usuário não encontrado"); + }); + + String key = String.valueOf(user.getId()); + String photoUrl = storageRepository.upload(key, input.data(), input.contentType(), input.size()); + + user.setPhoto(photoUrl); + userRepository.save(user); + + LOGGER.infof("Photo updated for user %d", user.getId()); + return new UploadUserPhotoOutput(photoUrl); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 1c5fd1a..b4c52d0 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -26,3 +26,10 @@ mp.jwt.verify.issuer=${MP_JWT_VERIFY_ISSUER} smallrye.jwt.sign.key=${SMALLRYE_JWT_SIGN_KEY} mp.jwt.verify.publickey=${MP_JWT_VERIFY_PUBLICKEY} +# MinIO / Object Storage +app.storage.endpoint=${MINIO_ENDPOINT:http://localhost:9000} +app.storage.access-key=${MINIO_ACCESS_KEY:minioadmin} +app.storage.secret-key=${MINIO_SECRET_KEY:minioadmin} +app.storage.bucket=${MINIO_BUCKET:avatars} +app.storage.public-url=${MINIO_PUBLIC_URL:http://localhost:9000} + diff --git a/deploy/.env.production.example b/deploy/.env.production.example index fbe2a17..60a0bc1 100644 --- a/deploy/.env.production.example +++ b/deploy/.env.production.example @@ -9,3 +9,12 @@ QUARKUS_HTTP_CORS_ORIGINS=https://app.example.com MP_JWT_VERIFY_ISSUER=https://beetech.net SMALLRYE_JWT_SIGN_KEY_LOCATION=privateKey.pem MP_JWT_VERIFY_PUBLICKEY_LOCATION=publicKey.pem + +# MinIO (object storage for user avatars) +# MINIO_ROOT_USER and MINIO_ROOT_PASSWORD are used both by the MinIO service +# and by the backend as access/secret keys. +MINIO_ROOT_USER=change-me +MINIO_ROOT_PASSWORD=change-me +MINIO_ENDPOINT=http://minio:9000 +MINIO_PUBLIC_URL=https://minio.task.beetech.dev +MINIO_BUCKET=avatars diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 4bef47e..632bd62 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -29,6 +29,11 @@ services: MP_JWT_VERIFY_ISSUER: ${MP_JWT_VERIFY_ISSUER} SMALLRYE_JWT_SIGN_KEY: ${SMALLRYE_JWT_SIGN_KEY} MP_JWT_VERIFY_PUBLICKEY: ${MP_JWT_VERIFY_PUBLICKEY} + MINIO_ENDPOINT: ${MINIO_ENDPOINT} + MINIO_ACCESS_KEY: ${MINIO_ROOT_USER} + MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD} + MINIO_BUCKET: ${MINIO_BUCKET} + MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL} depends_on: postgres: @@ -52,5 +57,38 @@ services: - "traefik.http.routers.frontend.tls.certresolver=letsencrypt" - "traefik.http.services.frontend.loadbalancer.server.port=80" + minio: + image: minio/minio:latest + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + volumes: + - minio_data:/data + labels: + - "traefik.enable=true" + - "traefik.http.routers.minio.rule=Host(`minio.task.beetech.dev`)" + - "traefik.http.routers.minio.entrypoints=websecure" + - "traefik.http.routers.minio.tls.certresolver=letsencrypt" + - "traefik.http.services.minio.loadbalancer.server.port=9000" + + minio-init: + image: minio/mc:latest + depends_on: + - minio + restart: on-failure + entrypoint: > + /bin/sh -c " + sleep 3 && + mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD && + mc mb --ignore-existing local/avatars && + mc anonymous set download local/avatars + " + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + volumes: postgres_data: + minio_data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a38573c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +services: + postgres: + image: postgres:16 + container_name: bee-task-postgres + restart: unless-stopped + + environment: + POSTGRES_USER: beetask + POSTGRES_PASSWORD: beetask + POSTGRES_DB: beetask + + ports: + - "5432:5432" + + volumes: + - postgres_data:/var/lib/postgresql/data + + healthcheck: + test: ["CMD-SHELL", "pg_isready -U beetask"] + interval: 10s + timeout: 5s + retries: 5 + + minio: + image: minio/minio:latest + restart: unless-stopped + command: server /data --console-address ":9001" + + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + + ports: + - "9000:9000" + - "9001:9001" + + volumes: + - minio_data:/data + + minio-init: + image: minio/mc:latest + depends_on: + - minio + restart: on-failure + entrypoint: > + /bin/sh -c " + sleep 3 && + mc alias set local http://minio:9000 minioadmin minioadmin && + mc mb --ignore-existing local/avatars && + mc anonymous set download local/avatars + " + +volumes: + postgres_data: + minio_data: diff --git a/frontend/src/components/UserAvatar.tsx b/frontend/src/components/UserAvatar.tsx index 97fadf9..beeb7f1 100644 --- a/frontend/src/components/UserAvatar.tsx +++ b/frontend/src/components/UserAvatar.tsx @@ -1,19 +1,23 @@ +import { useState } from 'react' import { cn } from '../lib/utils' interface UserAvatarProps { name: string photo?: string | null - size?: 'sm' | 'md' | 'lg' + size?: 'xs' | 'sm' | 'md' | 'lg' className?: string } const sizeClasses = { + xs: 'h-6 w-6 rounded-lg text-[10px]', sm: 'h-8 w-8 rounded-xl text-xs', md: 'h-10 w-10 rounded-2xl text-sm', lg: 'h-14 w-14 rounded-2xl text-xl', } export function UserAvatar({ name, photo, size = 'md', className }: UserAvatarProps) { + const [imgError, setImgError] = useState(false) + const initials = name .split(' ') .slice(0, 2) @@ -21,13 +25,14 @@ export function UserAvatar({ name, photo, size = 'md', className }: UserAvatarPr .join('') .toUpperCase() - if (photo) { + if (photo && !imgError) { return ( {name} setImgError(true)} /> ) } diff --git a/frontend/src/pages/OrganizationAdminPage.tsx b/frontend/src/pages/OrganizationAdminPage.tsx index d7c98fd..36a7a67 100644 --- a/frontend/src/pages/OrganizationAdminPage.tsx +++ b/frontend/src/pages/OrganizationAdminPage.tsx @@ -6,6 +6,7 @@ import { type JoinRequest, } from "../services/organizationService"; import { useAuth } from "../contexts/AuthContext"; +import { UserAvatar } from "../components/UserAvatar"; export const OrganizationAdminPage: React.FC = () => { const { activeOrg } = useAuth(); @@ -95,13 +96,11 @@ export const OrganizationAdminPage: React.FC = () => { className="p-6 flex flex-col sm:flex-row items-center justify-between gap-6 hover:bg-surface-muted/20 transition-colors" >
- {request.name}

diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 3737939..af84c2c 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -1,32 +1,59 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { motion } from "framer-motion"; -import { User, Mail, Save, CheckCircle, AlertCircle, Loader2 } from "lucide-react"; +import { Mail, Save, CheckCircle, AlertCircle, Loader2, Camera, User } from "lucide-react"; import { useAuth } from "../contexts/AuthContext"; import { userService } from "../services/userService"; import { cn } from "../lib/utils"; +const MAX_PHOTO_SIZE = 5 * 1024 * 1024; + export const ProfilePage: React.FC = () => { const { user, refreshUser } = useAuth(); const [name, setName] = useState(user?.name ?? ""); const [email, setEmail] = useState(user?.email ?? ""); + const [pendingPhoto, setPendingPhoto] = useState(null); + const [photoPreview, setPhotoPreview] = useState(null); const [isSaving, setIsSaving] = useState(false); const [successMsg, setSuccessMsg] = useState(null); const [errorMsg, setErrorMsg] = useState(null); + const fileInputRef = useRef(null); useEffect(() => { setName(user?.name ?? ""); setEmail(user?.email ?? ""); }, [user]); - const initials = (name || user?.name || "?") + const currentPhoto = photoPreview ?? user?.photo ?? null; + const displayInitials = (name || user?.name || "?") .split(" ") .slice(0, 2) .map((w) => w[0]) .join("") .toUpperCase(); - const hasChanges = name !== user?.name || email !== user?.email; + const hasChanges = + name !== user?.name || email !== user?.email || pendingPhoto !== null; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (file.size > MAX_PHOTO_SIZE) { + setErrorMsg("A foto deve ter no máximo 5 MB."); + e.target.value = ""; + return; + } + if (!file.type.startsWith("image/")) { + setErrorMsg("Selecione um arquivo de imagem."); + e.target.value = ""; + return; + } + + setErrorMsg(null); + setPendingPhoto(file); + setPhotoPreview(URL.createObjectURL(file)); + }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -37,8 +64,13 @@ export const ProfilePage: React.FC = () => { setErrorMsg(null); try { + if (pendingPhoto) { + await userService.uploadPhoto(pendingPhoto); + } await userService.updateProfile({ name: name.trim(), email: email.trim() }); await refreshUser(); + setPendingPhoto(null); + setPhotoPreview(null); setSuccessMsg("Perfil atualizado com sucesso!"); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Erro ao atualizar perfil."; @@ -63,14 +95,44 @@ export const ProfilePage: React.FC = () => {

- {/* Avatar */} + {/* Avatar com upload */}
-
- {initials} -
+ + + +

{user?.name}

{user?.email}

+

+ Clique na foto para alterar · máx. 5 MB +

diff --git a/frontend/src/services/userService.ts b/frontend/src/services/userService.ts index ce6171c..c001343 100644 --- a/frontend/src/services/userService.ts +++ b/frontend/src/services/userService.ts @@ -11,6 +11,10 @@ export interface UpdateProfileResult { photo: string } +export interface UploadPhotoResult { + photoUrl: string +} + export const userService = { updateProfile: async (payload: UpdateProfilePayload): Promise => { return apiFetch('/auth/profile', { @@ -18,4 +22,13 @@ export const userService = { body: payload, }) }, + + uploadPhoto: async (file: File): Promise => { + const form = new FormData() + form.append('photo', file) + return apiFetch('/auth/profile/photo', { + method: 'POST', + body: form, + }) + }, }