spring-boot-3-2-upgrade (#32)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: #32
Reviewed-by: Timotheus Pokorra <timotheus.pokorra@hostsharing.net>
This commit is contained in:
Michael Hoennig 2024-04-02 13:24:25 +02:00
parent ad04faa21d
commit 73c378b456
25 changed files with 62 additions and 198 deletions

View File

@ -1,15 +1,15 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.1.7'
id 'org.springframework.boot' version '3.2.4'
id 'io.spring.dependency-management' version '1.1.4'
id 'io.openapiprocessor.openapi-processor' version '2023.2'
id 'com.github.jk1.dependency-license-report' version '2.5'
id "org.owasp.dependencycheck" version "9.0.7"
id "com.diffplug.spotless" version "6.23.3"
id 'com.github.jk1.dependency-license-report' version '2.6'
id "org.owasp.dependencycheck" version "9.0.10"
id "com.diffplug.spotless" version "6.25.0"
id 'jacoco'
id 'info.solidsoft.pitest' version '1.15.0'
id 'se.patrikerdes.use-latest-versions' version '0.2.18'
id 'com.github.ben-manes.versions' version '0.50.0'
id 'com.github.ben-manes.versions' version '0.51.0'
}
group = 'net.hostsharing'
@ -59,28 +59,16 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'com.github.gavlyukovskiy:datasource-proxy-spring-boot-starter:1.9.1'
implementation 'org.springdoc:springdoc-openapi:2.3.0'
implementation 'org.postgresql:postgresql:42.7.1'
implementation 'org.liquibase:liquibase-core:4.25.1'
implementation 'com.vladmihalcea:hibernate-types-60:2.21.1'
implementation 'io.hypersistence:hypersistence-utils-hibernate-62:3.7.0'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.1'
implementation 'org.springdoc:springdoc-openapi:2.4.0'
implementation 'org.postgresql:postgresql:42.7.3'
implementation 'org.liquibase:liquibase-core:4.27.0'
implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.7.3'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.0'
implementation 'org.openapitools:jackson-databind-nullable:0.2.6'
implementation 'org.apache.commons:commons-text:1.11.0'
implementation 'org.modelmapper:modelmapper:3.2.0'
implementation 'org.iban4j:iban4j:3.2.7-RELEASE'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
// fixes vulnerability CVE-2022-1471
// The dependency usually comes from Spring Boot, just in the wrong version.
// TODO: Remove this explicit dependency once we are on SpringBoot 3.2.x
// as well as the related exclude in settings.gradle
// and the dependency suppression in owasp-dependency-check-suppression.xml.
implementation('org.yaml:snakeyaml') {
version {
strictly('2.2')
}
}
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0'
compileOnly 'org.projectlombok:lombok'
testCompileOnly 'org.projectlombok:lombok'

View File

@ -694,7 +694,7 @@ Users can view only the roles to which are granted to them.
Grant can be `empowered`, this means that the grantee user can grant the granted role to other users
and revoke grants to that role.
(TODO: access control part not yet implemented)
(TODO: access control part not yet implemented, currently all accessible roles can be granted to other users)
Grants can be `managed`, which means they are created and deleted by system-defined rules.
If a grant is not managed, it was created by an empowered user and can be deleted by empowered users.

View File

@ -1,33 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<suppressions xmlns="https://jeremylong.github.io/DependencyCheck/dependency-suppression.1.3.xsd">
<suppress>
<notes><![CDATA[
We don't use the Spring HTTP invoker which causes this vulnerability due to Java deserialization.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.springframework/spring-web@.*$</packageUrl>
<cve>CVE-2016-1000027</cve>
</suppress>
<suppress>
<notes><![CDATA[
We don't use the UNWRAP_SINGLE_VALUE_ARRAYS feature and thus are not affected.
]]></notes>
<packageUrl regex="true">^pkg:maven/com\.fasterxml\.jackson\.core/jackson\-databind@.*$</packageUrl>
<cve>CVE-2022-42003</cve>
</suppress>
<suppress>
<notes><![CDATA[
We don't parse external XML.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.eclipse\.angus/angus\-activation@.*$</packageUrl>
<cpe>cpe:/a:eclipse:eclipse_ide</cpe>
</suppress>
<suppress>
<notes><![CDATA[
We don't parse external XML.
]]></notes>
<packageUrl regex="true">^pkg:maven/jakarta\.activation/jakarta\.activation\-api@.*$</packageUrl>
<cpe>cpe:/a:eclipse:eclipse_ide</cpe>
</suppress>
<suppress>
<notes><![CDATA[
Cyclic references are not possible if file comes in JSON text format.
@ -35,13 +7,6 @@
<packageUrl regex="true">^pkg:maven/com\.fasterxml\.jackson\.core/jackson\-databind@.*$</packageUrl>
<cpe>cpe:/a:fasterxml:jackson-databind</cpe>
</suppress>
<suppress>
<notes><![CDATA[
As far as I see Criteria.parse(...) cannot be reached with external data.
]]></notes>
<packageUrl regex="true">^pkg:maven/com\.jayway\.jsonpath/json\-path@.*$</packageUrl>
<vulnerabilityName>CVE-2023-51074</vulnerabilityName>
</suppress>
<suppress>
<notes><![CDATA[
Internal tooling, not exposed to the Internet.
@ -49,17 +14,4 @@
<packageUrl regex="true">^pkg:maven/org\.pitest/pitest\-command\-line@.*$</packageUrl>
<cpe>cpe:/a:line:line</cpe>
</suppress>
<suppress>
<notes><![CDATA[
Spring Boot 3.1.x has a transient dependency to snakeyaml 1.3
which contains this vulnerability.
We've explicitly bumped to 2.2, but the vulnerability checker does not seem to notice that.
TODO: Remove this suppression once we are on SpringBoot 3.2,
as well as the explicit version bump and the transient dependency exclude.
]]></notes>
<packageUrl regex="true">^pkg:maven/org\.yaml/snakeyaml@.*$</packageUrl>
<cve>CVE-2022-1471</cve>
</suppress>
</suppressions>

View File

@ -11,28 +11,4 @@ plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0'
}
dependencyResolutionManagement {
components {
all {
allVariants {
withDependencies {
removeAll {
// Spring Boot 3.1.x has a transient dependency to snakeyaml 1.3
// which contains a severe vulnerability.
// Here we remove this transient dependency and in build.gradle
// we add an explicit dependency to snakeyaml 2.2,
// which does not have this vulnerability anymore.
//
// TODO: Check Once we are on SpringBoot 3.2.x, check if this exclude
// is still neccessary. If not:
// Remove it // as well as the related explicit dependency in build.gradle
// and the dependency suppression in owasp-dependency-check-suppression.xml.
it.module in [ 'snakeyaml' ]
}
}
}
}
}
}
rootProject.name = 'hsadmin-ng'

View File

@ -15,11 +15,9 @@ import java.util.Collections;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import static java.util.function.Predicate.not;
import static net.hostsharing.hsadminng.mapper.PostgresArray.fromPostgresArray;
import static org.springframework.transaction.annotation.Propagation.MANDATORY;
@Service
@ -82,14 +80,11 @@ public class Context {
}
public String[] getAssumedRoles() {
final byte[] result = (byte[]) em.createNativeQuery("select assumedRoles() as roles", String[].class).getSingleResult();
return fromPostgresArray(result, String.class, Function.identity());
return (String[]) em.createNativeQuery("select assumedRoles() as roles", String[].class).getSingleResult();
}
public UUID[] currentSubjectsUuids() {
final byte[] result = (byte[]) em.createNativeQuery("select currentSubjectsUuids() as uuids", UUID[].class)
.getSingleResult();
return fromPostgresArray(result, UUID.class, UUID::fromString);
return (UUID[]) em.createNativeQuery("select currentSubjectsUuids() as uuids", UUID[].class).getSingleResult();
}
public static String getCallerMethodNameFromStackFrame(final int skipFrames) {

View File

@ -11,16 +11,18 @@ import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.lang.Nullable;
import org.springframework.orm.jpa.JpaObjectRetrievalFailureException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.validation.FieldError;
import org.springframework.validation.method.ParameterValidationResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import jakarta.persistence.EntityNotFoundException;
import jakarta.validation.ValidationException;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.*;
import java.util.regex.Pattern;
import static net.hostsharing.hsadminng.errors.CustomErrorResponse.*;
@ -119,6 +121,28 @@ public class RestResponseEntityExceptionHandler
return errorResponse(request, HttpStatus.BAD_REQUEST, errorList.toString());
}
@SuppressWarnings("unchecked,rawtypes")
@Override
protected ResponseEntity handleHandlerMethodValidationException(
final HandlerMethodValidationException exc,
final HttpHeaders headers,
final HttpStatusCode status,
final WebRequest request) {
final var errorList = exc
.getAllValidationResults()
.stream()
.map(ParameterValidationResult::getResolvableErrors)
.flatMap(Collection::stream)
.filter(FieldError.class::isInstance)
.map(FieldError.class::cast)
.map(fieldError -> fieldError.getField() + " " + fieldError.getDefaultMessage() + " but is \""
+ fieldError.getRejectedValue() + "\"")
.toList();
return errorResponse(request, HttpStatus.BAD_REQUEST, errorList.toString());
}
private String userReadableEntityClassName(final String exceptionMessage) {
final var regex = "(net.hostsharing.hsadminng.[a-z0-9_.]*.[A-Za-z0-9_$]*Entity) ";
final var pattern = Pattern.compile(regex);

View File

@ -13,7 +13,6 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import jakarta.validation.Valid;
import jakarta.validation.ValidationException;
import java.time.LocalDate;
import java.util.ArrayList;
@ -59,7 +58,7 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse
public ResponseEntity<HsOfficeCoopAssetsTransactionResource> addCoopAssetsTransaction(
final String currentUser,
final String assumedRoles,
@Valid final HsOfficeCoopAssetsTransactionInsertResource requestBody) {
final HsOfficeCoopAssetsTransactionInsertResource requestBody) {
context.define(currentUser, assumedRoles);
validate(requestBody);

View File

@ -10,7 +10,7 @@ import net.hostsharing.hsadminng.errors.DisplayName;
import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipEntity;
import net.hostsharing.hsadminng.rbac.rbacobject.RbacObject;
import net.hostsharing.hsadminng.rbac.rbacdef.RbacView;
import net.hostsharing.hsadminng.rbac.rbacdef.RbacView;
import net.hostsharing.hsadminng.rbac.rbacobject.RbacObject;
import net.hostsharing.hsadminng.stringify.Stringify;
import net.hostsharing.hsadminng.stringify.Stringifyable;
import org.hibernate.annotations.GenericGenerator;

View File

@ -13,7 +13,6 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import jakarta.validation.Valid;
import jakarta.validation.ValidationException;
import java.time.LocalDate;
import java.util.ArrayList;
@ -60,7 +59,7 @@ public class HsOfficeCoopSharesTransactionController implements HsOfficeCoopShar
public ResponseEntity<HsOfficeCoopSharesTransactionResource> addCoopSharesTransaction(
final String currentUser,
final String assumedRoles,
@Valid final HsOfficeCoopSharesTransactionInsertResource requestBody) {
final HsOfficeCoopSharesTransactionInsertResource requestBody) {
context.define(currentUser, assumedRoles);
validate(requestBody);

View File

@ -7,10 +7,8 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
import net.hostsharing.hsadminng.errors.DisplayName;
import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipEntity;
import net.hostsharing.hsadminng.rbac.rbacdef.RbacView;
import net.hostsharing.hsadminng.rbac.rbacobject.RbacObject;
import net.hostsharing.hsadminng.rbac.rbacdef.RbacView;
import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL;
import net.hostsharing.hsadminng.rbac.rbacdef.RbacView;
import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL;
import net.hostsharing.hsadminng.stringify.Stringify;
import net.hostsharing.hsadminng.stringify.Stringifyable;

View File

@ -12,7 +12,6 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import jakarta.validation.Valid;
import java.util.List;
import java.util.UUID;
import java.util.function.BiConsumer;
@ -53,7 +52,7 @@ public class HsOfficeMembershipController implements HsOfficeMembershipsApi {
public ResponseEntity<HsOfficeMembershipResource> addMembership(
final String currentUser,
final String assumedRoles,
@Valid final HsOfficeMembershipInsertResource body) {
final HsOfficeMembershipInsertResource body) {
context.define(currentUser, assumedRoles);

View File

@ -1,7 +1,7 @@
package net.hostsharing.hsadminng.hs.office.membership;
import com.vladmihalcea.hibernate.type.range.PostgreSQLRangeType;
import com.vladmihalcea.hibernate.type.range.Range;
import io.hypersistence.utils.hibernate.type.range.PostgreSQLRangeType;
import io.hypersistence.utils.hibernate.type.range.Range;
import lombok.*;
import net.hostsharing.hsadminng.errors.DisplayName;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationEntity;

View File

@ -14,7 +14,6 @@ import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBui
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.validation.Valid;
import java.util.List;
import java.util.UUID;
import java.util.function.BiConsumer;
@ -57,7 +56,7 @@ public class HsOfficeSepaMandateController implements HsOfficeSepaMandatesApi {
public ResponseEntity<HsOfficeSepaMandateResource> addSepaMandate(
final String currentUser,
final String assumedRoles,
@Valid final HsOfficeSepaMandateInsertResource body) {
final HsOfficeSepaMandateInsertResource body) {
context.define(currentUser, assumedRoles);

View File

@ -1,7 +1,7 @@
package net.hostsharing.hsadminng.hs.office.sepamandate;
import com.vladmihalcea.hibernate.type.range.PostgreSQLRangeType;
import com.vladmihalcea.hibernate.type.range.Range;
import io.hypersistence.utils.hibernate.type.range.PostgreSQLRangeType;
import io.hypersistence.utils.hibernate.type.range.Range;
import lombok.*;
import net.hostsharing.hsadminng.errors.DisplayName;
import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountEntity;

View File

@ -1,58 +0,0 @@
package net.hostsharing.hsadminng.mapper;
import lombok.experimental.UtilityClass;
import org.postgresql.util.PGtokenizer;
import java.lang.reflect.Array;
import java.nio.charset.StandardCharsets;
import java.util.function.Function;
@UtilityClass
public class PostgresArray {
/**
* Converts a byte[], as returned for a Postgres-array by native queries, to a Java array.
*
* <p>This example code worked with Hibernate 5 (Spring Boot 3.0.x):
* <pre><code>
* return (UUID[]) em.createNativeQuery("select currentSubjectsUuids() as uuids", UUID[].class).getSingleResult();
* </code></pre>
* </p>
*
* <p>With Hibernate 6 (Spring Boot 3.1.x), this utility method can be used like such:
* <pre><code>
* final byte[] result = (byte[]) em.createNativeQuery("select * from currentSubjectsUuids() as uuids", UUID[].class)
* .getSingleResult();
* return fromPostgresArray(result, UUID.class, UUID::fromString);
* </code></pre>
* </p>
*
* @param pgArray the byte[] returned by a native query containing as rendered for a Postgres array
* @param elementClass the class of a single element of the Java array to be returned
* @param itemParser converts a string element to the specified elementClass
* @return a Java array containing the data from pgArray
* @param <T> type of a single element of the Java array
*/
public static <T> T[] fromPostgresArray(final byte[] pgArray, final Class<T> elementClass, final Function<String, T> itemParser) {
final var pgArrayLiteral = new String(pgArray, StandardCharsets.UTF_8);
if (pgArrayLiteral.length() == 2) {
return newGenericArray(elementClass, 0);
}
final PGtokenizer tokenizer = new PGtokenizer(pgArrayLiteral.substring(1, pgArrayLiteral.length()-1), ',');
tokenizer.remove("\"", "\"");
final T[] array = newGenericArray(elementClass, tokenizer.getSize()); // Create a new array of the specified type and length
for ( int n = 0; n < tokenizer.getSize(); ++n ) {
final String token = tokenizer.getToken(n);
if ( !"NULL".equals(token) ) {
array[n] = itemParser.apply(token.trim().replace("\\\"", "\""));
}
}
return array;
}
@SuppressWarnings("unchecked")
private static <T> T[] newGenericArray(final Class<T> elementClass, final int length) {
return (T[]) Array.newInstance(elementClass, length);
}
}

View File

@ -1,6 +1,6 @@
package net.hostsharing.hsadminng.mapper;
import com.vladmihalcea.hibernate.type.range.Range;
import io.hypersistence.utils.hibernate.type.range.Range;
import lombok.experimental.UtilityClass;
import java.time.LocalDate;

View File

@ -1,6 +1,6 @@
package net.hostsharing.hsadminng.hs.office.membership;
import com.vladmihalcea.hibernate.type.range.Range;
import io.hypersistence.utils.hibernate.type.range.Range;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import net.hostsharing.hsadminng.HsadminNgApplication;

View File

@ -1,6 +1,6 @@
package net.hostsharing.hsadminng.hs.office.membership;
import com.vladmihalcea.hibernate.type.range.Range;
import io.hypersistence.utils.hibernate.type.range.Range;
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeMembershipPatchResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeReasonForTerminationResource;

View File

@ -1,6 +1,6 @@
package net.hostsharing.hsadminng.hs.office.membership;
import com.vladmihalcea.hibernate.type.range.Range;
import io.hypersistence.utils.hibernate.type.range.Range;
import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity;
import org.junit.jupiter.api.Test;

View File

@ -1,6 +1,6 @@
package net.hostsharing.hsadminng.hs.office.membership;
import com.vladmihalcea.hibernate.type.range.Range;
import io.hypersistence.utils.hibernate.type.range.Range;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository;
import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerRepository;

View File

@ -1,6 +1,6 @@
package net.hostsharing.hsadminng.hs.office.membership;
import com.vladmihalcea.hibernate.type.range.Range;
import io.hypersistence.utils.hibernate.type.range.Range;
import java.time.LocalDate;

View File

@ -1,6 +1,6 @@
package net.hostsharing.hsadminng.hs.office.sepamandate;
import com.vladmihalcea.hibernate.type.range.Range;
import io.hypersistence.utils.hibernate.type.range.Range;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import net.hostsharing.hsadminng.HsadminNgApplication;

View File

@ -1,6 +1,6 @@
package net.hostsharing.hsadminng.hs.office.sepamandate;
import com.vladmihalcea.hibernate.type.range.Range;
import io.hypersistence.utils.hibernate.type.range.Range;
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeSepaMandatePatchResource;
import net.hostsharing.test.PatchUnitTestBase;

View File

@ -1,6 +1,6 @@
package net.hostsharing.hsadminng.hs.office.sepamandate;
import com.vladmihalcea.hibernate.type.range.Range;
import io.hypersistence.utils.hibernate.type.range.Range;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountRepository;
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository;

View File

@ -7,7 +7,6 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import jakarta.persistence.EntityManager;
import java.util.UUID;
import java.util.function.Function;
import static org.assertj.core.api.Assertions.assertThat;
@ -30,9 +29,7 @@ class PostgresArrayIntegrationTest {
return emptyArray;
end; $$;
""").executeUpdate();
final byte[] pgArray = (byte[]) em.createNativeQuery("SELECT returnEmptyArray()", String[].class).getSingleResult();
final String[] result = PostgresArray.fromPostgresArray(pgArray, String.class, Function.identity());
final String[] result = (String[]) em.createNativeQuery("SELECT returnEmptyArray()", String[].class).getSingleResult();
assertThat(result).isEmpty();
}
@ -53,9 +50,7 @@ class PostgresArrayIntegrationTest {
return array[text1, text2, text3, null, text4];
end; $$;
""").executeUpdate();
final byte[] pgArray = (byte[]) em.createNativeQuery("SELECT returnStringArray()", String[].class).getSingleResult();
final String[] result = PostgresArray.fromPostgresArray(pgArray, String.class, Function.identity());
final String[] result = (String[]) em.createNativeQuery("SELECT returnStringArray()", String[].class).getSingleResult();
assertThat(result).containsExactly("one", "two, three", "four; five", null, "say \"Hello\" to me");
}
@ -75,9 +70,7 @@ class PostgresArrayIntegrationTest {
return ARRAY[uuid1, uuid2, null, uuid3];
end; $$;
""").executeUpdate();
final byte[] pgArray = (byte[]) em.createNativeQuery("SELECT returnUuidArray()", UUID[].class).getSingleResult();
final UUID[] result = PostgresArray.fromPostgresArray(pgArray, UUID.class, UUID::fromString);
final UUID[] result = (UUID[]) em.createNativeQuery("SELECT returnUuidArray()", UUID[].class).getSingleResult();
assertThat(result).containsExactly(
UUID.fromString("f47ac10b-58cc-4372-a567-0e02b2c3d479"),