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> <version>6.5.0</version>
</dependency> </dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
</dependency>
</dependencies> </dependencies>
@@ -31,7 +31,7 @@ public class SecurityConfig {
) )
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers(new AntPathRequestMatcher("/api/auth/**")).permitAll() .requestMatchers(new AntPathRequestMatcher("/api/auth/**")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/h2-console/**")).permitAll() .requestMatchers(new AntPathRequestMatcher("/api/notes/*")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/error")).permitAll() .requestMatchers(new AntPathRequestMatcher("/error")).permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
@@ -14,12 +14,14 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Map; import java.util.Map;
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@Validated
@RequestMapping("/auth") @RequestMapping("/auth")
public class AuthController { public class AuthController {
@@ -38,7 +40,6 @@ public class AuthController {
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
@PostMapping("/new-root") @PostMapping("/new-root")
public ResponseEntity<RootResponse> newRoot() { public ResponseEntity<RootResponse> newRoot() {
RootResponse response = authService.registerRoot(); RootResponse response = authService.registerRoot();
@@ -1,11 +1,13 @@
package net.tokishu.note.controller; package net.tokishu.note.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import net.tokishu.note.dto.request.NoteRequest; import net.tokishu.note.dto.request.NoteRequest;
import net.tokishu.note.dto.response.NoteResponse; import net.tokishu.note.dto.response.NoteResponse;
import net.tokishu.note.service.NoteService; import net.tokishu.note.service.NoteService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@@ -14,6 +16,7 @@ import java.util.UUID;
@RequiredArgsConstructor @RequiredArgsConstructor
@RestController @RestController
@Validated
@RequestMapping("/notes") @RequestMapping("/notes")
public class NotesController { public class NotesController {
@@ -24,19 +27,19 @@ public class NotesController {
return ResponseEntity.ok(noteService.getAll()); return ResponseEntity.ok(noteService.getAll());
} }
@GetMapping("/{uuid}") @GetMapping("/{idOrCode}")
public NoteResponse find(@PathVariable UUID uuid){ public NoteResponse find(@PathVariable String idOrCode) {
return noteService.find(uuid); return noteService.findByIdOrPublicLink(idOrCode);
} }
@PostMapping() @PostMapping()
public ResponseEntity<Map<String, String>> add(@RequestBody NoteRequest note){ public ResponseEntity<Map<String, String>> add(@RequestBody @Valid NoteRequest note){
noteService.add(note); noteService.add(note);
return ResponseEntity.status(HttpStatus.CREATED).body(Map.of("message", "Note added")); return ResponseEntity.status(HttpStatus.CREATED).body(Map.of("message", "Note added"));
} }
@PutMapping("/{uuid}") @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); noteService.update(uuid, note);
return ResponseEntity.ok(Map.of("message", "Note updated")); return ResponseEntity.ok(Map.of("message", "Note updated"));
} }
@@ -1,6 +1,7 @@
package net.tokishu.note.dto.request; package net.tokishu.note.dto.request;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.Data; import lombok.Data;
@@ -13,4 +14,7 @@ public class NoteRequest {
@NotBlank @NotBlank
@Size(min=1, max=8192) @Size(min=1, max=8192)
private String text; private String text;
@NotNull
private Boolean isPublic;
} }
@@ -12,6 +12,8 @@ public class NoteResponse {
private UUID uuid; private UUID uuid;
private String name; private String name;
private String text; private String text;
private Boolean isPublic;
private String publicLink;
private String author; private String author;
private LocalDateTime createdAt; private LocalDateTime createdAt;
} }
@@ -4,6 +4,7 @@ import jakarta.servlet.http.HttpServletRequest;
import net.tokishu.note.dto.response.ApiErrorResponse; import net.tokishu.note.dto.response.ApiErrorResponse;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException; 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.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -12,48 +13,58 @@ import org.springframework.web.servlet.NoHandlerFoundException;
@ControllerAdvice @ControllerAdvice
public class GlobalExceptionHandler { 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) @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 return ResponseEntity
.status(500) .status(status)
.body(ApiErrorResponse.builder() .body(ApiErrorResponse.builder()
.status(500) .status(status)
.message("Internal server error") .message(message != null ? message : "Unexpected error")
.build() .build());
);
} }
} }
@@ -1,7 +1,10 @@
package net.tokishu.note.model; package net.tokishu.note.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.Null;
import lombok.Builder;
import lombok.Data; import lombok.Data;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
@@ -15,13 +18,21 @@ public class Note {
@Id @Id
@GeneratedValue(strategy = GenerationType.UUID) @GeneratedValue(strategy = GenerationType.UUID)
private UUID uuid; private UUID uuid;
@Column(length = 128, nullable = false)
private String name; private String name;
@Column(length = 8192, nullable = false)
private String text; private String text;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author", referencedColumnName = "username") @JoinColumn(name = "author", referencedColumnName = "username")
private User author; private User author;
@Column(length = 6, nullable = true, updatable = false)
private String publicLink;
@ColumnDefault("false")
private Boolean isPublic;
@CreationTimestamp @CreationTimestamp
@Column(updatable = false) @Column(updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -4,9 +4,12 @@ import net.tokishu.note.model.Note;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public interface NoteRepository extends JpaRepository<Note, UUID> { public interface NoteRepository extends JpaRepository<Note, UUID> {
List<Note> findByAuthorUsername(String username); List<Note> findByAuthorUsername(String username);
List<Note> findByAuthorUsernameOrderByCreatedAtDesc(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; import java.util.UUID;
public interface UserRepository extends JpaRepository<User, String> { 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.model.User;
import net.tokishu.note.repo.UserRepository; import net.tokishu.note.repo.UserRepository;
import net.tokishu.note.security.JwtService; 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.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -20,17 +20,15 @@ import org.springframework.web.server.ResponseStatusException;
public class AuthService { public class AuthService {
public final UserRepository userRepository; public final UserRepository userRepository;
public final UserService userService;
public final PasswordService passwordService;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JwtService jwtService; private final JwtService jwtService;
public RootResponse registerRoot() { public RootResponse registerRoot() {
userRepository.findById("root").ifPresent(user -> { if (userRepository.existsByRole("ADMIN")) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Root user already exists"); throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Administrator already exists");
}); }
String rawPassword = passwordService.generate(16); String rawPassword = PasswordGenerator.generate(16);
String hashedPassword = passwordEncoder.encode(rawPassword); String hashedPassword = passwordEncoder.encode(rawPassword);
User rootUser = new User(); 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.Note;
import net.tokishu.note.model.User; import net.tokishu.note.model.User;
import net.tokishu.note.repo.NoteRepository; 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.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.*; import java.util.*;
@@ -20,7 +21,6 @@ public class NoteService {
public final NoteRepository noteRepository; public final NoteRepository noteRepository;
public final UserService userService; public final UserService userService;
public final UserRepository userRepository;
public List<NoteResponse> getAll() { public List<NoteResponse> getAll() {
User currentUser = userService.getCurrentUser(); User currentUser = userService.getCurrentUser();
@@ -37,15 +37,28 @@ public class NoteService {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
public NoteResponse find(UUID uuid) { public NoteResponse findByIdOrPublicLink(String idOrCode) {
Note note = noteRepository.findById(uuid) 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")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Note not found"));
User currentUser = userService.getCurrentUser(); User currentUser = userService.getCurrentUser();
checkOwnership(note, currentUser); checkOwnership(note, currentUser);
} else {
note = noteRepository.findByPublicLinkAndIsPublicTrue(idOrCode)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Note not found"));
}
return toResponse(note); return toResponse(note);
} }
public NoteResponse add(NoteRequest data) { public NoteResponse add(NoteRequest data) {
User author = userService.getCurrentUser(); User author = userService.getCurrentUser();
@@ -54,10 +67,26 @@ public class NoteService {
note.setText(data.getText()); note.setText(data.getText());
note.setAuthor(author); 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); Note saved = noteRepository.save(note);
return toResponse(saved); return toResponse(saved);
} }
public NoteResponse update(UUID uuid, NoteRequest data) { public NoteResponse update(UUID uuid, NoteRequest data) {
Note existing = noteRepository.findById(uuid) Note existing = noteRepository.findById(uuid)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Note not found")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Note not found"));
@@ -67,6 +96,7 @@ public class NoteService {
existing.setName(data.getName()); existing.setName(data.getName());
existing.setText(data.getText()); existing.setText(data.getText());
existing.setIsPublic(Boolean.TRUE.equals(data.getIsPublic()));
return toResponse(noteRepository.save(existing)); return toResponse(noteRepository.save(existing));
} }
@@ -81,7 +111,7 @@ public class NoteService {
} }
private void checkOwnership(Note note, User user) { private void checkOwnership(Note note, User user) {
if ("ADMIN".equalsIgnoreCase(user.getRole())) { if ("ADMIN".equalsIgnoreCase(user.getRole())) { // TODO: use enum
return; return;
} }
@@ -95,8 +125,19 @@ public class NoteService {
.uuid(note.getUuid()) .uuid(note.getUuid())
.name(note.getName()) .name(note.getName())
.text(note.getText()) .text(note.getText())
.isPublic(note.getIsPublic())
.publicLink(note.getPublicLink())
.author(note.getAuthor().getUsername()) .author(note.getAuthor().getUsername())
.createdAt(note.getCreatedAt()) .createdAt(note.getCreatedAt())
.build(); .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; import java.security.SecureRandom;
@Service @UtilityClass
public class PasswordService { public class PasswordGenerator {
private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789*!$%&@";
private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
private static final SecureRandom RANDOM = new SecureRandom(); private static final SecureRandom RANDOM = new SecureRandom();
public String generate(int length) { public String generate(int length) {
+1 -1
View File
@@ -1,7 +1,7 @@
spring.application.name=notes spring.application.name=notes
spring.mvc.servlet.path=/api spring.mvc.servlet.path=/api
?Error # Error
spring.mvc.throw-exception-if-no-handler-found=true spring.mvc.throw-exception-if-no-handler-found=true
spring.web.resources.add-mappings=false spring.web.resources.add-mappings=false