diff --git a/pom.xml b/pom.xml index 9a7fd03..d299956 100644 --- a/pom.xml +++ b/pom.xml @@ -106,6 +106,12 @@ 6.5.0 + + org.hibernate.validator + hibernate-validator + + + diff --git a/src/main/java/net/tokishu/note/config/SecurityConfig.java b/src/main/java/net/tokishu/note/config/SecurityConfig.java index b97758c..2872177 100644 --- a/src/main/java/net/tokishu/note/config/SecurityConfig.java +++ b/src/main/java/net/tokishu/note/config/SecurityConfig.java @@ -31,7 +31,7 @@ public class SecurityConfig { ) .authorizeHttpRequests(auth -> auth .requestMatchers(new AntPathRequestMatcher("/api/auth/**")).permitAll() - .requestMatchers(new AntPathRequestMatcher("/h2-console/**")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/api/notes/*")).permitAll() .requestMatchers(new AntPathRequestMatcher("/error")).permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/net/tokishu/note/controller/AuthController.java b/src/main/java/net/tokishu/note/controller/AuthController.java index ecf7d1f..596a74d 100644 --- a/src/main/java/net/tokishu/note/controller/AuthController.java +++ b/src/main/java/net/tokishu/note/controller/AuthController.java @@ -14,12 +14,14 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpEntity; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequiredArgsConstructor +@Validated @RequestMapping("/auth") public class AuthController { @@ -38,7 +40,6 @@ public class AuthController { return ResponseEntity.ok(response); } - @PostMapping("/new-root") public ResponseEntity newRoot() { RootResponse response = authService.registerRoot(); diff --git a/src/main/java/net/tokishu/note/controller/NotesController.java b/src/main/java/net/tokishu/note/controller/NotesController.java index 9847864..8aed389 100644 --- a/src/main/java/net/tokishu/note/controller/NotesController.java +++ b/src/main/java/net/tokishu/note/controller/NotesController.java @@ -1,11 +1,13 @@ package net.tokishu.note.controller; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import net.tokishu.note.dto.request.NoteRequest; import net.tokishu.note.dto.response.NoteResponse; import net.tokishu.note.service.NoteService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -14,6 +16,7 @@ import java.util.UUID; @RequiredArgsConstructor @RestController +@Validated @RequestMapping("/notes") public class NotesController { @@ -24,19 +27,19 @@ public class NotesController { return ResponseEntity.ok(noteService.getAll()); } - @GetMapping("/{uuid}") - public NoteResponse find(@PathVariable UUID uuid){ - return noteService.find(uuid); + @GetMapping("/{idOrCode}") + public NoteResponse find(@PathVariable String idOrCode) { + return noteService.findByIdOrPublicLink(idOrCode); } @PostMapping() - public ResponseEntity> add(@RequestBody NoteRequest note){ + public ResponseEntity> add(@RequestBody @Valid NoteRequest note){ noteService.add(note); return ResponseEntity.status(HttpStatus.CREATED).body(Map.of("message", "Note added")); } @PutMapping("/{uuid}") - public ResponseEntity update(@PathVariable("uuid") UUID uuid, @RequestBody NoteRequest note){ + public ResponseEntity update(@PathVariable("uuid") UUID uuid, @RequestBody @Valid NoteRequest note){ noteService.update(uuid, note); return ResponseEntity.ok(Map.of("message", "Note updated")); } diff --git a/src/main/java/net/tokishu/note/dto/request/NoteRequest.java b/src/main/java/net/tokishu/note/dto/request/NoteRequest.java index a5d1a01..e54d5c6 100644 --- a/src/main/java/net/tokishu/note/dto/request/NoteRequest.java +++ b/src/main/java/net/tokishu/note/dto/request/NoteRequest.java @@ -1,6 +1,7 @@ package net.tokishu.note.dto.request; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Data; @@ -13,4 +14,7 @@ public class NoteRequest { @NotBlank @Size(min=1, max=8192) private String text; + + @NotNull + private Boolean isPublic; } diff --git a/src/main/java/net/tokishu/note/dto/response/NoteResponse.java b/src/main/java/net/tokishu/note/dto/response/NoteResponse.java index 14e7586..bc1a100 100644 --- a/src/main/java/net/tokishu/note/dto/response/NoteResponse.java +++ b/src/main/java/net/tokishu/note/dto/response/NoteResponse.java @@ -12,6 +12,8 @@ public class NoteResponse { private UUID uuid; private String name; private String text; + private Boolean isPublic; + private String publicLink; private String author; private LocalDateTime createdAt; } diff --git a/src/main/java/net/tokishu/note/exception/GlobalExceptionHandler.java b/src/main/java/net/tokishu/note/exception/GlobalExceptionHandler.java index 71fd2b0..b6e4ae7 100644 --- a/src/main/java/net/tokishu/note/exception/GlobalExceptionHandler.java +++ b/src/main/java/net/tokishu/note/exception/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import jakarta.servlet.http.HttpServletRequest; import net.tokishu.note.dto.response.ApiErrorResponse; 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; @@ -12,48 +13,58 @@ import org.springframework.web.servlet.NoHandlerFoundException; @ControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(ResponseStatusException.class) - public ResponseEntity handle(ResponseStatusException ex) { - return ResponseEntity - .status(ex.getStatusCode()) - .body(ApiErrorResponse.builder() - .status(ex.getStatusCode().value()) - .message(ex.getReason() != null ? ex.getReason() : "Unexpected error") - .build() - ); - } - - @ExceptionHandler(HttpMessageNotReadableException.class) - public ResponseEntity handleBadRequest(HttpMessageNotReadableException ex) { - return ResponseEntity - .badRequest() - .body(ApiErrorResponse.builder() - .status(400) - .message("Invalid request body") - .build() - ); - } - - @ExceptionHandler(NoHandlerFoundException.class) - public ResponseEntity handleNotFound(NoHandlerFoundException ex) { - return ResponseEntity - .status(404) - .body(ApiErrorResponse.builder() - .status(404) - .message("Endpoint not found") - .build() - ); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity handleGeneric(Exception ex) { + public ResponseEntity handleAll(Exception ex) { + if (ex instanceof MethodArgumentNotValidException validationEx) { + return handleValidation(validationEx); + } + + switch (ex.getClass().getSimpleName()) { + case "ResponseStatusException" -> { + ResponseStatusException rse = (ResponseStatusException) ex; + return buildErrorResponse(rse.getStatusCode().value(), rse.getReason()); + } + case "NoHandlerFoundException" -> { + return buildErrorResponse(404, "Endpoint not found"); + } + case "HttpMessageNotReadableException" -> { + return buildErrorResponse(400, "Invalid request body"); + } + case "HttpRequestMethodNotSupportedException" -> { + return buildErrorResponse(405, "Method not allowed"); + } + case "HttpMediaTypeNotSupportedException" -> { + return buildErrorResponse(415, "Unsupported media type"); + } + case "HttpMediaTypeNotAcceptableException" -> { + return buildErrorResponse(406, "Not acceptable"); + } + case "HttpRequestTimeoutException" -> { + return buildErrorResponse(408, "Request timeout"); + } + default -> { + return buildErrorResponse(500, "Internal server error"); + } + } + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .findFirst() + .orElse("Invalid request"); + + return buildErrorResponse(400, message); + } + + + private ResponseEntity buildErrorResponse(int status, String message) { return ResponseEntity - .status(500) + .status(status) .body(ApiErrorResponse.builder() - .status(500) - .message("Internal server error") - .build() - ); + .status(status) + .message(message != null ? message : "Unexpected error") + .build()); } } diff --git a/src/main/java/net/tokishu/note/model/Note.java b/src/main/java/net/tokishu/note/model/Note.java index dd80086..d0ff268 100644 --- a/src/main/java/net/tokishu/note/model/Note.java +++ b/src/main/java/net/tokishu/note/model/Note.java @@ -1,7 +1,10 @@ package net.tokishu.note.model; import jakarta.persistence.*; +import jakarta.validation.constraints.Null; +import lombok.Builder; import lombok.Data; +import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.CreationTimestamp; import org.springframework.validation.annotation.Validated; @@ -15,13 +18,21 @@ public class Note { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID uuid; + @Column(length = 128, nullable = false) private String name; + @Column(length = 8192, nullable = false) private String text; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "author", referencedColumnName = "username") private User author; + @Column(length = 6, nullable = true, updatable = false) + private String publicLink; + + @ColumnDefault("false") + private Boolean isPublic; + @CreationTimestamp @Column(updatable = false) private LocalDateTime createdAt; diff --git a/src/main/java/net/tokishu/note/repo/NoteRepository.java b/src/main/java/net/tokishu/note/repo/NoteRepository.java index cf7238a..ba43185 100644 --- a/src/main/java/net/tokishu/note/repo/NoteRepository.java +++ b/src/main/java/net/tokishu/note/repo/NoteRepository.java @@ -4,9 +4,12 @@ import net.tokishu.note.model.Note; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; import java.util.UUID; public interface NoteRepository extends JpaRepository { List findByAuthorUsername(String username); List findByAuthorUsernameOrderByCreatedAtDesc(String username); + Optional findByPublicLinkAndIsPublicTrue(String publicLink); + boolean existsByPublicLink(String publicLink); } diff --git a/src/main/java/net/tokishu/note/repo/UserRepository.java b/src/main/java/net/tokishu/note/repo/UserRepository.java index a733c6f..9a90f8b 100644 --- a/src/main/java/net/tokishu/note/repo/UserRepository.java +++ b/src/main/java/net/tokishu/note/repo/UserRepository.java @@ -6,4 +6,5 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.UUID; public interface UserRepository extends JpaRepository { + boolean existsByRole(String role); } diff --git a/src/main/java/net/tokishu/note/service/AuthService.java b/src/main/java/net/tokishu/note/service/AuthService.java index eacdb30..77e0f4c 100644 --- a/src/main/java/net/tokishu/note/service/AuthService.java +++ b/src/main/java/net/tokishu/note/service/AuthService.java @@ -9,7 +9,7 @@ import net.tokishu.note.dto.response.RootResponse; import net.tokishu.note.model.User; import net.tokishu.note.repo.UserRepository; import net.tokishu.note.security.JwtService; -import net.tokishu.note.security.PasswordService; +import net.tokishu.note.util.PasswordGenerator; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -20,17 +20,15 @@ import org.springframework.web.server.ResponseStatusException; public class AuthService { public final UserRepository userRepository; - public final UserService userService; - public final PasswordService passwordService; private final PasswordEncoder passwordEncoder; private final JwtService jwtService; public RootResponse registerRoot() { - userRepository.findById("root").ifPresent(user -> { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Root user already exists"); - }); + if (userRepository.existsByRole("ADMIN")) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Administrator already exists"); + } - String rawPassword = passwordService.generate(16); + String rawPassword = PasswordGenerator.generate(16); String hashedPassword = passwordEncoder.encode(rawPassword); User rootUser = new User(); diff --git a/src/main/java/net/tokishu/note/service/NoteService.java b/src/main/java/net/tokishu/note/service/NoteService.java index 0d590f3..628723f 100644 --- a/src/main/java/net/tokishu/note/service/NoteService.java +++ b/src/main/java/net/tokishu/note/service/NoteService.java @@ -6,9 +6,10 @@ import net.tokishu.note.dto.response.NoteResponse; import net.tokishu.note.model.Note; import net.tokishu.note.model.User; import net.tokishu.note.repo.NoteRepository; -import net.tokishu.note.repo.UserRepository; +import net.tokishu.note.util.CodeGenerator; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; import org.springframework.web.server.ResponseStatusException; import java.util.*; @@ -20,7 +21,6 @@ public class NoteService { public final NoteRepository noteRepository; public final UserService userService; - public final UserRepository userRepository; public List getAll() { User currentUser = userService.getCurrentUser(); @@ -37,16 +37,29 @@ public class NoteService { .collect(Collectors.toList()); } - public NoteResponse find(UUID uuid) { - Note note = noteRepository.findById(uuid) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Note not found")); + public NoteResponse findByIdOrPublicLink(String idOrCode) { + if (!StringUtils.hasText(idOrCode)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid identifier or public link"); + } + + Note note; + if (isUuid(idOrCode)) { + 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) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Note not found")); + } - User currentUser = userService.getCurrentUser(); - checkOwnership(note, currentUser); return toResponse(note); } - public NoteResponse add(NoteRequest data){ + + public NoteResponse add(NoteRequest data) { User author = userService.getCurrentUser(); Note note = new Note(); @@ -54,10 +67,26 @@ public class NoteService { note.setText(data.getText()); note.setAuthor(author); + String code; + + int attempts = 0; + do { + code = CodeGenerator.generateCode(); + attempts++; + if (attempts > 10) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to generate unique public link"); + } + } while (noteRepository.existsByPublicLink(code)); + + note.setPublicLink(code); + + note.setIsPublic(Boolean.TRUE.equals(data.getIsPublic())); + Note saved = noteRepository.save(note); return toResponse(saved); } + public NoteResponse update(UUID uuid, NoteRequest data) { Note existing = noteRepository.findById(uuid) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Note not found")); @@ -67,6 +96,7 @@ public class NoteService { existing.setName(data.getName()); existing.setText(data.getText()); + existing.setIsPublic(Boolean.TRUE.equals(data.getIsPublic())); return toResponse(noteRepository.save(existing)); } @@ -81,7 +111,7 @@ public class NoteService { } private void checkOwnership(Note note, User user) { - if ("ADMIN".equalsIgnoreCase(user.getRole())) { + if ("ADMIN".equalsIgnoreCase(user.getRole())) { // TODO: use enum return; } @@ -95,8 +125,19 @@ public class NoteService { .uuid(note.getUuid()) .name(note.getName()) .text(note.getText()) + .isPublic(note.getIsPublic()) + .publicLink(note.getPublicLink()) .author(note.getAuthor().getUsername()) .createdAt(note.getCreatedAt()) .build(); } + + private boolean isUuid(String input) { + try { + UUID.fromString(input); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } } diff --git a/src/main/java/net/tokishu/note/util/CodeGenerator.java b/src/main/java/net/tokishu/note/util/CodeGenerator.java new file mode 100644 index 0000000..26ddc3c --- /dev/null +++ b/src/main/java/net/tokishu/note/util/CodeGenerator.java @@ -0,0 +1,21 @@ +package net.tokishu.note.util; + +import lombok.experimental.UtilityClass; + +import java.security.SecureRandom; +import java.util.Random; + +@UtilityClass +public class CodeGenerator { + private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + private static final int LENGTH = 6; + private static final Random RANDOM = new SecureRandom(); + + public String generateCode() { + StringBuilder sb = new StringBuilder(LENGTH); + for (int i = 0; i < LENGTH; i++) { + sb.append(CHARACTERS.charAt(RANDOM.nextInt(CHARACTERS.length()))); + } + return sb.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/net/tokishu/note/security/PasswordService.java b/src/main/java/net/tokishu/note/util/PasswordGenerator.java similarity index 72% rename from src/main/java/net/tokishu/note/security/PasswordService.java rename to src/main/java/net/tokishu/note/util/PasswordGenerator.java index 2a27d13..26ba2f0 100644 --- a/src/main/java/net/tokishu/note/security/PasswordService.java +++ b/src/main/java/net/tokishu/note/util/PasswordGenerator.java @@ -1,13 +1,12 @@ -package net.tokishu.note.security; +package net.tokishu.note.util; -import org.springframework.stereotype.Service; +import lombok.experimental.UtilityClass; import java.security.SecureRandom; -@Service -public class PasswordService { - - private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +@UtilityClass +public class PasswordGenerator { + private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789*!$%&@"; private static final SecureRandom RANDOM = new SecureRandom(); public String generate(int length) { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9df386d..7f55270 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,7 +1,7 @@ spring.application.name=notes spring.mvc.servlet.path=/api -?Error +# Error spring.mvc.throw-exception-if-no-handler-found=true spring.web.resources.add-mappings=false