Compare commits

...

10 Commits

26 changed files with 1200 additions and 92 deletions

3
.gitignore vendored
View File

@ -35,3 +35,6 @@ out/
### VS Code ###
.vscode/
# Custom
.env

11
Dockerfile Normal file
View File

@ -0,0 +1,11 @@
FROM gradle:8.8.0-jdk21 AS build
WORKDIR /mme-api
COPY . /mme-api
RUN gradle build
FROM eclipse-temurin:21
WORKDIR /mme-api
COPY --from=build /mme-api/build/libs/api-0.1.0-SNAPSHOT.jar .
COPY dev-data ./dev-data/
EXPOSE 8080
CMD ["java", "-jar", "api-0.1.0-SNAPSHOT.jar", "--spring.profiles.active=dev"]

17
README.md Normal file
View File

@ -0,0 +1,17 @@
# Meals Made Easy API
## Getting Started
First, create a `.env` file with the following environment variables:
- `MINIO_ROOT_PASSWORD=<put something here>`
- `MYSQL_ROOT_PASSWORD=<put something here>`
- `MYSQL_PASSWORD=<put something here>`
Then run `docker compose up -d --build`. The `--build` option can be omitted if you
have already built the `api` from `Dockerfile`. Once Docker Compose has finished
starting everything, navigate to `http://localhost:8080/greeting`.
You should see a simple "Hello, World!" greeting.
**N.b.: the current configuration of the app is to use the `dev` Spring-Boot profile,
which seeds the app with some simple recipes whose sources are located in the
`dev-data` directory.**

56
compose.yaml Normal file
View File

@ -0,0 +1,56 @@
name: meals-made-easy-api
services:
db:
image: mysql:latest
ports:
- '55001:3306'
- '55000:33060'
env_file: .env
environment:
MYSQL_DATABASE: meals_made_easy_api
MYSQL_USER: meals-made-easy-api-user
healthcheck:
test: mysqladmin ping -u $$MYSQL_USER --password=$$MYSQL_PASSWORD
interval: 5s
timeout: 10s
retries: 10
volumes:
- mysql-data:/var/lib/mysql
minio:
image: minio/minio:latest
ports:
- 9000:9000
- 9001:9001
env_file:
- .env
environment:
MINIO_ROOT_USER: minio-root
volumes:
- minio-data:/data
command:
- server
- /data
- --console-address
- :9001
api:
build: .
depends_on:
db:
condition: service_healthy
minio:
condition: service_started
env_file:
- .env
environment:
MYSQL_HOST: db
MYSQL_PORT: 3306
MYSQL_DATABASE: meals_made_easy_api
MYSQL_USERNAME: meals-made-easy-api-user
MINIO_HOST: minio
MINIO_PORT: 9000
MINIO_ROOT_USER: minio-root
ports:
- 8080:8080
volumes:
mysql-data:
minio-data:

View File

@ -3,17 +3,33 @@ package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.auth.AuthService;
import app.mealsmadeeasy.api.auth.LoginDetails;
import app.mealsmadeeasy.api.auth.LoginException;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.image.ImageService;
import app.mealsmadeeasy.api.image.S3ImageServiceTests;
import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.star.RecipeStarService;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserCreateException;
import app.mealsmadeeasy.api.user.UserService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.containers.MinIOContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import java.io.InputStream;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.nullValue;
@ -21,10 +37,27 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Testcontainers
@SpringBootTest
@AutoConfigureMockMvc
public class RecipeControllerTests {
@Container
private static final MinIOContainer container = new MinIOContainer(
DockerImageName.parse("minio/minio:latest")
);
@DynamicPropertySource
public static void minioProperties(DynamicPropertyRegistry registry) {
registry.add("app.mealsmadeeasy.api.minio.endpoint", container::getS3URL);
registry.add("app.mealsmadeeasy.api.minio.accessKey", container::getUserName);
registry.add("app.mealsmadeeasy.api.minio.secretKey", container::getPassword);
}
private static InputStream getHal9000() {
return S3ImageServiceTests.class.getClassLoader().getResourceAsStream("HAL9000.svg");
}
@Autowired
private MockMvc mockMvc;
@ -40,6 +73,12 @@ public class RecipeControllerTests {
@Autowired
private AuthService authService;
@Autowired
private ImageService imageService;
@Autowired
private ObjectMapper objectMapper;
private User createTestUser(String username) {
try {
return this.userService.createUser(username, username + "@test.com", "test");
@ -70,6 +109,20 @@ public class RecipeControllerTests {
.getToken();
}
private Image createHal9000(User owner) {
try (final InputStream hal9000 = getHal9000()) {
return this.imageService.create(
owner,
"HAL9000.svg",
hal9000,
27881L,
new ImageCreateInfoSpec()
);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Test
@DirtiesContext
public void getRecipePageViewByIdPublicRecipeNoPrincipal() throws Exception {
@ -94,6 +147,7 @@ public class RecipeControllerTests {
.andExpect(jsonPath("$.recipe.starCount").value(0))
.andExpect(jsonPath("$.recipe.viewerCount").value(0))
.andExpect(jsonPath("$.recipe.isPublic").value(true))
.andExpect(jsonPath("$.recipe.mainImage").value(nullValue()))
.andExpect(jsonPath("$.isStarred").value(nullValue()))
.andExpect(jsonPath("$.isOwner").value(nullValue()));
}
@ -165,7 +219,8 @@ public class RecipeControllerTests {
.andExpect(jsonPath("$.content[0].owner.id").value(owner.getId()))
.andExpect(jsonPath("$.content[0].owner.username").value(owner.getUsername()))
.andExpect(jsonPath("$.content[0].isPublic").value(true))
.andExpect(jsonPath("$.content[0].starCount").value(0));
.andExpect(jsonPath("$.content[0].starCount").value(0))
.andExpect(jsonPath("$.content[0].mainImage").value(nullValue()));
}
@Test
@ -187,6 +242,83 @@ public class RecipeControllerTests {
.andExpect(jsonPath("$.content", hasSize(3)));
}
private String getUpdateBody() throws JsonProcessingException {
final RecipeUpdateSpec spec = new RecipeUpdateSpec();
spec.setTitle("Updated Test Recipe");
spec.setPreparationTime(15);
spec.setCookingTime(30);
spec.setTotalTime(45);
spec.setRawText("# Hello, Updated World!");
spec.setIsPublic(true);
return this.objectMapper.writeValueAsString(spec);
}
@Test
@DirtiesContext
public void updateRecipe() throws Exception {
final User owner = this.createTestUser("owner");
final Recipe recipe = this.createTestRecipe(owner, false);
final String accessToken = this.getAccessToken(owner);
final String body = this.getUpdateBody();
this.mockMvc.perform(
post("/recipes/{username}/{slug}", owner.getUsername(), recipe.getSlug())
.header("Authorization", "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON)
.content(body)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.recipe.id").value(recipe.getId()))
.andExpect(jsonPath("$.recipe.title").value("Updated Test Recipe"))
.andExpect(jsonPath("$.recipe.preparationTime").value(15))
.andExpect(jsonPath("$.recipe.cookingTime").value(30))
.andExpect(jsonPath("$.recipe.totalTime").value(45))
.andExpect(jsonPath("$.recipe.text").value("<h1>Hello, Updated World!</h1>"))
.andExpect(jsonPath("$.recipe.rawText").value("# Hello, Updated World!"))
.andExpect(jsonPath("$.recipe.owner.id").value(owner.getId()))
.andExpect(jsonPath("$.recipe.owner.username").value(owner.getUsername()))
.andExpect(jsonPath("$.recipe.starCount").value(0))
.andExpect(jsonPath("$.recipe.viewerCount").value(0))
.andExpect(jsonPath("$.recipe.isPublic").value(true))
.andExpect(jsonPath("$.recipe.mainImage").value(nullValue()))
.andExpect(jsonPath("$.isStarred").value(false))
.andExpect(jsonPath("$.isOwner").value(true));
}
@Test
@DirtiesContext
public void updateRecipeReturnsViewWithMainImage() throws Exception {
final User owner = this.createTestUser("owner");
final Image hal9000 = this.createHal9000(owner);
final RecipeCreateSpec createSpec = new RecipeCreateSpec();
createSpec.setTitle("Test Recipe");
createSpec.setSlug("test-recipe");
createSpec.setPublic(false);
createSpec.setRawText("# Hello, World!");
createSpec.setMainImage(hal9000);
Recipe recipe = this.recipeService.create(owner, createSpec);
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec();
updateSpec.setTitle("Updated Test Recipe");
updateSpec.setRawText("# Hello, Updated World!");
final RecipeUpdateSpec.MainImageUpdateSpec mainImageUpdateSpec = new RecipeUpdateSpec.MainImageUpdateSpec();
mainImageUpdateSpec.setUsername(hal9000.getOwner().getUsername());
mainImageUpdateSpec.setFilename(hal9000.getUserFilename());
updateSpec.setMainImage(mainImageUpdateSpec);
final String body = this.objectMapper.writeValueAsString(updateSpec);
final String accessToken = this.getAccessToken(owner);
this.mockMvc.perform(
post("/recipes/{username}/{slug}", owner.getUsername(), recipe.getSlug())
.header("Authorization", "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON)
.content(body)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.recipe.mainImage").isMap());
}
@Test
@DirtiesContext
public void addStarToRecipe() throws Exception {

View File

@ -1,5 +1,6 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.star.RecipeStar;
@ -162,13 +163,18 @@ public class RecipeServiceTests {
@Test
@DirtiesContext
public void getByIdWithStarsOkayWhenPublicRecipeWithViewer() throws RecipeException {
public void getByIdWithStarsOkayWhenPublicRecipeWithViewer() throws RecipeException, ImageException {
final User owner = this.createTestUser("recipeOwner");
final User viewer = this.createTestUser("viewer");
final Recipe notYetPublicRecipe = this.createTestRecipe(owner);
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec();
updateSpec.setPublic(true);
final Recipe publicRecipe = this.recipeService.update(notYetPublicRecipe.getId(), updateSpec, owner);
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec(notYetPublicRecipe);
updateSpec.setIsPublic(true);
final Recipe publicRecipe = this.recipeService.update(
notYetPublicRecipe.getOwner().getUsername(),
notYetPublicRecipe.getSlug(),
updateSpec,
owner
);
assertDoesNotThrow(() -> this.recipeService.getByIdWithStars(publicRecipe.getId(), viewer));
}
@ -304,7 +310,7 @@ public class RecipeServiceTests {
@Test
@DirtiesContext
public void updateRawText() throws RecipeException {
public void updateRawText() throws RecipeException, ImageException {
final User owner = this.createTestUser("recipeOwner");
final RecipeCreateSpec createSpec = new RecipeCreateSpec();
createSpec.setSlug("my-recipe");
@ -312,9 +318,14 @@ public class RecipeServiceTests {
createSpec.setRawText("# A Heading");
Recipe recipe = this.recipeService.create(owner, createSpec);
final String newRawText = "# A Heading\n## A Subheading";
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec();
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec(recipe);
updateSpec.setRawText(newRawText);
recipe = this.recipeService.update(recipe.getId(), updateSpec, owner);
recipe = this.recipeService.update(
recipe.getOwner().getUsername(),
recipe.getSlug(),
updateSpec,
owner
);
assertThat(recipe.getRawText(), is(newRawText));
}
@ -328,7 +339,12 @@ public class RecipeServiceTests {
updateSpec.setRawText("should fail");
assertThrows(
AccessDeniedException.class,
() -> this.recipeService.update(recipe.getId(), updateSpec, notOwner)
() -> this.recipeService.update(
recipe.getOwner().getUsername(),
recipe.getSlug(),
updateSpec,
notOwner
)
);
}

View File

@ -0,0 +1,740 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="256"
height="256"
id="svg2"
inkscape:version="0.48.2 r9819"
sodipodi:docname="HAL9000.svg">
<title
id="title3116">HAL9000</title>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="2560"
inkscape:window-height="1292"
id="namedview139"
showgrid="false"
inkscape:zoom="2.6074563"
inkscape:cx="94.488987"
inkscape:cy="146.92353"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<defs
id="defs4">
<linearGradient
id="linearGradient4716">
<stop
id="stop4718"
style="stop-color:#0a1314;stop-opacity:1"
offset="0" />
<stop
id="stop4720"
style="stop-color:#0a1314;stop-opacity:0"
offset="1" />
</linearGradient>
<linearGradient
id="linearGradient4698">
<stop
id="stop4700"
style="stop-color:#424a4b;stop-opacity:1"
offset="0" />
<stop
id="stop4702"
style="stop-color:#424a4b;stop-opacity:0"
offset="1" />
</linearGradient>
<linearGradient
id="linearGradient4680">
<stop
id="stop4682"
style="stop-color:#0e191c;stop-opacity:1"
offset="0" />
<stop
id="stop4684"
style="stop-color:#0e191c;stop-opacity:0"
offset="1" />
</linearGradient>
<linearGradient
id="linearGradient4662">
<stop
id="stop4664"
style="stop-color:#f5f4f1;stop-opacity:1"
offset="0" />
<stop
id="stop4666"
style="stop-color:#f5f4f1;stop-opacity:0"
offset="1" />
</linearGradient>
<linearGradient
id="linearGradient4632">
<stop
id="stop4634"
style="stop-color:#626463;stop-opacity:1"
offset="0" />
<stop
id="stop4636"
style="stop-color:#626463;stop-opacity:0"
offset="1" />
</linearGradient>
<linearGradient
id="linearGradient4622">
<stop
id="stop4624"
style="stop-color:#b9b5b4;stop-opacity:1"
offset="0" />
<stop
id="stop4626"
style="stop-color:#9d9290;stop-opacity:0"
offset="1" />
</linearGradient>
<linearGradient
id="linearGradient4594">
<stop
id="stop4596"
style="stop-color:#473e3e;stop-opacity:1"
offset="0" />
<stop
id="stop4598"
style="stop-color:#473e3e;stop-opacity:0"
offset="1" />
</linearGradient>
<linearGradient
id="linearGradient4584">
<stop
id="stop4586"
style="stop-color:#b1aba9;stop-opacity:1"
offset="0" />
<stop
id="stop4588"
style="stop-color:#b1aba9;stop-opacity:0"
offset="1" />
</linearGradient>
<linearGradient
id="linearGradient4572">
<stop
id="stop4574"
style="stop-color:#e0d8d4;stop-opacity:1"
offset="0" />
<stop
id="stop4580"
style="stop-color:#e0d8d4;stop-opacity:0.45490196"
offset="0.67647064" />
<stop
id="stop4576"
style="stop-color:#e0d8d4;stop-opacity:0"
offset="1" />
</linearGradient>
<linearGradient
id="linearGradient4562">
<stop
id="stop4564"
style="stop-color:#cfcdc7;stop-opacity:1"
offset="0" />
<stop
id="stop4566"
style="stop-color:#cfcdc7;stop-opacity:0"
offset="1" />
</linearGradient>
<linearGradient
id="linearGradient4546">
<stop
id="stop4548"
style="stop-color:#444544;stop-opacity:1"
offset="0" />
<stop
id="stop4550"
style="stop-color:#706062;stop-opacity:0"
offset="1" />
</linearGradient>
<linearGradient
id="linearGradient4454">
<stop
id="stop4456"
style="stop-color:#bebbb6;stop-opacity:1"
offset="0" />
<stop
id="stop4458"
style="stop-color:#bebbb6;stop-opacity:0"
offset="1" />
</linearGradient>
<linearGradient
id="linearGradient4446">
<stop
id="stop4448"
style="stop-color:#8f908a;stop-opacity:1"
offset="0" />
<stop
id="stop4450"
style="stop-color:#8f908a;stop-opacity:0"
offset="1" />
</linearGradient>
<linearGradient
id="linearGradient4438">
<stop
id="stop4440"
style="stop-color:#ffffff;stop-opacity:1"
offset="0" />
<stop
id="stop4442"
style="stop-color:#ffffff;stop-opacity:0"
offset="1" />
</linearGradient>
<linearGradient
id="linearGradient4430">
<stop
id="stop4432"
style="stop-color:#ea3231;stop-opacity:1"
offset="0" />
<stop
id="stop4434"
style="stop-color:#ea3231;stop-opacity:0"
offset="1" />
</linearGradient>
<linearGradient
id="linearGradient3971">
<stop
id="stop3973"
style="stop-color:#ea1117;stop-opacity:1"
offset="0" />
<stop
id="stop3975"
style="stop-color:#d3070e;stop-opacity:1"
offset="0.36951563" />
<stop
id="stop3977"
style="stop-color:#c10914;stop-opacity:0"
offset="1" />
</linearGradient>
<linearGradient
id="linearGradient3862">
<stop
id="stop3864"
style="stop-color:#ea1117;stop-opacity:1"
offset="0" />
<stop
id="stop3866"
style="stop-color:#ea1117;stop-opacity:0"
offset="1" />
</linearGradient>
<linearGradient
id="linearGradient3798">
<stop
id="stop3800"
style="stop-color:#f8ee46;stop-opacity:1"
offset="0" />
<stop
id="stop3802"
style="stop-color:#d3321c;stop-opacity:0"
offset="1" />
</linearGradient>
<linearGradient
id="linearGradient3774">
<stop
id="stop3776"
style="stop-color:#ea1117;stop-opacity:1"
offset="0" />
<stop
id="stop3782"
style="stop-color:#cd0d14;stop-opacity:1"
offset="0.36951563" />
<stop
id="stop3778"
style="stop-color:#c10914;stop-opacity:0"
offset="1" />
</linearGradient>
<filter
color-interpolation-filters="sRGB"
id="filter4426">
<feGaussianBlur
id="feGaussianBlur4428"
stdDeviation="0.79644532" />
</filter>
<filter
x="-0.014859335"
y="-0.10673607"
width="1.0297188"
height="1.2134721"
color-interpolation-filters="sRGB"
id="filter4487">
<feGaussianBlur
id="feGaussianBlur4489"
stdDeviation="0.49337636" />
</filter>
<filter
color-interpolation-filters="sRGB"
id="filter4511">
<feGaussianBlur
id="feGaussianBlur4513"
stdDeviation="0.36832783" />
</filter>
<radialGradient
cx="355.6181"
cy="263.47437"
r="214.64285"
fx="355.6181"
fy="263.47437"
id="radialGradient4735"
xlink:href="#linearGradient4662"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.49590542,0.02361451,-0.04756507,0.99886814,191.79733,-8.0995301)" />
<radialGradient
cx="688.20172"
cy="322.61343"
r="214.64285"
fx="688.20172"
fy="322.61343"
id="radialGradient4737"
xlink:href="#linearGradient4662"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.18891652,0.20859515,-0.74120407,0.67127976,623.54686,-26.528142)" />
<radialGradient
cx="-50.826534"
cy="568.55469"
r="214.64285"
fx="-50.826534"
fy="568.55469"
id="radialGradient4739"
xlink:href="#linearGradient4662"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.24198071,-0.24251704,0.72109578,0.71950102,-205.95295,-86.121137)" />
<radialGradient
cx="143.2925"
cy="560.57587"
r="214.64285"
fx="143.2925"
fy="560.57587"
id="radialGradient4741"
xlink:href="#linearGradient4680"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.55737084,1.1227092,-1.0153425,0.5040684,651.47245,105.56632)" />
<radialGradient
cx="359.53653"
cy="680.74078"
r="214.64285"
fx="359.53653"
fy="680.74078"
id="radialGradient4743"
xlink:href="#linearGradient4698"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.59429926,-0.0196788,0.03309447,0.99945223,124.48576,7.5040537)" />
<radialGradient
cx="549.07318"
cy="531.27026"
r="214.64285"
fx="549.07318"
fy="531.27026"
id="radialGradient4745"
xlink:href="#linearGradient4716"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-0.28951144,0.61897854,-1.0261698,-0.47996685,1253.2096,446.39787)" />
<radialGradient
cx="137.3555"
cy="130.31177"
r="87.547356"
fx="137.3555"
fy="130.31177"
id="radialGradient4965"
xlink:href="#linearGradient3774"
gradientUnits="userSpaceOnUse" />
<radialGradient
cx="137.3555"
cy="130.31177"
r="87.547356"
fx="137.3555"
fy="130.31177"
id="radialGradient4967"
xlink:href="#linearGradient3971"
gradientUnits="userSpaceOnUse" />
<radialGradient
cx="126.875"
cy="125.125"
r="44.125"
fx="126.875"
fy="125.125"
id="radialGradient4969"
xlink:href="#linearGradient3862"
gradientUnits="userSpaceOnUse" />
<radialGradient
cx="493.20697"
cy="120.2355"
r="20.152544"
fx="493.20697"
fy="120.2355"
id="radialGradient4971"
xlink:href="#linearGradient3798"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.75621888,0,0,0.75621888,120.23455,29.311144)" />
<linearGradient
x1="59.75"
y1="853.86218"
x2="63.5"
y2="848.86218"
id="linearGradient4973"
xlink:href="#linearGradient4454"
gradientUnits="userSpaceOnUse" />
<linearGradient
x1="65.5"
y1="878.36218"
x2="89"
y2="879.86218"
id="linearGradient4975"
xlink:href="#linearGradient4438"
gradientUnits="userSpaceOnUse" />
<linearGradient
x1="159.09903"
y1="895.73804"
x2="155.03316"
y2="895.2077"
id="linearGradient4977"
xlink:href="#linearGradient4430"
gradientUnits="userSpaceOnUse" />
<linearGradient
x1="201.5"
y1="859.36218"
x2="192.5"
y2="865.86218"
id="linearGradient4979"
xlink:href="#linearGradient4446"
gradientUnits="userSpaceOnUse" />
<radialGradient
cx="565.67145"
cy="446.36511"
r="214.64285"
fx="565.67145"
fy="446.36511"
id="radialGradient4981"
xlink:href="#linearGradient4546"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-0.01141222,0.7200172,-0.98807046,-0.01566569,1003.895,64.801116)" />
<radialGradient
cx="525.8512"
cy="583.23352"
r="214.64285"
fx="525.8512"
fy="583.23352"
id="radialGradient4983"
xlink:href="#linearGradient4562"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-0.13019845,0.14665728,-0.25941753,-0.23030421,735.49435,657.5781)" />
<radialGradient
cx="338.53104"
cy="703.86841"
r="214.64285"
fx="338.53104"
fy="703.86841"
id="radialGradient4985"
xlink:href="#linearGradient4572"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-0.27995439,0.0091529,-0.01235873,-0.37800452,464.47395,950.87477)" />
<radialGradient
cx="241.97748"
cy="591.31604"
r="214.64285"
fx="241.97748"
fy="591.31604"
id="radialGradient4987"
xlink:href="#linearGradient4584"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.0653018,0.07876991,-0.51364189,0.42581899,480.05196,325.10195)" />
<radialGradient
cx="148.8054"
cy="479.24811"
r="214.64285"
fx="148.8054"
fy="479.24811"
id="radialGradient4989"
xlink:href="#linearGradient4594"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.03414964,0.69195514,-1.3162287,0.06495088,774.52389,345.15386)" />
<radialGradient
cx="361.88593"
cy="270.58835"
r="214.64285"
fx="361.88593"
fy="270.58835"
id="radialGradient4991"
xlink:href="#linearGradient4622"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.50253939,0.0288342,-0.05728614,0.9983578,195.52495,-9.9903306)" />
<radialGradient
cx="113.19639"
cy="362.84845"
r="214.64285"
fx="113.19639"
fy="362.84845"
id="radialGradient4993"
xlink:href="#linearGradient4632"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-0.08238352,0.11533689,-0.81373347,-0.58123819,490.52721,552.42839)" />
</defs>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title>HAL9000</dc:title>
<dc:date></dc:date>
<dc:creator>
<cc:Agent>
<dc:title>MorningLemon</dc:title>
</cc:Agent>
</dc:creator>
<dc:language>German</dc:language>
<dc:subject>
<rdf:Bag>
<rdf:li>HAL</rdf:li>
<rdf:li>9000</rdf:li>
<rdf:li>HAL9000</rdf:li>
<rdf:li>robot</rdf:li>
<rdf:li>space</rdf:li>
</rdf:Bag>
</dc:subject>
<dc:description>The famous red eye of HAL 9000 from Stanley Kubricks Film &quot;2001: A Space Odyssey&quot;.</dc:description>
<cc:license
rdf:resource="http://creativecommons.org/licenses/by/3.0/" />
</cc:Work>
<cc:License
rdf:about="http://creativecommons.org/licenses/by/3.0/">
<cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction" />
<cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution" />
<cc:requires
rdf:resource="http://creativecommons.org/ns#Notice" />
<cc:requires
rdf:resource="http://creativecommons.org/ns#Attribution" />
<cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
</cc:License>
</rdf:RDF>
</metadata>
<g
transform="translate(0,-796.36218)"
id="layer1">
<g
transform="translate(-727,-21)"
id="g4726">
<path
d="m 568.57141,471.29074 a 214.64285,214.64285 0 1 1 -429.2857,0 214.64285,214.64285 0 1 1 429.2857,0 z"
transform="matrix(0.59186864,0,0,0.59186864,645.52079,666.41997)"
id="path4724"
style="fill:#5d5f5f;fill-opacity:1;stroke:none" />
<path
d="m 568.57141,471.29074 a 214.64285,214.64285 0 1 1 -429.2857,0 214.64285,214.64285 0 1 1 429.2857,0 z"
transform="matrix(0.59186864,0,0,0.59186864,645.52079,666.41997)"
id="path4642"
style="fill:url(#radialGradient4735);fill-opacity:1;stroke:none" />
<path
d="m 568.57141,471.29074 a 214.64285,214.64285 0 1 1 -429.2857,0 214.64285,214.64285 0 1 1 429.2857,0 z"
transform="matrix(0.59186864,0,0,0.59186864,645.52079,666.41997)"
id="path4670"
style="fill:url(#radialGradient4737);fill-opacity:1;stroke:none" />
<path
d="m 568.57141,471.29074 a 214.64285,214.64285 0 1 1 -429.2857,0 214.64285,214.64285 0 1 1 429.2857,0 z"
transform="matrix(0.59186864,0,0,0.59186864,645.52079,666.41997)"
id="path4674"
style="fill:url(#radialGradient4739);fill-opacity:1;stroke:none" />
<path
d="m 568.57141,471.29074 a 214.64285,214.64285 0 1 1 -429.2857,0 214.64285,214.64285 0 1 1 429.2857,0 z"
transform="matrix(0.59186864,0,0,0.59186864,645.52079,666.41997)"
id="path4678"
style="fill:url(#radialGradient4741);fill-opacity:1;stroke:none" />
<path
d="m 568.57141,471.29074 a 214.64285,214.64285 0 1 1 -429.2857,0 214.64285,214.64285 0 1 1 429.2857,0 z"
transform="matrix(0.59186864,0,0,0.59186864,645.52079,666.41997)"
id="path4688"
style="fill:url(#radialGradient4743);fill-opacity:1;stroke:none" />
<path
d="m 568.57141,471.29074 a 214.64285,214.64285 0 1 1 -429.2857,0 214.64285,214.64285 0 1 1 429.2857,0 z"
transform="matrix(0.59186864,0,0,0.59186864,645.52079,666.41997)"
id="path4706"
style="fill:url(#radialGradient4745);fill-opacity:1;stroke:none" />
</g>
<g
id="g4924">
<path
d="m 568.57141,471.29074 a 214.64285,214.64285 0 1 1 -429.2857,0 214.64285,214.64285 0 1 1 429.2857,0 z"
transform="matrix(0.56551391,0,0,0.56551391,-72.151523,657.84071)"
id="path3766"
style="fill:#706062;fill-opacity:1;stroke:none" />
<path
d="m 568.57141,471.29074 a 214.64285,214.64285 0 1 1 -429.2857,0 214.64285,214.64285 0 1 1 429.2857,0 z"
transform="matrix(0.56551391,0,0,0.56551391,-72.151523,657.84071)"
id="path4556"
style="fill:#767676;fill-opacity:1;stroke:none" />
<path
d="m 568.57141,471.29074 a 214.64285,214.64285 0 1 1 -429.2857,0 214.64285,214.64285 0 1 1 429.2857,0 z"
transform="matrix(0.56551391,0,0,0.56551391,-72.151523,657.84071)"
id="path4544"
style="fill:url(#radialGradient4981);fill-opacity:1;stroke:none" />
<path
d="m 568.57141,471.29074 a 214.64285,214.64285 0 1 1 -429.2857,0 214.64285,214.64285 0 1 1 429.2857,0 z"
transform="matrix(0.56551391,0,0,0.56551391,-72.151523,657.84071)"
id="path4560"
style="fill:url(#radialGradient4983);fill-opacity:1;stroke:none" />
<path
d="m 568.57141,471.29074 a 214.64285,214.64285 0 1 1 -429.2857,0 214.64285,214.64285 0 1 1 429.2857,0 z"
transform="matrix(0.56551391,0,0,0.56551391,-72.151523,657.84071)"
id="path4570"
style="fill:url(#radialGradient4985);fill-opacity:1;stroke:none" />
<path
d="m 568.57141,471.29074 a 214.64285,214.64285 0 1 1 -429.2857,0 214.64285,214.64285 0 1 1 429.2857,0 z"
transform="matrix(0.56551391,0,0,0.56551391,-72.151523,657.84071)"
id="path4582"
style="fill:url(#radialGradient4987);fill-opacity:1;stroke:none" />
<path
d="m 568.57141,471.29074 a 214.64285,214.64285 0 1 1 -429.2857,0 214.64285,214.64285 0 1 1 429.2857,0 z"
transform="matrix(0.56551391,0,0,0.56551391,-72.151523,657.84071)"
id="path4592"
style="fill:url(#radialGradient4989);fill-opacity:1;stroke:none" />
<path
d="m 568.57141,471.29074 a 214.64285,214.64285 0 1 1 -429.2857,0 214.64285,214.64285 0 1 1 429.2857,0 z"
transform="matrix(0.56551391,0,0,0.56551391,-72.151523,657.84071)"
id="path4602"
style="fill:url(#radialGradient4991);fill-opacity:1;stroke:none" />
<path
d="m 568.57141,471.29074 a 214.64285,214.64285 0 1 1 -429.2857,0 214.64285,214.64285 0 1 1 429.2857,0 z"
transform="matrix(0.56551391,0,0,0.56551391,-72.151523,657.84071)"
id="path4630"
style="fill:url(#radialGradient4993);fill-opacity:1;stroke:none" />
</g>
<g
transform="translate(-2.2167876e-7,-3.5831251e-6)"
id="g4879">
<g
id="g4800">
<path
d="m 568.57141,471.29074 a 214.64285,214.64285 0 1 1 -429.2857,0 214.64285,214.64285 0 1 1 429.2857,0 z"
transform="matrix(0.51198087,0,0,0.51198087,-53.204651,683.07034)"
id="path3768"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-opacity:1" />
<g
id="g4785">
<path
d="m 224.15285,130.31177 a 86.797356,86.797356 0 1 1 -173.594706,0 86.797356,86.797356 0 1 1 173.594706,0 z"
transform="matrix(0.74941633,0,0,0.74941633,25.063546,826.70441)"
id="path3874"
style="opacity:0.38050316;fill:url(#radialGradient4965);fill-opacity:1;stroke:none" />
<path
d="m 224.15285,130.31177 a 86.797356,86.797356 0 1 1 -173.594706,0 86.797356,86.797356 0 1 1 173.594706,0 z"
transform="translate(-9.3554993,794.05041)"
id="path3772"
style="fill:url(#radialGradient4967);fill-opacity:1;stroke:none" />
<path
d="m 170.25,125.125 a 43.375,43.375 0 1 1 -86.75,0 43.375,43.375 0 1 1 86.75,0 z"
transform="translate(1.125,801.23718)"
id="path3860"
style="fill:url(#radialGradient4969);fill-opacity:1;stroke:#ef1d00;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:0;stroke-dasharray:none;stroke-dashoffset:0" />
<path
d="m 513.35951,120.2355 a 20.152544,20.152544 0 1 1 -40.30508,0 20.152544,20.152544 0 1 1 40.30508,0 z"
transform="matrix(1.6843175,0,0,1.6843175,-702.71713,721.84743)"
id="path3796"
style="fill:url(#radialGradient4971);fill-opacity:1;stroke:none" />
<path
d="m 498.51029,117.9374 a 4.0658641,4.0658641 0 1 1 -8.13173,0 4.0658641,4.0658641 0 1 1 8.13173,0 z"
transform="matrix(0.86956522,0,0,0.86956522,-301.95168,821.80792)"
id="path3818"
style="fill:#f4f846;fill-opacity:1;stroke:none" />
</g>
</g>
<g
id="g4859">
<g
id="g4393"
style="filter:url(#filter4426)">
<path
d="m 98.20252,833.52304 2.12132,8.3085 c 15.36068,-5.88046 45.97986,-3.83259 61.87184,3.71231 l 1.59099,-8.3085 C 143.78528,828.55429 112.399,829.1646 98.20252,833.52304 z"
id="path4007"
style="fill:#c9c9bf;fill-opacity:1;stroke:none" />
<path
d="m 58.60454,849.25616 c -13.04121,17.05062 -17.38118,27.71714 -19.09189,37.29989 3.26274,-1.56268 4.80687,-4.56751 6.34116,-7.26083 6.11733,-9.48606 12.36426,-19.07676 21.05923,-25.44286 z"
id="path4009"
style="fill:url(#linearGradient4973);fill-opacity:1;stroke:none" />
<path
d="m 58.60454,877.71721 11.3137,-8.3085 9.54595,9.36916 -10.78338,4.5962 z"
id="path4011"
style="opacity:0.53773588;fill:url(#linearGradient4975);fill-opacity:1;stroke:none" />
<path
d="m 111.10722,853.49881 c 9.39243,-1.29788 19.13002,-0.86996 28.99137,0.17677 0.56932,2.02341 0.78297,4.40249 0.88389,6.89429 -9.25131,-0.68705 -18.50263,-1.334 -27.75394,-0.17677 -0.425,-2.01599 -1.22929,-4.41128 -2.12132,-6.89429 z"
id="path4013"
style="opacity:0.53773588;fill:#ffffff;fill-opacity:1;stroke:none" />
<path
d="m 178.81269,871.17648 -5.12652,6.54073 c 2.70117,0.95519 6.19764,2.10921 10.78337,3.53554 1.18577,-1.42148 2.82121,-3.29262 4.94975,-5.65686 -2.93649,-1.67282 -6.26255,-3.21578 -10.6066,-4.41941 z"
id="path4015"
style="fill:#816461;fill-opacity:1;stroke:none" />
<path
d="m 91.66178,896.98587 5.12652,-3.88909 2.2981,3.53554 -3.88909,3.18198 z"
id="path4017"
style="fill:#f15e4f;fill-opacity:1;stroke:none" />
<path
d="m 118.88539,882.31341 c 5.81338,-0.94098 11.07456,-0.22537 16.44023,0.17677 l -0.35355,3.18198 c -5.24438,-0.1614 -10.48875,-0.5541 -15.73313,0.17678 z"
id="path4019"
style="fill:#ec4e3e;fill-opacity:1;stroke:none" />
<path
d="m 119.76927,895.21811 c 7.32697,-1.53523 13.82111,-1.00484 19.26866,2.12132 l 1.41422,-2.82843 c -7.72213,-3.49102 -16.3628,-3.26623 -21.38998,-1.59099 z"
id="path4021"
style="fill:#e66044;fill-opacity:1;stroke:none" />
<path
d="m 160.25114,898.75364 2.12132,-3.0052 -4.94975,-2.2981 -2.65165,2.2981 z"
id="path4023"
style="fill:url(#linearGradient4977);fill-opacity:1;stroke:none" />
<path
d="m 155.30139,906.70859 c -2.27582,-3.53407 -2.72679,-5.37411 -4.77297,-7.07107 l -1.59099,1.06066 c 2.46956,2.24316 4.48363,4.82793 6.01041,7.77818 z"
id="path4025"
style="fill:#f74639;fill-opacity:1;stroke:none" />
<path
d="m 132.4972,901.40529 c -2.88735,-0.75674 -5.77471,-0.58908 -8.66206,-0.17678 l 0,-1.94454 c 2.70228,-0.8158 5.54755,-0.77368 8.48528,-0.17678 z"
id="path4027"
style="fill:#ef4d2b;fill-opacity:1;stroke:none" />
<path
d="m 108.63234,900.52141 c -3.03319,2.75901 -5.03926,5.90318 -6.18718,9.36916 l -1.06066,-1.06066 c 1.17722,-3.76435 2.99712,-7.35399 6.0104,-10.42982 z"
id="path4029"
style="fill:#eb5241;fill-opacity:1;stroke:none" />
<path
d="m 110.57688,908.47636 1.06067,-2.47488 -2.65166,-1.06066 -1.23743,2.47488 z"
id="path4031"
style="fill:#f7432e;fill-opacity:1;stroke:none" />
<path
d="m 146.99288,908.29958 1.23744,-1.94454 -2.12132,-1.23744 -1.76777,1.41421 z"
id="path4033"
style="fill:#f7432e;fill-opacity:1;stroke:none" />
<path
d="m 191.57094,855.65046 4.77297,-4.06587 c 8.44599,4.49145 14.50078,13.36678 20.15255,22.98097 l -2.47488,2.47488 c -7.3081,-7.48088 -14.67726,-14.83965 -22.45064,-21.38998 z"
id="path4035"
style="fill:url(#linearGradient4979);fill-opacity:1;stroke:none" />
</g>
<path
d="m 128,26.25 c -12.15497,0 -23.81896,2.153186 -34.625,6.0625 l 2.34375,0.15625 C 105.86477,29.075845 116.71287,28.25 128,28.25 c 15.02248,0 29.28979,2.249441 42.125,8.09375 L 173.0625,36.75 C 159.47932,30.02936 144.18114,26.25 128,26.25 z"
transform="translate(0,796.36218)"
id="path4462"
style="opacity:0.93081761;fill:#35373c;fill-opacity:1;stroke:none;filter:url(#filter4487)" />
<path
d="M 49.71875,63 C 46.008058,67.46453 42.663152,72.251252 39.75,77.3125 l 0.1875,0.03125 c 1.24315,-2.050869 3.554075,-4.055585 4.931758,-6.00967 1.73058,-2.454631 3.566499,-5.829368 5.505742,-8.11533 z"
transform="translate(0,796.36218)"
id="path4469"
style="opacity:0.39308178;fill:#bab7b2;fill-opacity:1;stroke:none;filter:url(#filter4511)" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -2,8 +2,10 @@ package app.mealsmadeeasy.api;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class MealsMadeEasyApiApplication {
public static void main(String[] args) {

View File

@ -2,8 +2,10 @@ package app.mealsmadeeasy.api.auth;
import app.mealsmadeeasy.api.jwt.JwtService;
import app.mealsmadeeasy.api.user.UserEntity;
import jakarta.transaction.Transactional;
import org.jetbrains.annotations.Nullable;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
@ -12,9 +14,10 @@ import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service
public final class AuthServiceImpl implements AuthService {
public class AuthServiceImpl implements AuthService {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
@ -60,11 +63,13 @@ public final class AuthServiceImpl implements AuthService {
}
@Override
@Transactional
public void logout(String refreshToken) {
this.refreshTokenRepository.findByToken(refreshToken).ifPresent(this.refreshTokenRepository::delete);
}
@Override
@Transactional
public LoginDetails refresh(@Nullable String refreshToken) throws LoginException {
if (refreshToken == null) {
throw new LoginException(LoginExceptionReason.NO_REFRESH_TOKEN, "No refresh token provided.");
@ -73,17 +78,18 @@ public final class AuthServiceImpl implements AuthService {
final RefreshTokenEntity old = this.refreshTokenRepository.findByToken(refreshToken)
.orElseThrow(() -> new LoginException(
LoginExceptionReason.INVALID_REFRESH_TOKEN,
"No such refresh-token: " + refreshToken
"No such refresh token: " + refreshToken
));
if (old.isRevoked()) {
throw new LoginException(LoginExceptionReason.INVALID_REFRESH_TOKEN, "RefreshToken is revoked.");
if (old.isRevoked() || old.isDeleted()) {
throw new LoginException(LoginExceptionReason.INVALID_REFRESH_TOKEN, "Invalid refresh token.");
}
if (old.getExpires().isBefore(LocalDateTime.now())) {
throw new LoginException(LoginExceptionReason.EXPIRED_REFRESH_TOKEN, "RefreshToken is expired.");
throw new LoginException(LoginExceptionReason.EXPIRED_REFRESH_TOKEN, "Refresh token is expired.");
}
final UserEntity principal = old.getOwner();
this.refreshTokenRepository.delete(old);
old.setDeleted(true);
this.refreshTokenRepository.save(old);
final String username = principal.getUsername();
return new LoginDetails(
@ -93,4 +99,9 @@ public final class AuthServiceImpl implements AuthService {
);
}
@Scheduled(fixedDelay = 60, timeUnit = TimeUnit.SECONDS)
public void cleanUpDeletedRefreshTokens() {
this.refreshTokenRepository.deleteAllWhereSoftDeleted();
}
}

View File

@ -7,4 +7,5 @@ import java.time.LocalDateTime;
public interface RefreshToken extends AuthToken {
LocalDateTime getIssued();
boolean isRevoked();
boolean isDeleted();
}

View File

@ -30,6 +30,9 @@ public class RefreshTokenEntity implements RefreshToken {
@ManyToOne
private UserEntity owner;
@Column(nullable = false)
private Boolean deleted = false;
public Long getId() {
return this.id;
}
@ -70,7 +73,7 @@ public class RefreshTokenEntity implements RefreshToken {
return this.revoked;
}
public void setRevoked(Boolean revoked) {
public void setRevoked(boolean revoked) {
this.revoked = revoked;
}
@ -82,6 +85,15 @@ public class RefreshTokenEntity implements RefreshToken {
this.owner = owner;
}
@Override
public boolean isDeleted() {
return this.deleted;
}
public void setDeleted(boolean deleted) {
this.deleted = deleted;
}
@Override
public long getLifetime() {
return ChronoUnit.SECONDS.between(this.issued, this.expiration);

View File

@ -1,9 +1,19 @@
package app.mealsmadeeasy.api.auth;
import jakarta.transaction.Transactional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import java.util.Optional;
public interface RefreshTokenRepository extends JpaRepository<RefreshTokenEntity, Long> {
Optional<RefreshTokenEntity> findByToken(String token);
@Modifying
@Transactional
@Query("DELETE FROM RefreshToken t WHERE t.deleted = true")
void deleteAllWhereSoftDeleted();
}

View File

@ -3,7 +3,10 @@ package app.mealsmadeeasy.api.image;
public class ImageException extends Exception {
public enum Type {
INVALID_ID, IMAGE_NOT_FOUND, UNKNOWN_MIME_TYPE
INVALID_ID,
INVALID_USERNAME_OR_FILENAME,
IMAGE_NOT_FOUND,
UNKNOWN_MIME_TYPE
}
private final Type type;

View File

@ -17,6 +17,7 @@ public interface ImageService {
Image getById(long id, @Nullable User viewer) throws ImageException;
Image getByOwnerAndFilename(User owner, String filename, User viewer) throws ImageException;
Image getByUsernameAndFilename(String username, String filename, User viewer) throws ImageException;
InputStream getImageContent(Image image, @Nullable User viewer) throws IOException;
List<Image> getImagesOwnedBy(User user);

View File

@ -17,4 +17,7 @@ public interface S3ImageRepository extends JpaRepository<S3ImageEntity, Long> {
List<S3ImageEntity> findAllByOwner(UserEntity owner);
Optional<S3ImageEntity> findByOwnerAndUserFilename(UserEntity owner, String filename);
@Query("SELECT image from Image image WHERE image.owner.username = ?1 AND image.userFilename = ?2")
Optional<S3ImageEntity> findByOwnerUsernameAndFilename(String username, String filename);
}

View File

@ -139,6 +139,17 @@ public class S3ImageService implements ImageService {
));
}
@Override
@PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)")
public Image getByUsernameAndFilename(String username, String filename, User viewer) throws ImageException {
return this.imageRepository.findByOwnerUsernameAndFilename(username, filename).orElseThrow(
() -> new ImageException(
ImageException.Type.INVALID_USERNAME_OR_FILENAME,
"No such Image for username " + username + " and filename " + filename
)
);
}
@Override
@PreAuthorize("@imageSecurity.isViewableBy(#image, #viewer)")
public InputStream getImageContent(Image image, User viewer) throws IOException {

View File

@ -24,5 +24,5 @@ public interface Recipe {
boolean isPublic();
Set<User> getViewers();
Set<RecipeComment> getComments();
Image getMainImage();
@Nullable Image getMainImage();
}

View File

@ -1,5 +1,7 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.star.RecipeStar;
import app.mealsmadeeasy.api.recipe.star.RecipeStarService;
import app.mealsmadeeasy.api.recipe.view.FullRecipeView;
@ -42,6 +44,14 @@ public class RecipeController {
));
}
private Map<String, Object> getFullViewWrapper(String username, String slug, FullRecipeView view, @Nullable User viewer) {
Map<String, Object> wrapper = new HashMap<>();
wrapper.put("recipe", view);
wrapper.put("isStarred", this.recipeService.isStarer(username, slug, viewer));
wrapper.put("isOwner", this.recipeService.isOwner(username, slug, viewer));
return wrapper;
}
@GetMapping("/{username}/{slug}")
public ResponseEntity<Map<String, Object>> getByUsernameAndSlug(
@PathVariable String username,
@ -55,11 +65,20 @@ public class RecipeController {
includeRawText,
viewer
);
final Map<String, Object> body = new HashMap<>();
body.put("recipe", view);
body.put("isStarred", this.recipeService.isStarer(username, slug, viewer));
body.put("isOwner", this.recipeService.isOwner(username, slug, viewer));
return ResponseEntity.ok(body);
return ResponseEntity.ok(this.getFullViewWrapper(username, slug, view, viewer));
}
@PostMapping("/{username}/{slug}")
public ResponseEntity<Map<String, Object>> updateByUsernameAndSlug(
@PathVariable String username,
@PathVariable String slug,
@RequestParam(defaultValue = "true") boolean includeRawText,
@RequestBody RecipeUpdateSpec updateSpec,
@AuthenticationPrincipal User principal
) throws ImageException, RecipeException {
final Recipe updated = this.recipeService.update(username, slug, updateSpec, principal);
final FullRecipeView view = this.recipeService.toFullRecipeView(updated, includeRawText, principal);
return ResponseEntity.ok(this.getFullViewWrapper(username, slug, view, principal));
}
@GetMapping

View File

@ -153,11 +153,11 @@ public final class RecipeEntity implements Recipe {
this.rawText = rawText;
}
public String getCachedRenderedText() {
public @Nullable String getCachedRenderedText() {
return this.cachedRenderedText;
}
public void setCachedRenderedText(String cachedRenderedText) {
public void setCachedRenderedText(@Nullable String cachedRenderedText) {
this.cachedRenderedText = cachedRenderedText;
}
@ -224,11 +224,11 @@ public final class RecipeEntity implements Recipe {
}
@Override
public S3ImageEntity getMainImage() {
public @Nullable S3ImageEntity getMainImage() {
return this.mainImage;
}
public void setMainImage(S3ImageEntity image) {
public void setMainImage(@Nullable S3ImageEntity image) {
this.mainImage = image;
}

View File

@ -6,6 +6,7 @@ import org.jetbrains.annotations.Nullable;
public interface RecipeSecurity {
boolean isOwner(Recipe recipe, User user);
boolean isOwner(long recipeId, User user) throws RecipeException;
boolean isOwner(String username, String slug, @Nullable User user) throws RecipeException;
boolean isViewableBy(Recipe recipe, @Nullable User user) throws RecipeException;
boolean isViewableBy(String ownerUsername, String slug, @Nullable User user) throws RecipeException;
boolean isViewableBy(long recipeId, @Nullable User user) throws RecipeException;

View File

@ -29,6 +29,17 @@ public class RecipeSecurityImpl implements RecipeSecurity {
return this.isOwner(recipe, user);
}
@Override
public boolean isOwner(String username, String slug, @Nullable User user) throws RecipeException {
final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow(
() -> new RecipeException(
RecipeException.Type.INVALID_USERNAME_OR_SLUG,
"No such Recipe for username " + username + " and slug " + slug
)
);
return this.isOwner(recipe, user);
}
@Override
public boolean isViewableBy(Recipe recipe, @Nullable User user) throws RecipeException {
if (recipe.isPublic()) {

View File

@ -1,5 +1,6 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.view.FullRecipeView;
@ -34,7 +35,8 @@ public interface RecipeService {
List<Recipe> getRecipesViewableBy(User viewer);
List<Recipe> getRecipesOwnedBy(User owner);
Recipe update(long id, RecipeUpdateSpec spec, User modifier) throws RecipeException;
Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier)
throws RecipeException, ImageException;
Recipe addViewer(long id, User modifier, User viewer) throws RecipeException;
Recipe removeViewer(long id, User modifier, User viewer) throws RecipeException;
@ -42,6 +44,9 @@ public interface RecipeService {
void deleteRecipe(long id, User modifier);
FullRecipeView toFullRecipeView(Recipe recipe, boolean includeRawText, @Nullable User viewer);
RecipeInfoView toRecipeInfoView(Recipe recipe, @Nullable User viewer);
@Contract("_, _, null -> null")
@Nullable Boolean isStarer(String username, String slug, @Nullable User viewer);

View File

@ -1,6 +1,7 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.image.ImageService;
import app.mealsmadeeasy.api.image.S3ImageEntity;
import app.mealsmadeeasy.api.image.view.ImageView;
@ -199,46 +200,38 @@ public class RecipeServiceImpl implements RecipeService {
}
@Override
@PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
public Recipe update(long id, RecipeUpdateSpec spec, User modifier) throws RecipeException {
final RecipeEntity entity = this.findRecipeEntity(id);
boolean didModify = false;
if (spec.getSlug() != null) {
entity.setSlug(spec.getSlug());
didModify = true;
@PreAuthorize("@recipeSecurity.isOwner(#username, #slug, #modifier)")
public Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier)
throws RecipeException, ImageException {
final RecipeEntity recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow(() ->
new RecipeException(
RecipeException.Type.INVALID_USERNAME_OR_SLUG,
"No such Recipe for username " + username + " and slug: " + slug
)
);
recipe.setTitle(spec.getTitle());
recipe.setPreparationTime(spec.getPreparationTime());
recipe.setCookingTime(spec.getCookingTime());
recipe.setTotalTime(spec.getTotalTime());
recipe.setRawText(spec.getRawText());
recipe.setCachedRenderedText(null);
recipe.setPublic(spec.getIsPublic());
final S3ImageEntity mainImage;
if (spec.getMainImage() == null) {
mainImage = null;
} else {
mainImage = (S3ImageEntity) this.imageService.getByUsernameAndFilename(
spec.getMainImage().getUsername(),
spec.getMainImage().getFilename(),
modifier
);
}
if (spec.getTitle() != null) {
entity.setTitle(spec.getTitle());
didModify = true;
}
if (spec.getPreparationTime() != null) {
entity.setPreparationTime(spec.getPreparationTime());
didModify = true;
}
if (spec.getCookingTime() != null) {
entity.setCookingTime(spec.getCookingTime());
didModify = true;
}
if (spec.getTotalTime() != null) {
entity.setTotalTime(spec.getTotalTime());
didModify = true;
}
if (spec.getRawText() != null) {
entity.setRawText(spec.getRawText());
didModify = true;
}
if (spec.getPublic() != null) {
entity.setPublic(spec.getPublic());
didModify = true;
}
if (spec.getMainImage() != null) {
entity.setMainImage((S3ImageEntity) spec.getMainImage());
didModify = true;
}
if (didModify) {
entity.setModified(LocalDateTime.now());
}
return this.recipeRepository.save(entity);
recipe.setMainImage(mainImage);
recipe.setModified(LocalDateTime.now());
return this.recipeRepository.save(recipe);
}
@Override
@ -277,6 +270,16 @@ public class RecipeServiceImpl implements RecipeService {
this.recipeRepository.deleteById(id);
}
@Override
public FullRecipeView toFullRecipeView(Recipe recipe, boolean includeRawText, @Nullable User viewer) {
return this.getFullView((RecipeEntity) recipe, includeRawText, viewer);
}
@Override
public RecipeInfoView toRecipeInfoView(Recipe recipe, @Nullable User viewer) {
return this.getInfoView((RecipeEntity) recipe, viewer);
}
@Override
@PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)")
@Contract("_, _, null -> null")

View File

@ -1,25 +1,65 @@
package app.mealsmadeeasy.api.recipe.spec;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.recipe.Recipe;
import org.jetbrains.annotations.Nullable;
// For now, we cannot change slug after creation.
// In the future, we may be able to have redirects from
// old slugs to new slugs.
public class RecipeUpdateSpec {
private @Nullable String slug;
private @Nullable String title;
public static class MainImageUpdateSpec {
private String username;
private String filename;
public String getUsername() {
return this.username;
}
public void setUsername(String username) {
this.username = username;
}
public String getFilename() {
return this.filename;
}
public void setFilename(String filename) {
this.filename = filename;
}
}
private String title;
private @Nullable Integer preparationTime;
private @Nullable Integer cookingTime;
private @Nullable Integer totalTime;
private @Nullable String rawText;
private @Nullable Boolean isPublic;
private @Nullable Image mainImage;
private String rawText;
private boolean isPublic;
private @Nullable MainImageUpdateSpec mainImage;
public @Nullable String getSlug() {
return this.slug;
public RecipeUpdateSpec() {}
/**
* Convenience constructor for testing purposes.
*
* @param recipe the Recipe to copy from
*/
public RecipeUpdateSpec(Recipe recipe) {
this.title = recipe.getTitle();
this.preparationTime = recipe.getPreparationTime();
this.cookingTime = recipe.getCookingTime();
this.totalTime = recipe.getTotalTime();
this.rawText = recipe.getRawText();
this.isPublic = recipe.isPublic();
final @Nullable Image mainImage = recipe.getMainImage();
if (mainImage != null) {
this.mainImage = new MainImageUpdateSpec();
this.mainImage.setUsername(mainImage.getOwner().getUsername());
this.mainImage.setFilename(mainImage.getUserFilename());
}
public void setSlug(@Nullable String slug) {
this.slug = slug;
}
public @Nullable String getTitle() {
@ -62,19 +102,19 @@ public class RecipeUpdateSpec {
this.rawText = rawText;
}
public @Nullable Boolean getPublic() {
public boolean getIsPublic() {
return this.isPublic;
}
public void setPublic(@Nullable Boolean isPublic) {
public void setIsPublic(boolean isPublic) {
this.isPublic = isPublic;
}
public @Nullable Image getMainImage() {
public @Nullable MainImageUpdateSpec getMainImage() {
return this.mainImage;
}
public void setMainImage(@Nullable Image mainImage) {
public void setMainImage(@Nullable MainImageUpdateSpec mainImage) {
this.mainImage = mainImage;
}

View File

@ -17,7 +17,7 @@ public class FullRecipeView {
boolean includeRawText,
int starCount,
int viewerCount,
ImageView mainImage
@Nullable ImageView mainImage
) {
final FullRecipeView view = new FullRecipeView();
view.setId(recipe.getId());
@ -53,7 +53,7 @@ public class FullRecipeView {
private UserInfoView owner;
private int starCount;
private int viewerCount;
private ImageView mainImage;
private @Nullable ImageView mainImage;
private boolean isPublic;
public long getId() {
@ -161,11 +161,11 @@ public class FullRecipeView {
this.viewerCount = viewerCount;
}
public ImageView getMainImage() {
public @Nullable ImageView getMainImage() {
return this.mainImage;
}
public void setMainImage(ImageView mainImage) {
public void setMainImage(@Nullable ImageView mainImage) {
this.mainImage = mainImage;
}

View File

@ -1,13 +1,13 @@
spring.application.name=meals-made-easy-api
spring.jpa.hibernate.ddl-auto=create-drop
spring.datasource.url=jdbc:mysql://localhost:55001/meals_made_easy_api
spring.datasource.username=meals-made-easy-api-user
spring.datasource.password=devpass
spring.datasource.url=jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:meals_made_easy_api}
spring.datasource.username=${MYSQL_USERNAME:meals-made-easy-api-user}
spring.datasource.password=${MYSQL_PASSWORD}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
app.mealsmadeeasy.api.baseUrl=http://localhost:8080
app.mealsmadeeasy.api.security.access-token-lifetime=60
app.mealsmadeeasy.api.security.refresh-token-lifetime=3600
app.mealsmadeeasy.api.minio.endpoint=http://localhost:9000
app.mealsmadeeasy.api.minio.accessKey=minio-root
app.mealsmadeeasy.api.minio.secretKey=test0123
app.mealsmadeeasy.api.minio.endpoint=http://${MINIO_HOST:localhost}:${MINIO_PORT:9000}
app.mealsmadeeasy.api.minio.accessKey=${MINIO_ROOT_USER}
app.mealsmadeeasy.api.minio.secretKey=${MINIO_ROOT_PASSWORD}
app.mealsmadeeasy.api.images.bucketName=images