diff --git a/src/main/java/net/tokishu/note/annotation/CurrentUser.java b/src/main/java/net/tokishu/note/annotation/CurrentUser.java new file mode 100644 index 0000000..088710e --- /dev/null +++ b/src/main/java/net/tokishu/note/annotation/CurrentUser.java @@ -0,0 +1,9 @@ +package net.tokishu.note.annotation; + +import java.lang.annotation.*; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface CurrentUser { +} diff --git a/src/main/java/net/tokishu/note/annotation/NullableCurrentUser.java b/src/main/java/net/tokishu/note/annotation/NullableCurrentUser.java new file mode 100644 index 0000000..7576d74 --- /dev/null +++ b/src/main/java/net/tokishu/note/annotation/NullableCurrentUser.java @@ -0,0 +1,9 @@ +package net.tokishu.note.annotation; + +import java.lang.annotation.*; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface NullableCurrentUser { +} diff --git a/src/main/java/net/tokishu/note/config/WebConfig.java b/src/main/java/net/tokishu/note/config/WebConfig.java new file mode 100644 index 0000000..f1992f5 --- /dev/null +++ b/src/main/java/net/tokishu/note/config/WebConfig.java @@ -0,0 +1,24 @@ +package net.tokishu.note.config; + +import lombok.RequiredArgsConstructor; +import net.tokishu.note.resolver.CurrentUserArgumentResolver; +import net.tokishu.note.resolver.NullableCurrentUserArgumentResolver; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final CurrentUserArgumentResolver currentUserArgumentResolver; + private final NullableCurrentUserArgumentResolver nullableCurrentUserArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(currentUserArgumentResolver); + resolvers.add(nullableCurrentUserArgumentResolver); + } +} \ No newline at end of file diff --git a/src/main/java/net/tokishu/note/controller/AuthController.java b/src/main/java/net/tokishu/note/controller/AuthController.java index 596a74d..8a0af11 100644 --- a/src/main/java/net/tokishu/note/controller/AuthController.java +++ b/src/main/java/net/tokishu/note/controller/AuthController.java @@ -26,7 +26,6 @@ import java.util.Map; public class AuthController { public final AuthService authService; - public final UserService userService; @PostMapping("/register") public ResponseEntity register(@RequestBody @Valid RegisterRequest request) { diff --git a/src/main/java/net/tokishu/note/controller/NotesController.java b/src/main/java/net/tokishu/note/controller/NotesController.java index 8aed389..50414fb 100644 --- a/src/main/java/net/tokishu/note/controller/NotesController.java +++ b/src/main/java/net/tokishu/note/controller/NotesController.java @@ -2,8 +2,11 @@ package net.tokishu.note.controller; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import net.tokishu.note.annotation.CurrentUser; +import net.tokishu.note.annotation.NullableCurrentUser; import net.tokishu.note.dto.request.NoteRequest; import net.tokishu.note.dto.response.NoteResponse; +import net.tokishu.note.model.User; import net.tokishu.note.service.NoteService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -23,30 +26,30 @@ public class NotesController { private final NoteService noteService; @GetMapping - public ResponseEntity> getAll(){ - return ResponseEntity.ok(noteService.getAll()); + public ResponseEntity> getAll(@CurrentUser User sender){ + return ResponseEntity.ok(noteService.getAll(sender)); } @GetMapping("/{idOrCode}") - public NoteResponse find(@PathVariable String idOrCode) { - return noteService.findByIdOrPublicLink(idOrCode); + public NoteResponse find(@PathVariable String idOrCode, @NullableCurrentUser User sender) { + return noteService.findByIdOrPublicLink(idOrCode, sender); } @PostMapping() - public ResponseEntity> add(@RequestBody @Valid NoteRequest note){ - noteService.add(note); + public ResponseEntity> add(@RequestBody @Valid NoteRequest note, @CurrentUser User sender){ + noteService.add(note, sender); return ResponseEntity.status(HttpStatus.CREATED).body(Map.of("message", "Note added")); } @PutMapping("/{uuid}") - public ResponseEntity update(@PathVariable("uuid") UUID uuid, @RequestBody @Valid NoteRequest note){ - noteService.update(uuid, note); + public ResponseEntity update(@PathVariable("uuid") UUID uuid, @RequestBody @Valid NoteRequest note, @CurrentUser User sender){ + noteService.update(uuid, note, sender); return ResponseEntity.ok(Map.of("message", "Note updated")); } @DeleteMapping("/{uuid}") - public ResponseEntity delete(@PathVariable UUID uuid){ - noteService.delete(uuid); + public ResponseEntity delete(@PathVariable UUID uuid, @CurrentUser User sender){ + noteService.delete(uuid, sender); return ResponseEntity.ok(Map.of("message", "Note deleted")); } } diff --git a/src/main/java/net/tokishu/note/controller/UserController.java b/src/main/java/net/tokishu/note/controller/UserController.java index b3c8fdb..f461029 100644 --- a/src/main/java/net/tokishu/note/controller/UserController.java +++ b/src/main/java/net/tokishu/note/controller/UserController.java @@ -1,12 +1,79 @@ package net.tokishu.note.controller; +import jakarta.validation.Valid; +import lombok.Builder; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import net.tokishu.note.annotation.CurrentUser; +import net.tokishu.note.dto.request.ChangePasswordRequest; +import net.tokishu.note.dto.request.UpdateProfileRequest; +import net.tokishu.note.dto.request.UserByAdminRequest; +import net.tokishu.note.dto.response.ApiResponse; +import net.tokishu.note.dto.response.UserResponse; +import net.tokishu.note.model.User; +import net.tokishu.note.service.UserService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; @RequiredArgsConstructor @RestController @RequestMapping("/users") public class UserController { + + private final UserService userService; + + @GetMapping("/me") + public UserResponse me(@CurrentUser User sender){ + return userService.getCurrentUserResponse(sender); + } + + @PutMapping("/me") + public UserResponse updateMyProfile(@RequestBody @Valid UpdateProfileRequest request, @CurrentUser User sender) { + return userService.updateCurrentUser(request, sender); + } + + @PutMapping("/me/password") + public ApiResponse changePassword(@RequestBody @Valid ChangePasswordRequest request, @CurrentUser User sender) { + // userService.changePassword(request); + return ApiResponse.builder().status(200).message("JoJ").build(); + } + + + + /* + ADMIN ACTIONS + */ + +// @PostMapping("/new") +// @PreAuthorize("hasAuthority('ADMIN')") +// public ResponseEntity newUser(@RequestBody @Valid UserByAdminRequest request, @CurrentUser User sender){ +// return userService.createUserByAdmin(request); +// } +// +// @GetMapping +// @PreAuthorize("hasAuthority('ADMIN')") +// public ResponseEntity> getAllUsers(@CurrentUser User sender) { +// return userService.getAllUsers(); +// } +// +// @GetMapping("/{username}") +// @PreAuthorize("hasAuthority('ADMIN')") +// public UserResponse getUserByUsername(@PathVariable String username, @CurrentUser User sender) { +// return userService.getUserByUsername(username); +// } +// +// +// @PutMapping("/{username}") +// @PreAuthorize("hasAuthority('ADMIN')") +// public UserResponse editUser(@RequestBody @Valid UserByAdminRequest request, @PathVariable String username, @CurrentUser User sender){ +// return userService.editUser(username, request); +// } +// +// @DeleteMapping("/{username}") +// @PreAuthorize("hasAuthority('ADMIN')") +// public ApiResponse removeUser(@PathVariable String username, @CurrentUser User sender){ +// return userService.deleteUser(username); +// } } diff --git a/src/main/java/net/tokishu/note/dto/request/ChangePasswordRequest.java b/src/main/java/net/tokishu/note/dto/request/ChangePasswordRequest.java new file mode 100644 index 0000000..1beddd4 --- /dev/null +++ b/src/main/java/net/tokishu/note/dto/request/ChangePasswordRequest.java @@ -0,0 +1,13 @@ +package net.tokishu.note.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class ChangePasswordRequest { + @NotBlank + private String oldPassword; + + @NotBlank + private String newPassword; +} diff --git a/src/main/java/net/tokishu/note/dto/request/RegisterRequest.java b/src/main/java/net/tokishu/note/dto/request/RegisterRequest.java index c2045b9..fff7cda 100644 --- a/src/main/java/net/tokishu/note/dto/request/RegisterRequest.java +++ b/src/main/java/net/tokishu/note/dto/request/RegisterRequest.java @@ -1,16 +1,19 @@ package net.tokishu.note.dto.request; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.Data; @Data public class RegisterRequest { - @NotBlank - @Size(min=1, max=24) + @NotBlank() + @Size(min = 3, max = 24) + @Pattern(regexp = "^[a-zA-Z0-9_-]+$", + message = "The username can only contain letters, numbers, symbols and hyphens.") private String username; - @NotBlank - @Size(min=6, max=42) + @NotBlank() + @Size(min = 6, max = 42) private String password; -} +} \ No newline at end of file diff --git a/src/main/java/net/tokishu/note/dto/request/UpdateProfileRequest.java b/src/main/java/net/tokishu/note/dto/request/UpdateProfileRequest.java new file mode 100644 index 0000000..a67a190 --- /dev/null +++ b/src/main/java/net/tokishu/note/dto/request/UpdateProfileRequest.java @@ -0,0 +1,18 @@ +package net.tokishu.note.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class UpdateProfileRequest { + @NotBlank() + @Size(min = 3, max = 24) + @Pattern(regexp = "^[a-zA-Z0-9_-]+$", + message = "The username can only contain letters, numbers, symbols and hyphens.") + private String username; + + @NotBlank() + private String gravatarEmail; +} diff --git a/src/main/java/net/tokishu/note/dto/request/UserByAdminRequest.java b/src/main/java/net/tokishu/note/dto/request/UserByAdminRequest.java new file mode 100644 index 0000000..9e7bd12 --- /dev/null +++ b/src/main/java/net/tokishu/note/dto/request/UserByAdminRequest.java @@ -0,0 +1,23 @@ +package net.tokishu.note.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; +import net.tokishu.note.model.UserRole; + +@Data +public class UserByAdminRequest { + @NotBlank() + @Size(min = 3, max = 24) + @Pattern(regexp = "^[a-zA-Z0-9_-]+$", + message = "The username can only contain letters, numbers, symbols and hyphens.") + private String username; + + @NotBlank() + @Size(min = 6, max = 42) + private String password; + + @NotBlank + private UserRole role; +} \ No newline at end of file diff --git a/src/main/java/net/tokishu/note/dto/response/ApiErrorResponse.java b/src/main/java/net/tokishu/note/dto/response/ApiResponse.java similarity index 84% rename from src/main/java/net/tokishu/note/dto/response/ApiErrorResponse.java rename to src/main/java/net/tokishu/note/dto/response/ApiResponse.java index fa576d8..a0db73b 100644 --- a/src/main/java/net/tokishu/note/dto/response/ApiErrorResponse.java +++ b/src/main/java/net/tokishu/note/dto/response/ApiResponse.java @@ -5,7 +5,7 @@ import lombok.Getter; @Getter @Builder -public class ApiErrorResponse { +public class ApiResponse { private final int status; private final String message; } \ No newline at end of file diff --git a/src/main/java/net/tokishu/note/dto/response/LoginResponse.java b/src/main/java/net/tokishu/note/dto/response/LoginResponse.java index 13633eb..98aa216 100644 --- a/src/main/java/net/tokishu/note/dto/response/LoginResponse.java +++ b/src/main/java/net/tokishu/note/dto/response/LoginResponse.java @@ -2,11 +2,12 @@ package net.tokishu.note.dto.response; import lombok.Builder; import lombok.Data; +import net.tokishu.note.model.UserRole; @Data @Builder public class LoginResponse { private String token; private String username; - private String role; + private UserRole role; } diff --git a/src/main/java/net/tokishu/note/dto/response/RegisterResponse.java b/src/main/java/net/tokishu/note/dto/response/RegisterResponse.java index 7807ee5..8d1c3ef 100644 --- a/src/main/java/net/tokishu/note/dto/response/RegisterResponse.java +++ b/src/main/java/net/tokishu/note/dto/response/RegisterResponse.java @@ -2,11 +2,13 @@ package net.tokishu.note.dto.response; import lombok.Builder; import lombok.Data; +import net.tokishu.note.model.UserRole; + @Data @Builder public class RegisterResponse { private String username; - private String role; + private UserRole role; private String message; } diff --git a/src/main/java/net/tokishu/note/dto/response/RootResponse.java b/src/main/java/net/tokishu/note/dto/response/RootResponse.java index 4a71157..965cbe8 100644 --- a/src/main/java/net/tokishu/note/dto/response/RootResponse.java +++ b/src/main/java/net/tokishu/note/dto/response/RootResponse.java @@ -2,11 +2,12 @@ package net.tokishu.note.dto.response; import lombok.Builder; import lombok.Data; +import net.tokishu.note.model.UserRole; @Data @Builder public class RootResponse { private String username; private String password; - private String role; + private UserRole role; } diff --git a/src/main/java/net/tokishu/note/dto/response/UserResponse.java b/src/main/java/net/tokishu/note/dto/response/UserResponse.java index 6219f1f..437368c 100644 --- a/src/main/java/net/tokishu/note/dto/response/UserResponse.java +++ b/src/main/java/net/tokishu/note/dto/response/UserResponse.java @@ -2,10 +2,12 @@ package net.tokishu.note.dto.response; import lombok.Data; import lombok.Builder; +import net.tokishu.note.model.UserRole; @Data @Builder public class UserResponse { private String username; - private String role; + private String gravatarUrl; + private UserRole role; } \ No newline at end of file diff --git a/src/main/java/net/tokishu/note/exception/GlobalExceptionHandler.java b/src/main/java/net/tokishu/note/exception/GlobalExceptionHandler.java index b6e4ae7..1dbb4c6 100644 --- a/src/main/java/net/tokishu/note/exception/GlobalExceptionHandler.java +++ b/src/main/java/net/tokishu/note/exception/GlobalExceptionHandler.java @@ -1,20 +1,17 @@ package net.tokishu.note.exception; -import jakarta.servlet.http.HttpServletRequest; -import net.tokishu.note.dto.response.ApiErrorResponse; +import net.tokishu.note.dto.response.ApiResponse; import org.springframework.http.ResponseEntity; -import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.server.ResponseStatusException; -import org.springframework.web.servlet.NoHandlerFoundException; @ControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) - public ResponseEntity handleAll(Exception ex) { + public ResponseEntity handleAll(Exception ex) { if (ex instanceof MethodArgumentNotValidException validationEx) { return handleValidation(validationEx); } @@ -49,7 +46,7 @@ public class GlobalExceptionHandler { } @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { String message = ex.getBindingResult().getFieldErrors().stream() .map(error -> error.getField() + ": " + error.getDefaultMessage()) .findFirst() @@ -59,10 +56,10 @@ public class GlobalExceptionHandler { } - private ResponseEntity buildErrorResponse(int status, String message) { + private ResponseEntity buildErrorResponse(int status, String message) { return ResponseEntity .status(status) - .body(ApiErrorResponse.builder() + .body(ApiResponse.builder() .status(status) .message(message != null ? message : "Unexpected error") .build()); diff --git a/src/main/java/net/tokishu/note/exception/SecurityExceptionHandler.java b/src/main/java/net/tokishu/note/exception/SecurityExceptionHandler.java index 4712e70..d7770f1 100644 --- a/src/main/java/net/tokishu/note/exception/SecurityExceptionHandler.java +++ b/src/main/java/net/tokishu/note/exception/SecurityExceptionHandler.java @@ -2,7 +2,7 @@ package net.tokishu.note.exception; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import net.tokishu.note.dto.response.ApiErrorResponse; +import net.tokishu.note.dto.response.ApiResponse; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.http.MediaType; import org.springframework.security.access.AccessDeniedException; @@ -20,7 +20,7 @@ public class SecurityExceptionHandler implements AuthenticationEntryPoint, Acces private void writeErrorResponse(HttpServletResponse response, int status, String message) throws IOException { response.setStatus(status); response.setContentType(MediaType.APPLICATION_JSON_VALUE); - ApiErrorResponse body = ApiErrorResponse.builder() + ApiResponse body = ApiResponse.builder() .status(status) .message(message) .build(); diff --git a/src/main/java/net/tokishu/note/model/User.java b/src/main/java/net/tokishu/note/model/User.java index 25be19d..93fb155 100644 --- a/src/main/java/net/tokishu/note/model/User.java +++ b/src/main/java/net/tokishu/note/model/User.java @@ -1,6 +1,7 @@ package net.tokishu.note.model; import jakarta.persistence.*; +import jakarta.validation.constraints.Email; import lombok.Data; import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.CreationTimestamp; @@ -13,11 +14,19 @@ import java.util.UUID; @Table(name = "Users") public class User { @Id + @Column(length = 24) private String username; + + @Column(nullable = false) private String password; + @Enumerated(EnumType.STRING) @ColumnDefault("'USER'") - private String role; + private UserRole role; + + @Email + @Column(nullable = true) + private String gravatarEmail; @CreationTimestamp @Column(updatable = false) diff --git a/src/main/java/net/tokishu/note/model/UserRole.java b/src/main/java/net/tokishu/note/model/UserRole.java new file mode 100644 index 0000000..e19aa68 --- /dev/null +++ b/src/main/java/net/tokishu/note/model/UserRole.java @@ -0,0 +1,5 @@ +package net.tokishu.note.model; + +public enum UserRole { + ADMIN, USER +} \ No newline at end of file diff --git a/src/main/java/net/tokishu/note/repo/NoteRepository.java b/src/main/java/net/tokishu/note/repo/NoteRepository.java index ba43185..0cd434d 100644 --- a/src/main/java/net/tokishu/note/repo/NoteRepository.java +++ b/src/main/java/net/tokishu/note/repo/NoteRepository.java @@ -2,6 +2,9 @@ package net.tokishu.note.repo; import net.tokishu.note.model.Note; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -12,4 +15,8 @@ public interface NoteRepository extends JpaRepository { List findByAuthorUsernameOrderByCreatedAtDesc(String username); Optional findByPublicLinkAndIsPublicTrue(String publicLink); boolean existsByPublicLink(String publicLink); + + @Modifying + @Query("UPDATE Note n SET n.author = :newUsername WHERE n.author = :oldUsername") + void updateOwnerUsername(@Param("oldUsername") String oldUsername, @Param("newUsername") String newUsername); } diff --git a/src/main/java/net/tokishu/note/repo/UserRepository.java b/src/main/java/net/tokishu/note/repo/UserRepository.java index 9a90f8b..8a79297 100644 --- a/src/main/java/net/tokishu/note/repo/UserRepository.java +++ b/src/main/java/net/tokishu/note/repo/UserRepository.java @@ -1,10 +1,19 @@ package net.tokishu.note.repo; import net.tokishu.note.model.User; +import net.tokishu.note.model.UserRole; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.UUID; public interface UserRepository extends JpaRepository { - boolean existsByRole(String role); + boolean existsByRole(UserRole role); + + @Modifying + @Query("UPDATE User u SET u.username = :newUsername WHERE u.username = :oldUsername") + void updateUsername(@Param("oldUsername") String oldUsername, @Param("newUsername") String newUsername); + } diff --git a/src/main/java/net/tokishu/note/resolver/CurrentUserArgumentResolver.java b/src/main/java/net/tokishu/note/resolver/CurrentUserArgumentResolver.java new file mode 100644 index 0000000..a783f82 --- /dev/null +++ b/src/main/java/net/tokishu/note/resolver/CurrentUserArgumentResolver.java @@ -0,0 +1,33 @@ +package net.tokishu.note.resolver; + +import net.tokishu.note.annotation.CurrentUser; +import lombok.RequiredArgsConstructor; +import net.tokishu.note.model.User; +import net.tokishu.note.service.AuthService; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +@RequiredArgsConstructor +public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { + + private final AuthService authService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class) + && parameter.getParameterType().equals(User.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + return authService.getCurrentUser(); + } +} diff --git a/src/main/java/net/tokishu/note/resolver/NullableCurrentUserArgumentResolver.java b/src/main/java/net/tokishu/note/resolver/NullableCurrentUserArgumentResolver.java new file mode 100644 index 0000000..50dd4d9 --- /dev/null +++ b/src/main/java/net/tokishu/note/resolver/NullableCurrentUserArgumentResolver.java @@ -0,0 +1,33 @@ +package net.tokishu.note.resolver; + +import lombok.RequiredArgsConstructor; +import net.tokishu.note.annotation.NullableCurrentUser; +import net.tokishu.note.model.User; +import net.tokishu.note.service.AuthService; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +@RequiredArgsConstructor +public class NullableCurrentUserArgumentResolver implements HandlerMethodArgumentResolver { + + private final AuthService authService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(NullableCurrentUser.class) + && parameter.getParameterType().equals(User.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + return authService.getCurrentUserOrNull(); + } +} diff --git a/src/main/java/net/tokishu/note/service/AuthService.java b/src/main/java/net/tokishu/note/service/AuthService.java index 77e0f4c..36d61a6 100644 --- a/src/main/java/net/tokishu/note/service/AuthService.java +++ b/src/main/java/net/tokishu/note/service/AuthService.java @@ -6,11 +6,17 @@ import net.tokishu.note.dto.request.RegisterRequest; import net.tokishu.note.dto.response.LoginResponse; import net.tokishu.note.dto.response.RegisterResponse; import net.tokishu.note.dto.response.RootResponse; +import net.tokishu.note.dto.response.UserResponse; import net.tokishu.note.model.User; +import net.tokishu.note.model.UserRole; import net.tokishu.note.repo.UserRepository; import net.tokishu.note.security.JwtService; +import net.tokishu.note.util.GravatarUtil; import net.tokishu.note.util.PasswordGenerator; import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; @@ -19,24 +25,14 @@ import org.springframework.web.server.ResponseStatusException; @RequiredArgsConstructor public class AuthService { - public final UserRepository userRepository; + private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtService jwtService; + private final UserService userService; public RootResponse registerRoot() { - if (userRepository.existsByRole("ADMIN")) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Administrator already exists"); - } - String rawPassword = PasswordGenerator.generate(16); - String hashedPassword = passwordEncoder.encode(rawPassword); - - User rootUser = new User(); - rootUser.setUsername("root"); - rootUser.setPassword(hashedPassword); - rootUser.setRole("ADMIN"); - - userRepository.save(rootUser); + User rootUser = userService.createUser("root", rawPassword, UserRole.ADMIN, null); return RootResponse.builder() .username(rootUser.getUsername()) @@ -45,18 +41,8 @@ public class AuthService { .build(); } - public RegisterResponse registerUser(RegisterRequest request) { - if (userRepository.existsById(request.getUsername())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "User already exists"); - } - - User user = new User(); - user.setUsername(request.getUsername()); - user.setPassword(passwordEncoder.encode(request.getPassword())); - user.setRole("USER"); - - userRepository.save(user); + User user = userService.createRegularUser(request.getUsername(), request.getPassword()); return RegisterResponse.builder() .username(user.getUsername()) @@ -77,7 +63,7 @@ public class AuthService { org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) - .roles(user.getRole()) + .roles(user.getRole().name()) .build() ); @@ -88,4 +74,46 @@ public class AuthService { .build(); } -} + public User getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not authenticated"); + } + + Object principal = authentication.getPrincipal(); + String username; + + if (principal instanceof UserDetails userDetails) { + username = userDetails.getUsername(); + } else if (principal instanceof String) { + username = (String) principal; + } else { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unable to identify user"); + } + + return userRepository.findById(username) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")); + } + + public User getCurrentUserOrNull() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + + Object principal = authentication.getPrincipal(); + String username; + + if (principal instanceof UserDetails userDetails) { + username = userDetails.getUsername(); + } else if (principal instanceof String) { + username = (String) principal; + } else { + return null; + } + + return userRepository.findById(username).orElse(null); + } +} \ No newline at end of file diff --git a/src/main/java/net/tokishu/note/service/NoteService.java b/src/main/java/net/tokishu/note/service/NoteService.java index 628723f..a2acea2 100644 --- a/src/main/java/net/tokishu/note/service/NoteService.java +++ b/src/main/java/net/tokishu/note/service/NoteService.java @@ -5,7 +5,9 @@ import net.tokishu.note.dto.request.NoteRequest; import net.tokishu.note.dto.response.NoteResponse; import net.tokishu.note.model.Note; import net.tokishu.note.model.User; +import net.tokishu.note.model.UserRole; import net.tokishu.note.repo.NoteRepository; +import net.tokishu.note.util.CheckAuthUtil; import net.tokishu.note.util.CodeGenerator; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -22,11 +24,10 @@ public class NoteService { public final NoteRepository noteRepository; public final UserService userService; - public List getAll() { - User currentUser = userService.getCurrentUser(); + public List getAll(User currentUser) { List notes; - if ("ADMIN".equalsIgnoreCase(currentUser.getRole())) { + if (currentUser.getRole() == UserRole.ADMIN) { notes = noteRepository.findAll(); } else { notes = noteRepository.findByAuthorUsername(currentUser.getUsername()); @@ -37,18 +38,17 @@ public class NoteService { .collect(Collectors.toList()); } - public NoteResponse findByIdOrPublicLink(String idOrCode) { + public NoteResponse findByIdOrPublicLink(String idOrCode, User currentUser) { if (!StringUtils.hasText(idOrCode)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid identifier or public link"); } Note note; if (isUuid(idOrCode)) { + CheckAuthUtil.check(currentUser); UUID id = UUID.fromString(idOrCode); note = noteRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Note not found")); - - User currentUser = userService.getCurrentUser(); checkOwnership(note, currentUser); } else { note = noteRepository.findByPublicLinkAndIsPublicTrue(idOrCode) @@ -58,10 +58,7 @@ public class NoteService { return toResponse(note); } - - public NoteResponse add(NoteRequest data) { - User author = userService.getCurrentUser(); - + public NoteResponse add(NoteRequest data, User author) { Note note = new Note(); note.setName(data.getName()); note.setText(data.getText()); @@ -86,12 +83,10 @@ public class NoteService { return toResponse(saved); } - - public NoteResponse update(UUID uuid, NoteRequest data) { + public NoteResponse update(UUID uuid, NoteRequest data, User currentUser) { Note existing = noteRepository.findById(uuid) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Note not found")); - User currentUser = userService.getCurrentUser(); checkOwnership(existing, currentUser); existing.setName(data.getName()); @@ -101,17 +96,16 @@ public class NoteService { return toResponse(noteRepository.save(existing)); } - public void delete(UUID uuid) { + public void delete(UUID uuid, User currentUser) { Note existing = noteRepository.findById(uuid) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Note not found")); - - User currentUser = userService.getCurrentUser(); + checkOwnership(existing, currentUser); noteRepository.delete(existing); } private void checkOwnership(Note note, User user) { - if ("ADMIN".equalsIgnoreCase(user.getRole())) { // TODO: use enum + if (user.getRole() == UserRole.ADMIN) { return; } diff --git a/src/main/java/net/tokishu/note/service/UserService.java b/src/main/java/net/tokishu/note/service/UserService.java index 47010b1..d3b0134 100644 --- a/src/main/java/net/tokishu/note/service/UserService.java +++ b/src/main/java/net/tokishu/note/service/UserService.java @@ -1,10 +1,15 @@ package net.tokishu.note.service; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; -import net.tokishu.note.dto.request.RegisterRequest; +import net.tokishu.note.dto.request.UpdateProfileRequest; import net.tokishu.note.dto.response.UserResponse; import net.tokishu.note.model.User; +import net.tokishu.note.model.UserRole; +import net.tokishu.note.repo.NoteRepository; import net.tokishu.note.repo.UserRepository; +import net.tokishu.note.util.CheckAuthUtil; +import net.tokishu.note.util.GravatarUtil; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -13,45 +18,71 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; -import java.util.List; - @Service @RequiredArgsConstructor public class UserService { - public final UserRepository userRepository; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final NoteRepository noteRepository; - public List getAll(){ - return userRepository.findAll(); - } - - public User getCurrentUser() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - - if (authentication == null || !authentication.isAuthenticated()) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not authenticated"); - } - - Object principal = authentication.getPrincipal(); - String username; - - if (principal instanceof UserDetails userDetails) { - username = userDetails.getUsername(); - } else if (principal instanceof String) { - username = (String) principal; - } else { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unable to identify user"); - } - - return userRepository.findById(username) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")); - } - - public UserResponse getCurrentUserResponse() { - User user = getCurrentUser(); + public UserResponse getCurrentUserResponse(User user) { return UserResponse.builder() .username(user.getUsername()) .role(user.getRole()) + .gravatarUrl(GravatarUtil.generateGravatarUrl(user.getGravatarEmail())) .build(); } -} + public User createUser(String username, String password, UserRole role, User currentUser) { + if (userRepository.existsById(username)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "User already exists"); + } + + boolean adminExists = userRepository.existsByRole(UserRole.ADMIN); + boolean isCreatingAdmin = UserRole.ADMIN.equals(role); + + if (isCreatingAdmin && adminExists) { + if (currentUser == null || currentUser.getRole() != UserRole.ADMIN) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only admin can create another admin"); + } + } + + User user = new User(); + user.setUsername(username); + user.setPassword(passwordEncoder.encode(password)); + user.setRole(role); + + return userRepository.save(user); + } + + public User createRegularUser(String username, String password) { + return createUser(username, password, UserRole.USER, null); + } + + @Transactional + public UserResponse updateCurrentUser(UpdateProfileRequest request, User currentUser) { + String oldUsername = currentUser.getUsername(); + String newUsername = request.getUsername(); + + if (!oldUsername.equals(newUsername)) { + if (userRepository.existsById(newUsername)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Username is already taken"); + } + + userRepository.updateUsername(oldUsername, newUsername); + noteRepository.updateOwnerUsername(oldUsername, newUsername); + + currentUser.setUsername(newUsername); + } + + currentUser.setGravatarEmail(request.getGravatarEmail()); + User saved = userRepository.save(currentUser); + + return UserResponse.builder() + .username(saved.getUsername()) + .role(currentUser.getRole()) + .gravatarUrl(GravatarUtil.generateGravatarUrl(saved.getGravatarEmail())) + .build(); + } + +} \ No newline at end of file diff --git a/src/main/java/net/tokishu/note/util/CheckAuthUtil.java b/src/main/java/net/tokishu/note/util/CheckAuthUtil.java new file mode 100644 index 0000000..ca3d2ec --- /dev/null +++ b/src/main/java/net/tokishu/note/util/CheckAuthUtil.java @@ -0,0 +1,15 @@ +package net.tokishu.note.util; + +import lombok.experimental.UtilityClass; +import net.tokishu.note.model.User; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@UtilityClass +public class CheckAuthUtil { + public void check(User user) { + if (user == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not authenticated"); + } + } +} diff --git a/src/main/java/net/tokishu/note/util/GravatarUtil.java b/src/main/java/net/tokishu/note/util/GravatarUtil.java new file mode 100644 index 0000000..efc2e15 --- /dev/null +++ b/src/main/java/net/tokishu/note/util/GravatarUtil.java @@ -0,0 +1,28 @@ +package net.tokishu.note.util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class GravatarUtil { + + public static String generateGravatarUrl(String email) { + if (email == null || email.isBlank()) { + return null; + } + + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] digest = md.digest(email.trim().toLowerCase().getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + + for (byte b : digest) { + sb.append(String.format("%02x", b & 0xff)); + } + + return "https://www.gravatar.com/avatar/" + sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("MD5 not supported", e); + } + } +}