Merge main into nomorebreaking
This commit is contained in:
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
target/
|
||||
nohup.out
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
|
||||
@@ -90,6 +90,10 @@
|
||||
"key": "avatarFile",
|
||||
"value": "postman/avatar.png"
|
||||
},
|
||||
{
|
||||
"key": "avatarUrl",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "bulkPetId",
|
||||
"value": ""
|
||||
@@ -117,6 +121,10 @@
|
||||
{
|
||||
"key": "bulkInventoryId",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "adoptedPetId",
|
||||
"value": "4"
|
||||
}
|
||||
],
|
||||
"item": [
|
||||
@@ -212,6 +220,7 @@
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"var jsonData = pm.response.json();",
|
||||
"if (jsonData.id !== undefined) pm.collectionVariables.set('userId', jsonData.id);",
|
||||
"if (jsonData.token) pm.collectionVariables.set('customerToken', jsonData.token);"
|
||||
]
|
||||
}
|
||||
@@ -307,7 +316,9 @@
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});"
|
||||
"});",
|
||||
"var jsonData = pm.response.json();",
|
||||
"if (jsonData.id !== undefined) pm.collectionVariables.set('userId', jsonData.id);"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -381,7 +392,8 @@
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"var jsonData = pm.response.json();",
|
||||
"pm.expect(jsonData.avatarUrl).to.be.a('string');"
|
||||
"pm.expect(jsonData.avatarUrl).to.be.a('string');",
|
||||
"pm.collectionVariables.set('avatarUrl', jsonData.avatarUrl);"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -414,7 +426,68 @@
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"var jsonData = pm.response.json();",
|
||||
"pm.expect(jsonData.avatarUrl).to.be.a('string');"
|
||||
"pm.expect(jsonData.avatarUrl).to.be.a('string');",
|
||||
"pm.collectionVariables.set('avatarUrl', jsonData.avatarUrl);"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Get My Avatar File",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}{{avatarUrl}}",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{customerToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"pm.test('Avatar response is an image', function () {",
|
||||
" pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Get User Avatar File As Staff",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/users/{{userId}}/avatar/file",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{staffToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"pm.test('Avatar response is an image', function () {",
|
||||
" pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -662,6 +735,95 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Upload Pet Image",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "{{baseUrl}}/api/v1/pets/{{petId}}/image",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{staffToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "image",
|
||||
"type": "file",
|
||||
"src": "{{avatarFile}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"var jsonData = pm.response.json();",
|
||||
"pm.expect(jsonData.imageUrl).to.be.a('string');"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Get Pet Image Public",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/pets/{{petId}}/image"
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"pm.test('Pet image response is an image', function () {",
|
||||
" pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Delete Pet Image",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"url": "{{baseUrl}}/api/v1/pets/{{petId}}/image",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{staffToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Delete Pet",
|
||||
"request": {
|
||||
@@ -769,6 +931,120 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Upload Adopted Pet Image",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{staffToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "image",
|
||||
"type": "file",
|
||||
"src": "{{avatarFile}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Get Adopted Pet Image Public",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image"
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 403', function () {",
|
||||
" pm.response.to.have.status(403);",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Get Adopted Pet Image As Staff",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{staffToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"pm.test('Pet image response is an image', function () {",
|
||||
" pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Delete Adopted Pet Image",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{staffToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1015,6 +1291,95 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Upload Product Image",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "{{baseUrl}}/api/v1/products/{{productId}}/image",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{staffToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "image",
|
||||
"type": "file",
|
||||
"src": "{{avatarFile}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"var jsonData = pm.response.json();",
|
||||
"pm.expect(jsonData.imageUrl).to.be.a('string');"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Get Product Image",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/api/v1/products/{{productId}}/image"
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});",
|
||||
"pm.test('Product image response is an image', function () {",
|
||||
" pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Delete Product Image",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"url": "{{baseUrl}}/api/v1/products/{{productId}}/image",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{adminToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test('Status code is 200', function () {",
|
||||
" pm.response.to.have.status(200);",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -4468,4 +4833,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import java.util.Arrays;
|
||||
|
||||
public class FlywayContextInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
|
||||
|
||||
private static final int MAX_RETRIES = 15;
|
||||
private static final long RETRY_DELAY_MILLIS = 1000L;
|
||||
|
||||
@Override
|
||||
public void initialize(ConfigurableApplicationContext applicationContext) {
|
||||
ConfigurableEnvironment environment = applicationContext.getEnvironment();
|
||||
@@ -29,12 +32,33 @@ public class FlywayContextInitializer implements ApplicationContextInitializer<C
|
||||
.filter(location -> !location.isEmpty())
|
||||
.toArray(String[]::new);
|
||||
|
||||
Flyway.configure()
|
||||
.dataSource(url, username, password)
|
||||
.locations(locations)
|
||||
.baselineOnMigrate(environment.getProperty("spring.flyway.baseline-on-migrate", Boolean.class, false))
|
||||
.baselineVersion(MigrationVersion.fromVersion(environment.getProperty("spring.flyway.baseline-version", "1")))
|
||||
.load()
|
||||
.migrate();
|
||||
RuntimeException lastFailure = null;
|
||||
for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
Flyway.configure()
|
||||
.dataSource(url, username, password)
|
||||
.locations(locations)
|
||||
.baselineOnMigrate(environment.getProperty("spring.flyway.baseline-on-migrate", Boolean.class, false))
|
||||
.baselineVersion(MigrationVersion.fromVersion(environment.getProperty("spring.flyway.baseline-version", "1")))
|
||||
.load()
|
||||
.migrate();
|
||||
return;
|
||||
} catch (RuntimeException ex) {
|
||||
lastFailure = ex;
|
||||
if (attempt == MAX_RETRIES) {
|
||||
throw ex;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(RETRY_DELAY_MILLIS);
|
||||
} catch (InterruptedException interruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Interrupted while waiting for database startup", interruptedException);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastFailure != null) {
|
||||
throw lastFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,13 @@ import com.petshop.backend.repository.EmployeeRepository;
|
||||
import com.petshop.backend.repository.EmployeeStoreRepository;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
import com.petshop.backend.security.JwtUtil;
|
||||
import com.petshop.backend.service.AvatarStorageService;
|
||||
import com.petshop.backend.service.UserBusinessLinkageService;
|
||||
import com.petshop.backend.util.AuthenticationHelper;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
@@ -28,15 +31,9 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth")
|
||||
@@ -49,8 +46,9 @@ public class AuthController {
|
||||
private final UserBusinessLinkageService userBusinessLinkageService;
|
||||
private final EmployeeRepository employeeRepository;
|
||||
private final EmployeeStoreRepository employeeStoreRepository;
|
||||
private final AvatarStorageService avatarStorageService;
|
||||
|
||||
public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, UserBusinessLinkageService userBusinessLinkageService, EmployeeRepository employeeRepository, EmployeeStoreRepository employeeStoreRepository) {
|
||||
public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, UserBusinessLinkageService userBusinessLinkageService, EmployeeRepository employeeRepository, EmployeeStoreRepository employeeStoreRepository, AvatarStorageService avatarStorageService) {
|
||||
this.authenticationManager = authenticationManager;
|
||||
this.userRepository = userRepository;
|
||||
this.jwtUtil = jwtUtil;
|
||||
@@ -58,6 +56,7 @@ public class AuthController {
|
||||
this.userBusinessLinkageService = userBusinessLinkageService;
|
||||
this.employeeRepository = employeeRepository;
|
||||
this.employeeStoreRepository = employeeStoreRepository;
|
||||
this.avatarStorageService = avatarStorageService;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
@@ -155,7 +154,7 @@ public class AuthController {
|
||||
user.getEmail(),
|
||||
user.getFullName(),
|
||||
user.getPhone(),
|
||||
user.getAvatarUrl(),
|
||||
avatarStorageService.toOwnerAvatarUrl(user),
|
||||
user.getRole().name(),
|
||||
employeeStore != null ? employeeStore.getStore().getStoreId() : null,
|
||||
employeeStore != null ? employeeStore.getStore().getStoreName() : null
|
||||
@@ -224,7 +223,7 @@ public class AuthController {
|
||||
updatedUser.getEmail(),
|
||||
updatedUser.getFullName(),
|
||||
updatedUser.getPhone(),
|
||||
updatedUser.getAvatarUrl(),
|
||||
avatarStorageService.toOwnerAvatarUrl(updatedUser),
|
||||
updatedUser.getRole().name(),
|
||||
employeeStore != null ? employeeStore.getStore().getStoreId() : null,
|
||||
employeeStore != null ? employeeStore.getStore().getStoreName() : null
|
||||
@@ -273,26 +272,12 @@ public class AuthController {
|
||||
}
|
||||
|
||||
try {
|
||||
String uploadDir = "uploads/avatars";
|
||||
File directory = new File(uploadDir);
|
||||
if (!directory.exists()) {
|
||||
directory.mkdirs();
|
||||
}
|
||||
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String extension = originalFilename != null && originalFilename.contains(".")
|
||||
? originalFilename.substring(originalFilename.lastIndexOf("."))
|
||||
: ".jpg";
|
||||
String filename = UUID.randomUUID().toString() + extension;
|
||||
Path filePath = Paths.get(uploadDir, filename);
|
||||
|
||||
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
String avatarUrl = "/uploads/avatars/" + filename;
|
||||
user.setAvatarUrl(avatarUrl);
|
||||
avatarStorageService.deleteAvatar(user);
|
||||
String avatarPath = avatarStorageService.storeAvatar(file);
|
||||
user.setAvatarUrl(avatarPath);
|
||||
userRepository.save(user);
|
||||
|
||||
return ResponseEntity.ok(new AvatarUploadResponse(avatarUrl, "Avatar uploaded successfully"));
|
||||
return ResponseEntity.ok(new AvatarUploadResponse(avatarStorageService.toOwnerAvatarUrl(user), "Avatar uploaded successfully"));
|
||||
|
||||
} catch (IOException e) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
@@ -305,25 +290,41 @@ public class AuthController {
|
||||
public ResponseEntity<?> getAvatar() {
|
||||
User user = getAuthenticatedUser();
|
||||
|
||||
if (user.getAvatarUrl() == null || user.getAvatarUrl().isEmpty()) {
|
||||
if (!avatarStorageService.hasAvatar(user)) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("message", "No avatar uploaded");
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
|
||||
}
|
||||
|
||||
Map<String, String> response = new HashMap<>();
|
||||
response.put("avatarUrl", user.getAvatarUrl());
|
||||
response.put("avatarUrl", avatarStorageService.toOwnerAvatarUrl(user));
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@GetMapping("/me/avatar/file")
|
||||
public ResponseEntity<Resource> getAvatarFile() {
|
||||
User user = getAuthenticatedUser();
|
||||
|
||||
if (!avatarStorageService.hasAvatar(user)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
try {
|
||||
Resource resource = avatarStorageService.loadAvatarResource(user);
|
||||
MediaType mediaType = avatarStorageService.resolveMediaType(user);
|
||||
return ResponseEntity.ok().contentType(mediaType).body(resource);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/me/avatar")
|
||||
public ResponseEntity<?> deleteAvatar() {
|
||||
User user = getAuthenticatedUser();
|
||||
|
||||
if (user.getAvatarUrl() != null && !user.getAvatarUrl().isEmpty()) {
|
||||
if (avatarStorageService.hasAvatar(user)) {
|
||||
try {
|
||||
Path filePath = Paths.get("." + user.getAvatarUrl());
|
||||
Files.deleteIfExists(filePath);
|
||||
avatarStorageService.deleteAvatar(user);
|
||||
} catch (IOException e) {
|
||||
}
|
||||
user.setAvatarUrl(null);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.petshop.backend.controller;
|
||||
|
||||
import com.petshop.backend.dto.pet.PetResponse;
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.security.AppPrincipal;
|
||||
import com.petshop.backend.service.PetService;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/pets")
|
||||
public class PetImageController {
|
||||
|
||||
private final PetService petService;
|
||||
|
||||
public PetImageController(PetService petService) {
|
||||
this.petService = petService;
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/image")
|
||||
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
|
||||
public ResponseEntity<?> uploadPetImage(@PathVariable Long id, @RequestParam("image") MultipartFile image) {
|
||||
try {
|
||||
PetResponse response = petService.uploadPetImage(id, image);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return badRequest(ex.getMessage());
|
||||
} catch (IOException ex) {
|
||||
return badRequest("Failed to upload pet image: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/image")
|
||||
public ResponseEntity<Resource> getPetImage(@PathVariable Long id) {
|
||||
try {
|
||||
PetService.ImagePayload payload = petService.loadPetImage(id, currentUserId(), currentUserRole());
|
||||
return ResponseEntity.ok().contentType(payload.mediaType()).body(payload.resource());
|
||||
} catch (PetService.ForbiddenImageAccessException ex) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/image")
|
||||
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
|
||||
public ResponseEntity<PetResponse> deletePetImage(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(petService.deletePetImage(id));
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, String>> badRequest(String message) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("message", message);
|
||||
return ResponseEntity.badRequest().body(error);
|
||||
}
|
||||
|
||||
private Long currentUserId() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
Object principal = authentication.getPrincipal();
|
||||
if (principal instanceof AppPrincipal appPrincipal) {
|
||||
return appPrincipal.getUserId();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private User.Role currentUserRole() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
Object principal = authentication.getPrincipal();
|
||||
if (principal instanceof AppPrincipal appPrincipal) {
|
||||
return appPrincipal.getRole();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.petshop.backend.controller;
|
||||
|
||||
import com.petshop.backend.dto.product.ProductResponse;
|
||||
import com.petshop.backend.service.ProductService;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/products")
|
||||
public class ProductImageController {
|
||||
|
||||
private final ProductService productService;
|
||||
|
||||
public ProductImageController(ProductService productService) {
|
||||
this.productService = productService;
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/image")
|
||||
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
|
||||
public ResponseEntity<?> uploadProductImage(@PathVariable Long id, @RequestParam("image") MultipartFile image) {
|
||||
try {
|
||||
ProductResponse response = productService.uploadProductImage(id, image);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return badRequest(ex.getMessage());
|
||||
} catch (IOException ex) {
|
||||
return badRequest("Failed to upload product image: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/image")
|
||||
public ResponseEntity<Resource> getProductImage(@PathVariable Long id) {
|
||||
ProductService.ImagePayload payload = productService.loadProductImage(id);
|
||||
return ResponseEntity.ok().contentType(payload.mediaType()).body(payload.resource());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/image")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ProductResponse> deleteProductImage(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(productService.deleteProductImage(id));
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, String>> badRequest(String message) {
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("message", message);
|
||||
return ResponseEntity.badRequest().body(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.petshop.backend.controller;
|
||||
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
import com.petshop.backend.service.AvatarStorageService;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/users")
|
||||
public class UserAvatarController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final AvatarStorageService avatarStorageService;
|
||||
|
||||
public UserAvatarController(UserRepository userRepository, AvatarStorageService avatarStorageService) {
|
||||
this.userRepository = userRepository;
|
||||
this.avatarStorageService = avatarStorageService;
|
||||
}
|
||||
|
||||
@GetMapping("/{userId}/avatar/file")
|
||||
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
|
||||
public ResponseEntity<Resource> getUserAvatarFile(@PathVariable Long userId) {
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
if (user == null || !avatarStorageService.hasAvatar(user)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
try {
|
||||
Resource resource = avatarStorageService.loadAvatarResource(user);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(avatarStorageService.resolveMediaType(user))
|
||||
.body(resource);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,14 @@ public class PetResponse {
|
||||
private Integer petAge;
|
||||
private String petStatus;
|
||||
private BigDecimal petPrice;
|
||||
private String imageUrl;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public PetResponse() {
|
||||
}
|
||||
|
||||
public PetResponse(Long petId, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
public PetResponse(Long petId, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
this.petId = petId;
|
||||
this.petName = petName;
|
||||
this.petSpecies = petSpecies;
|
||||
@@ -26,6 +27,7 @@ public class PetResponse {
|
||||
this.petAge = petAge;
|
||||
this.petStatus = petStatus;
|
||||
this.petPrice = petPrice;
|
||||
this.imageUrl = imageUrl;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
@@ -86,6 +88,14 @@ public class PetResponse {
|
||||
this.petPrice = petPrice;
|
||||
}
|
||||
|
||||
public String getImageUrl() {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
public void setImageUrl(String imageUrl) {
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
@@ -107,12 +117,12 @@ public class PetResponse {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
PetResponse that = (PetResponse) o;
|
||||
return Objects.equals(petId, that.petId) && Objects.equals(petName, that.petName) && Objects.equals(petSpecies, that.petSpecies) && Objects.equals(petBreed, that.petBreed) && Objects.equals(petAge, that.petAge) && Objects.equals(petStatus, that.petStatus) && Objects.equals(petPrice, that.petPrice) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt);
|
||||
return Objects.equals(petId, that.petId) && Objects.equals(petName, that.petName) && Objects.equals(petSpecies, that.petSpecies) && Objects.equals(petBreed, that.petBreed) && Objects.equals(petAge, that.petAge) && Objects.equals(petStatus, that.petStatus) && Objects.equals(petPrice, that.petPrice) && Objects.equals(imageUrl, that.imageUrl) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, createdAt, updatedAt);
|
||||
return Objects.hash(petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, imageUrl, createdAt, updatedAt);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -125,6 +135,7 @@ public class PetResponse {
|
||||
", petAge=" + petAge +
|
||||
", petStatus='" + petStatus + '\'' +
|
||||
", petPrice=" + petPrice +
|
||||
", imageUrl='" + imageUrl + '\'' +
|
||||
", createdAt=" + createdAt +
|
||||
", updatedAt=" + updatedAt +
|
||||
'}';
|
||||
|
||||
@@ -11,19 +11,21 @@ public class ProductResponse {
|
||||
private String categoryName;
|
||||
private String prodDesc;
|
||||
private BigDecimal prodPrice;
|
||||
private String imageUrl;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public ProductResponse() {
|
||||
}
|
||||
|
||||
public ProductResponse(Long prodId, String prodName, Long categoryId, String categoryName, String prodDesc, BigDecimal prodPrice, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
public ProductResponse(Long prodId, String prodName, Long categoryId, String categoryName, String prodDesc, BigDecimal prodPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
this.prodId = prodId;
|
||||
this.prodName = prodName;
|
||||
this.categoryId = categoryId;
|
||||
this.categoryName = categoryName;
|
||||
this.prodDesc = prodDesc;
|
||||
this.prodPrice = prodPrice;
|
||||
this.imageUrl = imageUrl;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
@@ -76,6 +78,14 @@ public class ProductResponse {
|
||||
this.prodPrice = prodPrice;
|
||||
}
|
||||
|
||||
public String getImageUrl() {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
public void setImageUrl(String imageUrl) {
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
@@ -97,12 +107,12 @@ public class ProductResponse {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
ProductResponse that = (ProductResponse) o;
|
||||
return Objects.equals(prodId, that.prodId) && Objects.equals(prodName, that.prodName) && Objects.equals(categoryId, that.categoryId) && Objects.equals(categoryName, that.categoryName) && Objects.equals(prodDesc, that.prodDesc) && Objects.equals(prodPrice, that.prodPrice) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt);
|
||||
return Objects.equals(prodId, that.prodId) && Objects.equals(prodName, that.prodName) && Objects.equals(categoryId, that.categoryId) && Objects.equals(categoryName, that.categoryName) && Objects.equals(prodDesc, that.prodDesc) && Objects.equals(prodPrice, that.prodPrice) && Objects.equals(imageUrl, that.imageUrl) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(prodId, prodName, categoryId, categoryName, prodDesc, prodPrice, createdAt, updatedAt);
|
||||
return Objects.hash(prodId, prodName, categoryId, categoryName, prodDesc, prodPrice, imageUrl, createdAt, updatedAt);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -114,6 +124,7 @@ public class ProductResponse {
|
||||
", categoryName='" + categoryName + '\'' +
|
||||
", prodDesc='" + prodDesc + '\'' +
|
||||
", prodPrice=" + prodPrice +
|
||||
", imageUrl='" + imageUrl + '\'' +
|
||||
", createdAt=" + createdAt +
|
||||
", updatedAt=" + updatedAt +
|
||||
'}';
|
||||
|
||||
@@ -35,6 +35,9 @@ public class Pet {
|
||||
@Column(nullable = false, precision = 10, scale = 2)
|
||||
private BigDecimal petPrice;
|
||||
|
||||
@Column(length = 255)
|
||||
private String imageUrl;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
@@ -46,7 +49,7 @@ public class Pet {
|
||||
public Pet() {
|
||||
}
|
||||
|
||||
public Pet(Long id, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
public Pet(Long id, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
this.id = id;
|
||||
this.petName = petName;
|
||||
this.petSpecies = petSpecies;
|
||||
@@ -54,6 +57,7 @@ public class Pet {
|
||||
this.petAge = petAge;
|
||||
this.petStatus = petStatus;
|
||||
this.petPrice = petPrice;
|
||||
this.imageUrl = imageUrl;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
@@ -114,6 +118,14 @@ public class Pet {
|
||||
this.petPrice = petPrice;
|
||||
}
|
||||
|
||||
public String getImageUrl() {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
public void setImageUrl(String imageUrl) {
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
@@ -153,6 +165,7 @@ public class Pet {
|
||||
", petAge=" + petAge +
|
||||
", petStatus='" + petStatus + '\'' +
|
||||
", petPrice=" + petPrice +
|
||||
", imageUrl='" + imageUrl + '\'' +
|
||||
", createdAt=" + createdAt +
|
||||
", updatedAt=" + updatedAt +
|
||||
'}';
|
||||
|
||||
@@ -29,6 +29,9 @@ public class Product {
|
||||
@Column(nullable = false, precision = 10, scale = 2)
|
||||
private BigDecimal prodPrice;
|
||||
|
||||
@Column(length = 255)
|
||||
private String imageUrl;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
@@ -40,12 +43,13 @@ public class Product {
|
||||
public Product() {
|
||||
}
|
||||
|
||||
public Product(Long prodId, String prodName, Category category, String prodDesc, BigDecimal prodPrice, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
public Product(Long prodId, String prodName, Category category, String prodDesc, BigDecimal prodPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
this.prodId = prodId;
|
||||
this.prodName = prodName;
|
||||
this.category = category;
|
||||
this.prodDesc = prodDesc;
|
||||
this.prodPrice = prodPrice;
|
||||
this.imageUrl = imageUrl;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
@@ -90,6 +94,14 @@ public class Product {
|
||||
this.prodPrice = prodPrice;
|
||||
}
|
||||
|
||||
public String getImageUrl() {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
public void setImageUrl(String imageUrl) {
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
@@ -127,6 +139,7 @@ public class Product {
|
||||
", category=" + category +
|
||||
", prodDesc='" + prodDesc + '\'' +
|
||||
", prodPrice=" + prodPrice +
|
||||
", imageUrl='" + imageUrl + '\'' +
|
||||
", createdAt=" + createdAt +
|
||||
", updatedAt=" + updatedAt +
|
||||
'}';
|
||||
|
||||
@@ -8,6 +8,8 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface AdoptionRepository extends JpaRepository<Adoption, Long> {
|
||||
|
||||
@@ -24,4 +26,6 @@ public interface AdoptionRepository extends JpaRepository<Adoption, Long> {
|
||||
"LOWER(a.customer.lastName) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
|
||||
"LOWER(a.pet.petName) LIKE LOWER(CONCAT('%', :q, '%')))")
|
||||
Page<Adoption> searchAdoptionsByCustomer(@Param("customerId") Long customerId, @Param("q") String query, Pageable pageable);
|
||||
|
||||
Optional<Adoption> findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(Long petId, String adoptionStatus);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.petshop.backend.service;
|
||||
|
||||
import com.petshop.backend.entity.User;
|
||||
import org.springframework.core.io.PathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class AvatarStorageService {
|
||||
|
||||
private static final String STORED_PREFIX = "/uploads/avatars/";
|
||||
private static final String OWNER_ENDPOINT = "/api/v1/auth/me/avatar/file";
|
||||
|
||||
private final Path avatarDirectory = Paths.get("uploads", "avatars").toAbsolutePath().normalize();
|
||||
|
||||
public String storeAvatar(MultipartFile file) throws IOException {
|
||||
Files.createDirectories(avatarDirectory);
|
||||
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String extension = resolveExtension(originalFilename);
|
||||
String filename = UUID.randomUUID() + extension;
|
||||
Path filePath = avatarDirectory.resolve(filename).normalize();
|
||||
|
||||
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
|
||||
return STORED_PREFIX + filename;
|
||||
}
|
||||
|
||||
public Resource loadAvatarResource(User user) {
|
||||
Path filePath = resolveStoredAvatarPath(user.getAvatarUrl());
|
||||
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
|
||||
throw new IllegalArgumentException("Avatar file was not found");
|
||||
}
|
||||
return new PathResource(filePath);
|
||||
}
|
||||
|
||||
public void deleteAvatar(User user) throws IOException {
|
||||
if (user.getAvatarUrl() == null || user.getAvatarUrl().isBlank()) {
|
||||
return;
|
||||
}
|
||||
Files.deleteIfExists(resolveStoredAvatarPath(user.getAvatarUrl()));
|
||||
}
|
||||
|
||||
public String toOwnerAvatarUrl(User user) {
|
||||
return hasAvatar(user) ? OWNER_ENDPOINT : null;
|
||||
}
|
||||
|
||||
public String toStoredAvatarUrl(String avatarFilenamePath) {
|
||||
return avatarFilenamePath;
|
||||
}
|
||||
|
||||
public boolean hasAvatar(User user) {
|
||||
return user.getAvatarUrl() != null && !user.getAvatarUrl().isBlank();
|
||||
}
|
||||
|
||||
public MediaType resolveMediaType(User user) {
|
||||
try {
|
||||
return MediaTypeFactory.getMediaType(loadAvatarResource(user)).orElse(MediaType.APPLICATION_OCTET_STREAM);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return MediaType.APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveStoredAvatarPath(String storedAvatarUrl) {
|
||||
if (storedAvatarUrl == null || storedAvatarUrl.isBlank() || !storedAvatarUrl.startsWith(STORED_PREFIX)) {
|
||||
throw new IllegalArgumentException("Avatar file was not found");
|
||||
}
|
||||
|
||||
String filename = storedAvatarUrl.substring(STORED_PREFIX.length());
|
||||
if (filename.isBlank() || filename.contains("/") || filename.contains("\\") || filename.contains("..")) {
|
||||
throw new IllegalArgumentException("Avatar file was not found");
|
||||
}
|
||||
|
||||
Path resolved = avatarDirectory.resolve(filename).normalize();
|
||||
if (!resolved.startsWith(avatarDirectory)) {
|
||||
throw new IllegalArgumentException("Avatar file was not found");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private String resolveExtension(String originalFilename) {
|
||||
if (originalFilename == null) {
|
||||
return ".jpg";
|
||||
}
|
||||
int extensionIndex = originalFilename.lastIndexOf('.');
|
||||
if (extensionIndex < 0 || extensionIndex == originalFilename.length() - 1) {
|
||||
return ".jpg";
|
||||
}
|
||||
String extension = originalFilename.substring(extensionIndex).toLowerCase(Locale.ROOT);
|
||||
return switch (extension) {
|
||||
case ".jpg", ".jpeg", ".png", ".gif" -> extension;
|
||||
default -> ".jpg";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.petshop.backend.service;
|
||||
|
||||
import org.springframework.core.io.PathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class CatalogImageStorageService {
|
||||
|
||||
private static final String PET_PREFIX = "/uploads/pets/";
|
||||
private static final String PRODUCT_PREFIX = "/uploads/products/";
|
||||
|
||||
public String storePetImage(MultipartFile file) throws IOException {
|
||||
return storeImage(file, Paths.get("uploads", "pets").toAbsolutePath().normalize(), PET_PREFIX);
|
||||
}
|
||||
|
||||
public String storeProductImage(MultipartFile file) throws IOException {
|
||||
return storeImage(file, Paths.get("uploads", "products").toAbsolutePath().normalize(), PRODUCT_PREFIX);
|
||||
}
|
||||
|
||||
public Resource loadPetImage(String storedPath) {
|
||||
return new PathResource(resolveStoredPath(storedPath, Paths.get("uploads", "pets").toAbsolutePath().normalize(), PET_PREFIX));
|
||||
}
|
||||
|
||||
public Resource loadProductImage(String storedPath) {
|
||||
return new PathResource(resolveStoredPath(storedPath, Paths.get("uploads", "products").toAbsolutePath().normalize(), PRODUCT_PREFIX));
|
||||
}
|
||||
|
||||
public MediaType resolveMediaType(Resource resource) {
|
||||
return MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
|
||||
public void deletePetImage(String storedPath) throws IOException {
|
||||
deleteImage(storedPath, Paths.get("uploads", "pets").toAbsolutePath().normalize(), PET_PREFIX);
|
||||
}
|
||||
|
||||
public void deleteProductImage(String storedPath) throws IOException {
|
||||
deleteImage(storedPath, Paths.get("uploads", "products").toAbsolutePath().normalize(), PRODUCT_PREFIX);
|
||||
}
|
||||
|
||||
private String storeImage(MultipartFile file, Path directory, String prefix) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
String extension = resolveExtension(file.getOriginalFilename());
|
||||
String filename = UUID.randomUUID() + extension;
|
||||
Path filePath = directory.resolve(filename).normalize();
|
||||
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
|
||||
return prefix + filename;
|
||||
}
|
||||
|
||||
private void deleteImage(String storedPath, Path directory, String prefix) throws IOException {
|
||||
if (storedPath == null || storedPath.isBlank()) {
|
||||
return;
|
||||
}
|
||||
Files.deleteIfExists(resolveStoredPath(storedPath, directory, prefix));
|
||||
}
|
||||
|
||||
private Path resolveStoredPath(String storedPath, Path directory, String prefix) {
|
||||
if (storedPath == null || storedPath.isBlank() || !storedPath.startsWith(prefix)) {
|
||||
throw new IllegalArgumentException("Image file was not found");
|
||||
}
|
||||
String filename = storedPath.substring(prefix.length());
|
||||
if (filename.isBlank() || filename.contains("/") || filename.contains("\\") || filename.contains("..")) {
|
||||
throw new IllegalArgumentException("Image file was not found");
|
||||
}
|
||||
Path resolved = directory.resolve(filename).normalize();
|
||||
if (!resolved.startsWith(directory)) {
|
||||
throw new IllegalArgumentException("Image file was not found");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private String resolveExtension(String originalFilename) {
|
||||
if (originalFilename == null) {
|
||||
return ".jpg";
|
||||
}
|
||||
int extensionIndex = originalFilename.lastIndexOf('.');
|
||||
if (extensionIndex < 0 || extensionIndex == originalFilename.length() - 1) {
|
||||
return ".jpg";
|
||||
}
|
||||
String extension = originalFilename.substring(extensionIndex).toLowerCase(Locale.ROOT);
|
||||
return switch (extension) {
|
||||
case ".jpg", ".jpeg", ".png", ".gif" -> extension;
|
||||
default -> ".jpg";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,21 +3,34 @@ package com.petshop.backend.service;
|
||||
import com.petshop.backend.dto.common.BulkDeleteRequest;
|
||||
import com.petshop.backend.dto.pet.PetRequest;
|
||||
import com.petshop.backend.dto.pet.PetResponse;
|
||||
import com.petshop.backend.entity.Adoption;
|
||||
import com.petshop.backend.entity.Pet;
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||
import com.petshop.backend.repository.AdoptionRepository;
|
||||
import com.petshop.backend.repository.PetRepository;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
|
||||
@Service
|
||||
public class PetService {
|
||||
|
||||
private final PetRepository petRepository;
|
||||
private final AdoptionRepository adoptionRepository;
|
||||
private final CatalogImageStorageService catalogImageStorageService;
|
||||
|
||||
public PetService(PetRepository petRepository) {
|
||||
public PetService(PetRepository petRepository, AdoptionRepository adoptionRepository, CatalogImageStorageService catalogImageStorageService) {
|
||||
this.petRepository = petRepository;
|
||||
this.adoptionRepository = adoptionRepository;
|
||||
this.catalogImageStorageService = catalogImageStorageService;
|
||||
}
|
||||
|
||||
public Page<PetResponse> getAllPets(String query, Pageable pageable) {
|
||||
@@ -68,17 +81,107 @@ public class PetService {
|
||||
|
||||
@Transactional
|
||||
public void deletePet(Long id) {
|
||||
if (!petRepository.existsById(id)) {
|
||||
throw new ResourceNotFoundException("Pet not found with id: " + id);
|
||||
}
|
||||
petRepository.deleteById(id);
|
||||
Pet pet = petRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id));
|
||||
deleteStoredImageIfPresent(pet.getImageUrl());
|
||||
petRepository.delete(pet);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void bulkDeletePets(BulkDeleteRequest request) {
|
||||
petRepository.findAllById(request.getIds()).forEach(pet -> deleteStoredImageIfPresent(pet.getImageUrl()));
|
||||
petRepository.deleteAllById(request.getIds());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PetResponse uploadPetImage(Long id, MultipartFile file) throws IOException {
|
||||
validateImageFile(file);
|
||||
Pet pet = findPet(id);
|
||||
deleteStoredImageIfPresent(pet.getImageUrl());
|
||||
pet.setImageUrl(catalogImageStorageService.storePetImage(file));
|
||||
return mapToResponse(petRepository.save(pet));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PetResponse deletePetImage(Long id) {
|
||||
Pet pet = findPet(id);
|
||||
deleteStoredImageIfPresent(pet.getImageUrl());
|
||||
pet.setImageUrl(null);
|
||||
return mapToResponse(petRepository.save(pet));
|
||||
}
|
||||
|
||||
public ImagePayload loadPetImage(Long id, Long requesterUserId, User.Role requesterRole) {
|
||||
Pet pet = findPet(id);
|
||||
if (pet.getImageUrl() == null || pet.getImageUrl().isBlank()) {
|
||||
throw new ResourceNotFoundException("Pet image not found for id: " + id);
|
||||
}
|
||||
if (!canViewPetImage(pet, requesterUserId, requesterRole)) {
|
||||
throw new ForbiddenImageAccessException();
|
||||
}
|
||||
Resource resource = catalogImageStorageService.loadPetImage(pet.getImageUrl());
|
||||
MediaType mediaType = catalogImageStorageService.resolveMediaType(resource);
|
||||
return new ImagePayload(resource, mediaType);
|
||||
}
|
||||
|
||||
public boolean isPubliclyVisible(Pet pet) {
|
||||
return "available".equalsIgnoreCase(normalizeStatus(pet.getPetStatus()));
|
||||
}
|
||||
|
||||
private boolean canViewPetImage(Pet pet, Long requesterUserId, User.Role requesterRole) {
|
||||
if (isPubliclyVisible(pet)) {
|
||||
return true;
|
||||
}
|
||||
if (requesterRole == User.Role.STAFF || requesterRole == User.Role.ADMIN) {
|
||||
return true;
|
||||
}
|
||||
if (requesterUserId == null) {
|
||||
return false;
|
||||
}
|
||||
if (!"adopted".equalsIgnoreCase(normalizeStatus(pet.getPetStatus()))) {
|
||||
return false;
|
||||
}
|
||||
return adoptionRepository.findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(pet.getPetId(), "Completed")
|
||||
.map(Adoption::getCustomer)
|
||||
.map(customer -> requesterUserId.equals(customer.getUserId()))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
private Pet findPet(Long id) {
|
||||
return petRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id));
|
||||
}
|
||||
|
||||
private void validateImageFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("Please select an image to upload");
|
||||
}
|
||||
if (file.getSize() > 5 * 1024 * 1024) {
|
||||
throw new IllegalArgumentException("Image file size must be less than 5MB");
|
||||
}
|
||||
String contentType = file.getContentType();
|
||||
if (contentType == null) {
|
||||
throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed");
|
||||
}
|
||||
String normalized = contentType.toLowerCase(Locale.ROOT);
|
||||
if (!normalized.equals("image/jpeg") && !normalized.equals("image/png") && !normalized.equals("image/gif")) {
|
||||
throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed");
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteStoredImageIfPresent(String storedImagePath) {
|
||||
if (storedImagePath == null || storedImagePath.isBlank()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
catalogImageStorageService.deletePetImage(storedImagePath);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeStatus(String status) {
|
||||
return status == null ? "" : status.trim();
|
||||
}
|
||||
|
||||
private PetResponse mapToResponse(Pet pet) {
|
||||
return new PetResponse(
|
||||
pet.getPetId(),
|
||||
@@ -88,8 +191,15 @@ public class PetService {
|
||||
pet.getPetAge(),
|
||||
pet.getPetStatus(),
|
||||
pet.getPetPrice(),
|
||||
pet.getImageUrl() != null && !pet.getImageUrl().isBlank() ? "/api/v1/pets/" + pet.getPetId() + "/image" : null,
|
||||
pet.getCreatedAt(),
|
||||
pet.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
public record ImagePayload(Resource resource, MediaType mediaType) {
|
||||
}
|
||||
|
||||
public static class ForbiddenImageAccessException extends RuntimeException {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,20 +8,28 @@ import com.petshop.backend.entity.Product;
|
||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||
import com.petshop.backend.repository.CategoryRepository;
|
||||
import com.petshop.backend.repository.ProductRepository;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
|
||||
@Service
|
||||
public class ProductService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final CatalogImageStorageService catalogImageStorageService;
|
||||
|
||||
public ProductService(ProductRepository productRepository, CategoryRepository categoryRepository) {
|
||||
public ProductService(ProductRepository productRepository, CategoryRepository categoryRepository, CatalogImageStorageService catalogImageStorageService) {
|
||||
this.productRepository = productRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
this.catalogImageStorageService = catalogImageStorageService;
|
||||
}
|
||||
|
||||
public Page<ProductResponse> getAllProducts(String query, Pageable pageable) {
|
||||
@@ -74,17 +82,76 @@ public class ProductService {
|
||||
|
||||
@Transactional
|
||||
public void deleteProduct(Long id) {
|
||||
if (!productRepository.existsById(id)) {
|
||||
throw new ResourceNotFoundException("Product not found with id: " + id);
|
||||
}
|
||||
productRepository.deleteById(id);
|
||||
Product product = findProduct(id);
|
||||
deleteStoredImageIfPresent(product.getImageUrl());
|
||||
productRepository.delete(product);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void bulkDeleteProducts(BulkDeleteRequest request) {
|
||||
productRepository.findAllById(request.getIds()).forEach(product -> deleteStoredImageIfPresent(product.getImageUrl()));
|
||||
productRepository.deleteAllById(request.getIds());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ProductResponse uploadProductImage(Long id, MultipartFile file) throws IOException {
|
||||
validateImageFile(file);
|
||||
Product product = findProduct(id);
|
||||
deleteStoredImageIfPresent(product.getImageUrl());
|
||||
product.setImageUrl(catalogImageStorageService.storeProductImage(file));
|
||||
return mapToResponse(productRepository.save(product));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ProductResponse deleteProductImage(Long id) {
|
||||
Product product = findProduct(id);
|
||||
deleteStoredImageIfPresent(product.getImageUrl());
|
||||
product.setImageUrl(null);
|
||||
return mapToResponse(productRepository.save(product));
|
||||
}
|
||||
|
||||
public ImagePayload loadProductImage(Long id) {
|
||||
Product product = findProduct(id);
|
||||
if (product.getImageUrl() == null || product.getImageUrl().isBlank()) {
|
||||
throw new ResourceNotFoundException("Product image not found with id: " + id);
|
||||
}
|
||||
Resource resource = catalogImageStorageService.loadProductImage(product.getImageUrl());
|
||||
MediaType mediaType = catalogImageStorageService.resolveMediaType(resource);
|
||||
return new ImagePayload(resource, mediaType);
|
||||
}
|
||||
|
||||
private Product findProduct(Long id) {
|
||||
return productRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
|
||||
}
|
||||
|
||||
private void validateImageFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("Please select an image to upload");
|
||||
}
|
||||
if (file.getSize() > 5 * 1024 * 1024) {
|
||||
throw new IllegalArgumentException("Image file size must be less than 5MB");
|
||||
}
|
||||
String contentType = file.getContentType();
|
||||
if (contentType == null) {
|
||||
throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed");
|
||||
}
|
||||
String normalized = contentType.toLowerCase(Locale.ROOT);
|
||||
if (!normalized.equals("image/jpeg") && !normalized.equals("image/png") && !normalized.equals("image/gif")) {
|
||||
throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed");
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteStoredImageIfPresent(String storedImagePath) {
|
||||
if (storedImagePath == null || storedImagePath.isBlank()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
catalogImageStorageService.deleteProductImage(storedImagePath);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private ProductResponse mapToResponse(Product product) {
|
||||
return new ProductResponse(
|
||||
product.getProdId(),
|
||||
@@ -93,8 +160,12 @@ public class ProductService {
|
||||
product.getCategory().getCategoryName(),
|
||||
product.getProdDesc(),
|
||||
product.getProdPrice(),
|
||||
product.getImageUrl() != null && !product.getImageUrl().isBlank() ? "/api/v1/products/" + product.getProdId() + "/image" : null,
|
||||
product.getCreatedAt(),
|
||||
product.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
public record ImagePayload(Resource resource, MediaType mediaType) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE pet
|
||||
ADD COLUMN imageUrl VARCHAR(255) NULL;
|
||||
|
||||
ALTER TABLE product
|
||||
ADD COLUMN imageUrl VARCHAR(255) NULL;
|
||||
Reference in New Issue
Block a user