diff --git a/.gitignore b/.gitignore index 7ed0d6b6..700903d4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ target/ !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ +/.mvn/ +/.tools/ ### STS ### .apt_generated diff --git a/README.md b/README.md index 2fbc4ffa..144337f2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,15 @@ # MyPortfolios GEIADE API +## Run locally + +On Windows PowerShell, use the Maven wrapper included in this repository: + +```powershell +.\mvnw.cmd spring-boot:run +``` + +If you prefer a global Maven installation, `mvn spring-boot:run` also works once `mvn` is available on your `PATH`. + Template for a Spring Boot project including Spring REST, HATEOAS, JPA, etc. Additional details: [HELP.md](HELP.md) [![Open Issues](https://img.shields.io/github/issues-raw/UdL-EPS-SoftArch/MyPortfoliosGEIADE-API?logo=github)](https://github.com/orgs/UdL-EPS-SoftArch/projects/28) diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 00000000..1ab336d9 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,36 @@ +@echo off +setlocal + +set "BASE_DIR=%~dp0" +set "WRAPPER_PROPS=%BASE_DIR%.mvn\wrapper\maven-wrapper.properties" +set "DIST_URL=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip" + +if exist "%WRAPPER_PROPS%" ( + for /f "usebackq tokens=1,* delims==" %%A in ("%WRAPPER_PROPS%") do ( + if /i "%%A"=="distributionUrl" set "DIST_URL=%%B" + ) +) + +for %%F in ("%DIST_URL%") do set "ARCHIVE_NAME=%%~nxF" +set "MAVEN_VERSION=%ARCHIVE_NAME:-bin.zip=%" +set "MAVEN_VERSION=%MAVEN_VERSION:apache-maven-=%" +set "INSTALL_ROOT=%USERPROFILE%\.m2\wrapper\dists" +set "ARCHIVE_PATH=%INSTALL_ROOT%\%ARCHIVE_NAME%" +set "MAVEN_HOME=%INSTALL_ROOT%\apache-maven-%MAVEN_VERSION%" +set "MVN_CMD=%MAVEN_HOME%\bin\mvn.cmd" + +if not exist "%MVN_CMD%" ( + if not exist "%INSTALL_ROOT%" mkdir "%INSTALL_ROOT%" + echo Downloading Maven %MAVEN_VERSION%... + powershell -NoProfile -ExecutionPolicy Bypass -Command ^ + "$ProgressPreference='SilentlyContinue';" ^ + "Invoke-WebRequest -Uri '%DIST_URL%' -OutFile '%ARCHIVE_PATH%';" ^ + "Expand-Archive -LiteralPath '%ARCHIVE_PATH%' -DestinationPath '%INSTALL_ROOT%' -Force" + if errorlevel 1 ( + echo Failed to download Maven from %DIST_URL% + exit /b 1 + ) +) + +call "%MVN_CMD%" %* +exit /b %ERRORLEVEL% diff --git a/src/main/java/cat/udl/eps/softarch/demo/config/WebSecurityConfig.java b/src/main/java/cat/udl/eps/softarch/demo/config/WebSecurityConfig.java index 6d914d7b..b2c3a4f8 100644 --- a/src/main/java/cat/udl/eps/softarch/demo/config/WebSecurityConfig.java +++ b/src/main/java/cat/udl/eps/softarch/demo/config/WebSecurityConfig.java @@ -34,28 +34,35 @@ protected SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exce .requestMatchers(HttpMethod.POST, "/users").anonymous() .requestMatchers(HttpMethod.GET, "/users/{username}").anonymous() .requestMatchers(HttpMethod.POST, "/users/*").denyAll() - //Admins + // Admins .requestMatchers(HttpMethod.GET, "/admins").hasRole("ADMIN") .requestMatchers(HttpMethod.POST, "/admins").hasRole("ADMIN") .requestMatchers(HttpMethod.GET, "/admins/{username}").hasRole("ADMIN") .requestMatchers(HttpMethod.POST, "/admins/*/suspend").hasRole("ADMIN") .requestMatchers(HttpMethod.POST, "/admins/*").denyAll() - //Creators + // Creators .requestMatchers(HttpMethod.GET, "/creators").permitAll() .requestMatchers(HttpMethod.POST, "/creators").permitAll() .requestMatchers(HttpMethod.GET, "/creators/{username}").permitAll() .requestMatchers(HttpMethod.PUT, "/creators/{username}").hasRole("ADMIN") .requestMatchers(HttpMethod.POST, "/creators/*").hasRole("ADMIN") - //Projects + + // Projects + .requestMatchers(HttpMethod.GET, "/projects/search/findByVisibility").permitAll() + .requestMatchers(HttpMethod.GET, "/projects/search/findByPortfolioAndVisibility").permitAll() + .requestMatchers(HttpMethod.GET, "/projects/**").authenticated() .requestMatchers(HttpMethod.POST, "/projects").authenticated() .requestMatchers(HttpMethod.PUT, "/projects/*").authenticated() + .requestMatchers(HttpMethod.PATCH, "/projects/*").authenticated() .requestMatchers(HttpMethod.DELETE, "/projects/*").authenticated() - //Portfolios + // Portfolios .requestMatchers(HttpMethod.GET, "/portfolios/search/findByVisibility").permitAll() + .requestMatchers(HttpMethod.GET, "/portfolios/*/owner").permitAll() .requestMatchers(HttpMethod.GET, "/portfolios/**").authenticated() - //Profile + // Profile .requestMatchers(HttpMethod.POST, "/profiles").hasRole("CREATOR") - //Default + // Default + .requestMatchers(HttpMethod.POST, "/*/*").authenticated() .requestMatchers(HttpMethod.PUT, "/*/*").authenticated() .requestMatchers(HttpMethod.PATCH, "/*/*").authenticated() diff --git a/src/main/java/cat/udl/eps/softarch/demo/domain/Project.java b/src/main/java/cat/udl/eps/softarch/demo/domain/Project.java index 7f31560c..ec599795 100644 --- a/src/main/java/cat/udl/eps/softarch/demo/domain/Project.java +++ b/src/main/java/cat/udl/eps/softarch/demo/domain/Project.java @@ -1,5 +1,6 @@ package cat.udl.eps.softarch.demo.domain; +import com.fasterxml.jackson.annotation.JsonIdentityReference; import jakarta.persistence.*; import jakarta.validation.constraints.NotBlank; import lombok.Data; @@ -29,10 +30,10 @@ public class Project extends UriEntity { private ZonedDateTime modified; - // Relació amb Portfolio (segons el diagrama, un Project pertany a un Portfolio) - //@ManyToOne - //@JoinColumn(name = "portfolio_id") - //private Portfolio portfolio; + @ManyToOne(optional = false) + @JoinColumn(name = "portfolio_id", nullable = false) + @JsonIdentityReference(alwaysAsId = true) + private Portfolio portfolio; public Project() { this.created = ZonedDateTime.now(); @@ -58,8 +59,4 @@ public void setModified(ZonedDateTime modified) { public void setDescription(String description) {this.description = description;} - //public void setPortfolio(Portfolio portfolio) { - //this.portfolio = portfolio; - //} - -} \ No newline at end of file +} diff --git a/src/main/java/cat/udl/eps/softarch/demo/handler/ProjectEventHandler.java b/src/main/java/cat/udl/eps/softarch/demo/handler/ProjectEventHandler.java index e2d06c53..d71d14e8 100644 --- a/src/main/java/cat/udl/eps/softarch/demo/handler/ProjectEventHandler.java +++ b/src/main/java/cat/udl/eps/softarch/demo/handler/ProjectEventHandler.java @@ -1,31 +1,77 @@ package cat.udl.eps.softarch.demo.handler; +import cat.udl.eps.softarch.demo.domain.Portfolio; import cat.udl.eps.softarch.demo.domain.Project; -import cat.udl.eps.softarch.demo.repository.ProjectRepository; -import org.springframework.data.rest.core.annotation.*; +import cat.udl.eps.softarch.demo.repository.PortfolioRepository; +import org.springframework.data.rest.core.annotation.HandleBeforeCreate; +import org.springframework.data.rest.core.annotation.HandleBeforeDelete; +import org.springframework.data.rest.core.annotation.HandleBeforeSave; +import org.springframework.data.rest.core.annotation.RepositoryEventHandler; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; import java.time.ZonedDateTime; @Component -@RepositoryEventHandler +@RepositoryEventHandler(Project.class) public class ProjectEventHandler { - final ProjectRepository projectRepository; + private final PortfolioRepository portfolioRepository; - public ProjectEventHandler(ProjectRepository projectRepository) { - this.projectRepository = projectRepository; + public ProjectEventHandler(PortfolioRepository portfolioRepository) { + this.portfolioRepository = portfolioRepository; } @HandleBeforeCreate public void handleProjectPreCreate(Project project) { + String username = requireAuthenticatedUsername(); + Portfolio portfolio = requireOwnedPortfolio(project, username); + ZonedDateTime timeStamp = ZonedDateTime.now(); + project.setPortfolio(portfolio); project.setCreated(timeStamp); project.setModified(timeStamp); } @HandleBeforeSave public void handleProjectPreSave(Project project) { - ZonedDateTime timeStamp = ZonedDateTime.now(); - project.setModified(timeStamp); + String username = requireAuthenticatedUsername(); + Portfolio portfolio = requireOwnedPortfolio(project, username); + + project.setPortfolio(portfolio); + project.setModified(ZonedDateTime.now()); + } + + @HandleBeforeDelete + public void handleProjectPreDelete(Project project) { + String username = requireAuthenticatedUsername(); + requireOwnedPortfolio(project, username); + } + + private String requireAuthenticatedUsername() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + if (auth == null || !auth.isAuthenticated() || auth.getName().equals("anonymousUser")) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User must be logged in"); + } + + return auth.getName(); + } + + private Portfolio requireOwnedPortfolio(Project project, String username) { + if (project.getPortfolio() == null || project.getPortfolio().getId() == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Project must belong to a portfolio"); + } + + Portfolio portfolio = portfolioRepository.findById(project.getPortfolio().getId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Portfolio does not exist")); + + if (portfolio.getOwner() == null || !portfolio.getOwner().getUsername().equals(username)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You cannot manage projects in another user's portfolio"); + } + + return portfolio; } -} \ No newline at end of file +} diff --git a/src/main/java/cat/udl/eps/softarch/demo/repository/ProjectRepository.java b/src/main/java/cat/udl/eps/softarch/demo/repository/ProjectRepository.java index 33b01220..42bd7168 100644 --- a/src/main/java/cat/udl/eps/softarch/demo/repository/ProjectRepository.java +++ b/src/main/java/cat/udl/eps/softarch/demo/repository/ProjectRepository.java @@ -1,7 +1,8 @@ package cat.udl.eps.softarch.demo.repository; +import cat.udl.eps.softarch.demo.domain.Portfolio; import cat.udl.eps.softarch.demo.domain.Project; -import cat.udl.eps.softarch.demo.domain.User; +import cat.udl.eps.softarch.demo.domain.Visibility; import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.PagingAndSortingRepository; import org.springframework.data.repository.query.Param; @@ -13,5 +14,11 @@ public interface ProjectRepository extends CrudRepository, PagingAndSortingRepository { List findByNameContaining(@Param("name") String name); - -} \ No newline at end of file + + List findByPortfolio(@Param("portfolio") Portfolio portfolio); + + List findByPortfolioAndVisibility(@Param("portfolio") Portfolio portfolio, + @Param("visibility") Visibility visibility); + + List findByVisibility(@Param("visibility") Visibility visibility); +} diff --git a/src/test/java/cat/udl/eps/softarch/demo/steps/ManageProjectStepDefs.java b/src/test/java/cat/udl/eps/softarch/demo/steps/ManageProjectStepDefs.java index 8b78f25b..b1775056 100644 --- a/src/test/java/cat/udl/eps/softarch/demo/steps/ManageProjectStepDefs.java +++ b/src/test/java/cat/udl/eps/softarch/demo/steps/ManageProjectStepDefs.java @@ -1,8 +1,12 @@ package cat.udl.eps.softarch.demo.steps; import cat.udl.eps.softarch.demo.domain.Project; +import cat.udl.eps.softarch.demo.domain.Portfolio; +import cat.udl.eps.softarch.demo.domain.User; import cat.udl.eps.softarch.demo.domain.Visibility; +import cat.udl.eps.softarch.demo.repository.PortfolioRepository; import cat.udl.eps.softarch.demo.repository.ProjectRepository; +import cat.udl.eps.softarch.demo.repository.UserRepository; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import io.cucumber.java.en.And; import io.cucumber.java.en.Given; @@ -24,11 +28,18 @@ public class ManageProjectStepDefs { private final StepDefs stepDefs; private final ProjectRepository projectRepository; + private final PortfolioRepository portfolioRepository; + private final UserRepository userRepository; private Project preparedProject; - public ManageProjectStepDefs(StepDefs stepDefs, ProjectRepository projectRepository) { + public ManageProjectStepDefs(StepDefs stepDefs, + ProjectRepository projectRepository, + PortfolioRepository portfolioRepository, + UserRepository userRepository) { this.stepDefs = stepDefs; this.projectRepository = projectRepository; + this.portfolioRepository = portfolioRepository; + this.userRepository = userRepository; this.stepDefs.mapper.registerModule(new JavaTimeModule()); } @@ -40,6 +51,7 @@ public void iPrepareAProject(String name, String description, String visibility) preparedProject.setName(name); preparedProject.setDescription(description); preparedProject.setVisibility(Visibility.valueOf(visibility)); + preparedProject.setPortfolio(createPortfolioForCurrentContext("prepared-" + normalizeName(name))); } @And("There is a project with name {string} and description {string} and visibility {string}") @@ -48,6 +60,7 @@ public void thereIsAProject(String name, String description, String visibility) p.setName(name); p.setDescription(description); p.setVisibility(Visibility.valueOf(visibility)); + p.setPortfolio(createPortfolioForCurrentContext("stored-" + normalizeName(name))); projectRepository.save(p); } @@ -229,5 +242,34 @@ public void theProjectListIsEmpty() throws Exception { stepDefs.result.andExpect(jsonPath("$._embedded.projects", empty())); } + private Portfolio createPortfolioForCurrentContext(String baseName) { + String username = AuthenticationStepDefs.currentUsername; + if (username == null || username.isBlank()) { + username = "project-owner"; + } + final String ownerUsername = username; + + User owner = userRepository.findById(ownerUsername).orElseGet(() -> { + User user = new User(); + user.setId(ownerUsername); + user.setEmail(ownerUsername + "@sample.app"); + user.setPassword("password"); + user.encodePassword(); + return userRepository.save(user); + }); + + Portfolio portfolio = new Portfolio(); + portfolio.setName(baseName + "-portfolio"); + portfolio.setVisibility(Visibility.PUBLIC); + portfolio.setOwner(owner); + return portfolioRepository.save(portfolio); + } + + private String normalizeName(String value) { + if (value == null || value.isBlank()) { + return "project"; + } + return value.toLowerCase().replaceAll("[^a-z0-9]+", "-"); + } -} \ No newline at end of file +}