Just push

This commit is contained in:
Tokishu
2025-05-31 23:05:43 +02:00
parent ca10835732
commit aceeb32d69
15 changed files with 171 additions and 70 deletions
+6
View File
@@ -106,6 +106,12 @@
<version>6.5.0</version>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
</dependency>
</dependencies>
@@ -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()
)
@@ -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<RootResponse> newRoot() {
RootResponse response = authService.registerRoot();
@@ -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<Map<String, String>> add(@RequestBody NoteRequest note){
public ResponseEntity<Map<String, String>> 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"));
}
@@ -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;
}
@@ -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;
}
@@ -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<ApiErrorResponse> 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<ApiErrorResponse> handleBadRequest(HttpMessageNotReadableException ex) {
return ResponseEntity
.badRequest()
.body(ApiErrorResponse.builder()
.status(400)
.message("Invalid request body")
.build()
);
}
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<ApiErrorResponse> handleNotFound(NoHandlerFoundException ex) {
return ResponseEntity
.status(404)
.body(ApiErrorResponse.builder()
.status(404)
.message("Endpoint not found")
.build()
);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> handleGeneric(Exception ex) {
public ResponseEntity<ApiErrorResponse> 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<ApiErrorResponse> 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<ApiErrorResponse> 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());
}
}
@@ -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;
@@ -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<Note, UUID> {
List<Note> findByAuthorUsername(String username);
List<Note> findByAuthorUsernameOrderByCreatedAtDesc(String username);
Optional<Note> findByPublicLinkAndIsPublicTrue(String publicLink);
boolean existsByPublicLink(String publicLink);
}
@@ -6,4 +6,5 @@ import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface UserRepository extends JpaRepository<User, String> {
boolean existsByRole(String role);
}
@@ -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();
@@ -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<NoteResponse> 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;
}
}
}
@@ -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();
}
}
@@ -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) {
+1 -1
View File
@@ -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