diff --git a/webapi/src/main/resources/application.conf b/webapi/src/main/resources/application.conf index a1a47283b0..e72a1102b4 100644 --- a/webapi/src/main/resources/application.conf +++ b/webapi/src/main/resources/application.conf @@ -456,5 +456,8 @@ app { features { allow-erase-projects = false allow-erase-projects = ${?ALLOW_ERASE_PROJECTS} + + disable-last-modification-date-check = false + disable-last-modification-date-check = ${?DISABLE_LAST_MODIFICATION_DATE_CHECK} } } diff --git a/webapi/src/main/scala/org/knora/webapi/config/AppConfig.scala b/webapi/src/main/scala/org/knora/webapi/config/AppConfig.scala index 17c4a90196..6dd625afcd 100644 --- a/webapi/src/main/scala/org/knora/webapi/config/AppConfig.scala +++ b/webapi/src/main/scala/org/knora/webapi/config/AppConfig.scala @@ -173,7 +173,10 @@ final case class InstrumentationServerConfig( interval: Duration, ) -final case class Features(allowEraseProjects: Boolean) +final case class Features( + allowEraseProjects: Boolean, + disableLastModificationDateCheck: Boolean, +) object AppConfig { type AppConfigurationsTest = AppConfig & DspIngestConfig & Triplestore & Features & Sipi @@ -182,14 +185,19 @@ object AppConfig { val parseConfig: UIO[AppConfig] = { val descriptor = deriveConfig[AppConfig].mapKey(toKebabCase) val source = TypesafeConfigProvider.fromTypesafeConfig(ConfigFactory.load().getConfig("app").resolve) - read(descriptor from source).orDie + read(descriptor from source).tap(logFeaturesEnabled).orDie } - val layer: ULayer[AppConfigurations] = { - val appConfigLayer = ZLayer.fromZIO( - parseConfig.tap(c => ZIO.logInfo("Feature: ALLOW_ERASE_PROJECTS enabled").when(c.features.allowEraseProjects)), - ) - projectAppConfigurations(appConfigLayer).tap(_ => ZIO.logInfo(">>> AppConfig Initialized <<<")) + val layer: ULayer[AppConfigurations] = + projectAppConfigurations(ZLayer.fromZIO(parseConfig)) + .tap(_ => ZIO.logInfo(">>> AppConfig Initialized <<<")) + + private def logFeaturesEnabled(c: AppConfig) = { + val features = List( + "ALLOW_ERASE_PROJECTS" -> c.features.allowEraseProjects, + "DISABLE_LAST_MODIFICATION_DATE_CHECK" -> c.features.disableLastModificationDateCheck, + ).collect { case (feature, enabled) if enabled => feature } + ZIO.logInfo(s"Features enabled: ${features.mkString(", ")}").when(features.nonEmpty) } def projectAppConfigurations[R](appConfigLayer: URLayer[R, AppConfig]): URLayer[R, AppConfigurations] = diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/ResourcesResponderV2.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/ResourcesResponderV2.scala index 001e41bf1a..8b3cbaf1b4 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/ResourcesResponderV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/ResourcesResponderV2.scala @@ -192,6 +192,37 @@ final case class ResourcesResponderV2( def createResource(createResource: CreateResourceRequestV2): Task[ReadResourcesSequenceV2] = createHandler(createResource) + /** + * If resource has already been modified, make sure that its lastModificationDate is given in the request body. + * It is a conflict if the resource has been modified since the client last read it. + * It is also conflict if the resource has a lastModificationDate but it was not provided by the client. + * + * @param resource The resource to be updated. + * @param providedLastModificationDate The lastModificationDate provided by the client. + * @return Fails with an [[EditConflictException]] if there is a conflict. + */ + private def ensureNoConflictingChange( + resource: ReadResourceV2, + providedLastModificationDate: Option[Instant], + ): IO[EditConflictException, Unit] = { + val existingLastModificationDate = resource.lastModificationDate + val isConflict = ( + for { + existingDate <- existingLastModificationDate + providedDate <- providedLastModificationDate + } yield existingDate != providedDate + ).getOrElse(existingLastModificationDate.nonEmpty) + ZIO + .fail( + EditConflictException( + s"Resource ${resource.resourceIri} has been modified since you last read it. Its lastModificationDate " + + s"${existingLastModificationDate.map(_.toString).getOrElse("")} must be included in the request body.", + ), + ) + .when(isConflict && !appConfig.features.disableLastModificationDateCheck) + .unit + } + /** * Updates a resources metadata. * @@ -201,7 +232,7 @@ final case class ResourcesResponderV2( private def updateResourceMetadataV2( updateResourceMetadataRequestV2: UpdateResourceMetadataRequestV2, ): Task[UpdateResourceMetadataResponseV2] = { - def makeTaskFuture: Task[UpdateResourceMetadataResponseV2] = { + def makeTaskFuture: Task[UpdateResourceMetadataResponseV2] = for { // Get the metadata of the resource to be updated. resourcesSeq <- getResourcePreviewV2( @@ -220,25 +251,7 @@ final case class ResourcesResponderV2( ZIO.fail(BadRequestException(msg)) } - // If resource has already been modified, make sure that its lastModificationDate is given in the request body. - _ <- - ZIO.when( - resource.lastModificationDate.nonEmpty && updateResourceMetadataRequestV2.maybeLastModificationDate.isEmpty, - ) { - val msg = - s"Resource <${resource.resourceIri}> has been modified in the past. Its lastModificationDate " + - s"${resource.lastModificationDate.get} must be included in the request body." - ZIO.fail(EditConflictException(msg)) - } - - // Make sure that the resource hasn't been updated since the client got its last modification date. - _ <- ZIO.when( - updateResourceMetadataRequestV2.maybeLastModificationDate.nonEmpty && - resource.lastModificationDate != updateResourceMetadataRequestV2.maybeLastModificationDate, - ) { - val msg = s"Resource <${resource.resourceIri}> has been modified since you last read it" - ZIO.fail(EditConflictException(msg)) - } + _ <- ensureNoConflictingChange(resource, updateResourceMetadataRequestV2.maybeLastModificationDate) // Check that the user has permission to modify the resource. _ <- resourceUtilV2.checkResourcePermission( @@ -318,7 +331,6 @@ final case class ResourcesResponderV2( maybePermissions = updateResourceMetadataRequestV2.maybePermissions, lastModificationDate = newModificationDate, ) - } for { // Do the remaining pre-update checks and the update while holding an update lock on the resource. @@ -370,11 +382,7 @@ final case class ResourcesResponderV2( ZIO.fail(BadRequestException(msg)) } - // Make sure that the resource hasn't been updated since the client got its last modification date. - _ <- ZIO.when(resource.lastModificationDate != deleteResourceV2.maybeLastModificationDate) { - val msg = s"Resource <${resource.resourceIri}> has been modified since you last read it" - ZIO.fail(EditConflictException(msg)) - } + _ <- ensureNoConflictingChange(resource, deleteResourceV2.maybeLastModificationDate) // If a custom delete date was provided, make sure it's later than the resource's most recent timestamp. _ <- ZIO.when( @@ -462,11 +470,7 @@ final case class ResourcesResponderV2( ZIO.fail(BadRequestException(msg)) } - // Make sure that the resource hasn't been updated since the client got its last modification date. - _ <- ZIO.when(resource.lastModificationDate != eraseResourceV2.maybeLastModificationDate) { - val msg = s"Resource <${resource.resourceIri}> has been modified since you last read it" - ZIO.fail(EditConflictException(msg)) - } + _ <- ensureNoConflictingChange(resource, eraseResourceV2.maybeLastModificationDate) // Check that the resource is not referred to by any other resources. We ignore rdf:subject (so we // can erase the resource's own links) and rdf:object (in case there is a deleted link value that diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/ontology/OntologyTriplestoreHelpers.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/ontology/OntologyTriplestoreHelpers.scala index 9f940dc0f3..5661b16475 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/ontology/OntologyTriplestoreHelpers.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/ontology/OntologyTriplestoreHelpers.scala @@ -12,6 +12,7 @@ import scala.collection.immutable import dsp.errors.* import org.knora.webapi.* +import org.knora.webapi.config.Features import org.knora.webapi.messages.IriConversions.* import org.knora.webapi.messages.OntologyConstants import org.knora.webapi.messages.SmartIri @@ -87,6 +88,7 @@ trait OntologyTriplestoreHelpers { } final case class OntologyTriplestoreHelpersLive( + features: Features, triplestore: TriplestoreService, stringFormatter: StringFormatter, ) extends OntologyTriplestoreHelpers { @@ -211,40 +213,41 @@ final case class OntologyTriplestoreHelpersLive( } /** - * Checks that the last modification date of an ontology is the same as the one we expect it to be. + * Checks that: + * 1. The metadata of an ontology is loaded and has a `lastModificationDate`. + * 2. The `lastModificationDate` of an ontology is the same as the one we expect it to be. * - * @param internalOntologyIri the internal IRI of the ontology. - * @param expectedLastModificationDate the last modification date that the ontology is expected to have. - * @param errorFun a function that throws an exception. It will be called if the expected last modification date is not found. - * @return a failed Future if the expected last modification date is not found. + * @param ontologyIri The internal IRI of the ontology. + * @param expectedLastModificationDate The last modification date that the ontology is expected to have. + * @param error It will be returned if the expected last modification date is not found. + * @return A failed Task if the checks fail: + * The 1. check fails with an [[InconsistentRepositoryDataException]] and + * The 2. check fails with the provided error. */ private def checkOntologyLastModificationDate( - internalOntologyIri: SmartIri, + ontologyIri: SmartIri, expectedLastModificationDate: Instant, - errorFun: => Nothing, + disableLastModificationDateCheck: Boolean, + error: => Exception, ): Task[Unit] = for { - existingOntologyMetadata <- loadOntologyMetadata(internalOntologyIri) - - _ = existingOntologyMetadata match { - case Some(metadata) => - metadata.lastModificationDate match { - case Some(lastModificationDate) => - if (lastModificationDate != expectedLastModificationDate) { - errorFun - } - - case None => - throw InconsistentRepositoryDataException( - s"Ontology $internalOntologyIri has no ${OntologyConstants.KnoraBase.LastModificationDate}", - ) - } - - case None => - throw NotFoundException( - s"Ontology $internalOntologyIri (corresponding to ${internalOntologyIri.toOntologySchema(ApiV2Complex)}) not found", - ) + metadataMaybe <- loadOntologyMetadata(ontologyIri) + externalOntologyIri <- ZIO.attempt(ontologyIri.toOntologySchema(ApiV2Complex)) + metadata <- + ZIO + .fromOption(metadataMaybe) + .orElseFail(NotFoundException(s"Ontology $externalOntologyIri not found.")) + existingLastModificationDate <- + ZIO + .fromOption(metadata.lastModificationDate) + .orElseFail { + val msg = s"Ontology $externalOntologyIri has no ${OntologyConstants.KnoraBase.LastModificationDate}." + InconsistentRepositoryDataException(msg) } + _ <- + ZIO + .fail(error) + .when(!disableLastModificationDateCheck && existingLastModificationDate != expectedLastModificationDate) } yield () override def checkOntologyLastModificationDateBeforeUpdate( @@ -252,9 +255,10 @@ final case class OntologyTriplestoreHelpersLive( expectedLastModificationDate: Instant, ): Task[Unit] = checkOntologyLastModificationDate( - internalOntologyIri = internalOntologyIri, - expectedLastModificationDate = expectedLastModificationDate, - errorFun = throw EditConflictException( + internalOntologyIri, + expectedLastModificationDate, + features.disableLastModificationDateCheck, + EditConflictException( s"Ontology ${internalOntologyIri.toOntologySchema(ApiV2Complex)} has been modified by another user, please reload it and try again.", ), ) @@ -264,9 +268,10 @@ final case class OntologyTriplestoreHelpersLive( expectedLastModificationDate: Instant, ): Task[Unit] = checkOntologyLastModificationDate( - internalOntologyIri = internalOntologyIri, - expectedLastModificationDate = expectedLastModificationDate, - errorFun = throw UpdateNotPerformedException( + internalOntologyIri, + expectedLastModificationDate, + features.disableLastModificationDateCheck, + UpdateNotPerformedException( s"Ontology ${internalOntologyIri.toOntologySchema(ApiV2Complex)} was not updated. Please report this as a possible bug.", ), )