diff --git a/docker-compose.yml b/docker-compose.yml index 77dcc3339d..04ae96e6ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ version: '3.7' services: app: - image: daschswiss/dsp-app:latest + image: daschswiss/dsp-app:v10.19.2 ports: - "4200:4200" networks: @@ -41,7 +41,10 @@ services: - SIPI_EXTERNAL_PROTOCOL=http - SIPI_EXTERNAL_HOSTNAME=0.0.0.0 - SIPI_EXTERNAL_PORT=1024 - - SIPI_WEBAPI_HOSTNAME=api + # Use the following line if you start the api as a docker container from this docker-compose.yml + # - SIPI_WEBAPI_HOSTNAME=api + # Use the following line if you start the api from your IDE + - SIPI_WEBAPI_HOSTNAME=host.docker.internal - SIPI_WEBAPI_PORT=3333 - KNORA_WEBAPI_KNORA_API_EXTERNAL_HOST=0.0.0.0 - KNORA_WEBAPI_KNORA_API_EXTERNAL_PORT=3333 diff --git a/project/Dependencies.scala b/project/Dependencies.scala index ee32272805..070116bde5 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -102,12 +102,18 @@ object Dependencies { val xmlunitCore = "org.xmlunit" % "xmlunit-core" % "2.9.1" // test - val akkaHttpTestkit = "com.typesafe.akka" %% "akka-http-testkit" % AkkaHttpVersion // Scala 3 incompatible - val akkaStreamTestkit = "com.typesafe.akka" %% "akka-stream-testkit" % AkkaActorVersion // Scala 3 compatible - val akkaTestkit = "com.typesafe.akka" %% "akka-testkit" % AkkaActorVersion // Scala 3 compatible - val scalaTest = "org.scalatest" %% "scalatest" % "3.2.15" // Scala 3 compatible - val testcontainers = "org.testcontainers" % "testcontainers" % "1.18.0" - val wiremock = "com.github.tomakehurst" % "wiremock-jre8" % "2.35.0" + val akkaHttpTestkit = "com.typesafe.akka" %% "akka-http-testkit" % AkkaHttpVersion // Scala 3 incompatible + val akkaStreamTestkit = "com.typesafe.akka" %% "akka-stream-testkit" % AkkaActorVersion // Scala 3 compatible + val akkaTestkit = "com.typesafe.akka" %% "akka-testkit" % AkkaActorVersion // Scala 3 compatible + val scalaTest = "org.scalatest" %% "scalatest" % "3.2.15" // Scala 3 compatible + // The scoverage plugin actually adds its dependencies automatically. + // Add it redundantly to the IT dependencies in order to fix build issues with IntelliJ + // Fixes error message when running IT in IntelliJ + // A needed class was not found. This could be due to an error in your runpath.Missing class: scoverage / Invoker$ + // java.lang.NoClassDefFoundError: scoverage / Invoker$ + val scoverage = "org.scoverage" %% "scalac-scoverage-runtime" % "2.0.7" + val testcontainers = "org.testcontainers" % "testcontainers" % "1.18.0" + val wiremock = "com.github.tomakehurst" % "wiremock-jre8" % "2.35.0" // found/added by the plugin but deleted anyway val commonsLang3 = "org.apache.commons" % "commons-lang3" % "3.12.0" @@ -118,6 +124,7 @@ object Dependencies { akkaTestkit, rdf4jClient, scalaTest, + scoverage, testcontainers, wiremock, xmlunitCore, diff --git a/project/plugins.sbt b/project/plugins.sbt index 3953a2caac..f23aa6e88c 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -11,11 +11,12 @@ addSbtPlugin("io.kamon" % "sbt-aspectj-runner" % "1.1.2") addSbtPlugin("com.typesafe.play" % "sbt-twirl" % "1.5.2") addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.1.1") addSbtPlugin("com.lightbend.sbt" % "sbt-javaagent" % "0.1.6") -addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.7") -addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") -addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0") -addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.10.4") -addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.9.0") +// also update the scalac-scoverage-runtime version in build.sbt +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.7") +addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0") +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.10.4") +addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.9.0") // ad-hoc plugins - uncomment on demenad and keep it commented out in main branch diff --git a/sipi/scripts/authentication.lua b/sipi/scripts/authentication.lua index 0423611595..a1b363bc84 100644 --- a/sipi/scripts/authentication.lua +++ b/sipi/scripts/authentication.lua @@ -7,6 +7,8 @@ require "send_response" require "strings" require "util" +local NO_TOKEN_FOUND_ERROR = "No token found" + --- Extracts a JSON web token (JWT) from the HTTP request: header ('Authorization' and 'Cookie') or query param 'token'. -- If present decodes token and validates claims exp, aud, iss. -- Sends an HTTP error if the token is invalid. @@ -22,7 +24,7 @@ function _token() local nil_token = { raw = nil, decoded = nil } local jwt_raw = _get_jwt_string_from_header_params_or_cookie() if jwt_raw == nil then - return nil_token, "No token found" + return nil_token, NO_TOKEN_FOUND_ERROR else local decoded, error = _decode_jwt(jwt_raw) if decoded == nil then @@ -81,6 +83,8 @@ function _decode_jwt(token_str) if decoded_token["iss"] ~= token_issuer then return _send_unauthorized_error(401, "Invalid 'iss' (issuer) in token, expected: " .. token_issuer .. ".") end + + log("authentication: decoded jwt token for 'sub' " .. decoded_token["sub"], server.loglevel.LOG_DEBUG) return decoded_token end diff --git a/webapi/src/it/scala/org/knora/webapi/core/LayersTest.scala b/webapi/src/it/scala/org/knora/webapi/core/LayersTest.scala index 9869a657cf..47adb2867c 100644 --- a/webapi/src/it/scala/org/knora/webapi/core/LayersTest.scala +++ b/webapi/src/it/scala/org/knora/webapi/core/LayersTest.scala @@ -121,6 +121,9 @@ import org.knora.webapi.testcontainers.FusekiTestContainer import org.knora.webapi.testcontainers.SipiTestContainer import org.knora.webapi.testservices.TestClientService import org.knora.webapi.messages.util.search.gravsearch.transformers.ConstructTransformer +import org.knora.webapi.slice.admin.domain.service.AssetService +import org.knora.webapi.slice.admin.domain.service.AssetServiceLive + object LayersTest { /** @@ -134,6 +137,7 @@ object LayersTest { ApiRoutes with AppRouter with Authenticator + with AssetService with CacheService with CacheServiceRequestMessageHandler with CardinalityHandler @@ -200,6 +204,7 @@ object LayersTest { AuthenticationMiddleware.layer, AuthenticatorLive.layer, AuthenticatorService.layer, + AssetServiceLive.layer, CacheServiceInMemImpl.layer, CacheServiceRequestMessageHandlerLive.layer, CardinalityHandlerLive.layer, diff --git a/webapi/src/it/scala/org/knora/webapi/it/v1/KnoraSipiIntegrationV1ITSpec.scala b/webapi/src/it/scala/org/knora/webapi/it/v1/KnoraSipiIntegrationV1ITSpec.scala index b14635e2cd..fbc644af03 100644 --- a/webapi/src/it/scala/org/knora/webapi/it/v1/KnoraSipiIntegrationV1ITSpec.scala +++ b/webapi/src/it/scala/org/knora/webapi/it/v1/KnoraSipiIntegrationV1ITSpec.scala @@ -182,27 +182,25 @@ class KnoraSipiIntegrationV1ITSpec } } + private def getLoginToken() = { + val params = + s""" + |{ + | "email": "$userEmail", + | "password": "$password" + |} + """.stripMargin + val loginResponse: HttpResponse = singleAwaitingRequest( + Post(baseApiUrl + s"/v2/authentication", HttpEntity(ContentTypes.`application/json`, params)) + ) + assert(loginResponse.status == StatusCodes.OK) + Await.result(Unmarshal(loginResponse.entity).to[LoginResponse], 1.seconds).token + } + "Knora and Sipi" should { - var loginToken: String = "" + lazy val loginToken: String = getLoginToken() "log in as a Knora user" in { - /* Correct username and correct password */ - - val params = - s""" - |{ - | "email": "$userEmail", - | "password": "$password" - |} - """.stripMargin - - val request = Post(baseApiUrl + s"/v2/authentication", HttpEntity(ContentTypes.`application/json`, params)) - val response: HttpResponse = singleAwaitingRequest(request) - assert(response.status == StatusCodes.OK) - - val lr: LoginResponse = Await.result(Unmarshal(response.entity).to[LoginResponse], 1.seconds) - loginToken = lr.token - loginToken.nonEmpty should be(true) } diff --git a/webapi/src/it/scala/org/knora/webapi/it/v2/StandoffRouteV2ITSpec.scala b/webapi/src/it/scala/org/knora/webapi/it/v2/StandoffRouteV2ITSpec.scala index 696f795545..c46c019d44 100644 --- a/webapi/src/it/scala/org/knora/webapi/it/v2/StandoffRouteV2ITSpec.scala +++ b/webapi/src/it/scala/org/knora/webapi/it/v2/StandoffRouteV2ITSpec.scala @@ -44,8 +44,7 @@ import org.knora.webapi.util.MutableTestIri */ class StandoffRouteV2ITSpec extends ITKnoraLiveSpec with AuthenticationV2JsonProtocol { - private implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance - val validationFun: (String, => Nothing) => String = (s, e) => Iri.validateAndEscapeIri(s).getOrElse(e) + val validationFun: (String, => Nothing) => String = (s, e) => Iri.validateAndEscapeIri(s).getOrElse(e) private val anythingUser = SharedTestDataADM.anythingUser1 private val anythingUserEmail = anythingUser.email diff --git a/webapi/src/it/scala/org/knora/webapi/routing/AuthenticatorSpec.scala b/webapi/src/it/scala/org/knora/webapi/routing/AuthenticatorSpec.scala index 90ea2f214f..ed874e5d3c 100644 --- a/webapi/src/it/scala/org/knora/webapi/routing/AuthenticatorSpec.scala +++ b/webapi/src/it/scala/org/knora/webapi/routing/AuthenticatorSpec.scala @@ -8,11 +8,12 @@ package org.knora.webapi.routing import akka.testkit.ImplicitSender import akka.util.Timeout import org.scalatest.PrivateMethodTester + import dsp.errors.BadCredentialsException import dsp.errors.BadRequestException - import org.knora.webapi._ import org.knora.webapi.messages.StringFormatter +import org.knora.webapi.messages.admin.responder.usersmessages.UserADM import org.knora.webapi.messages.admin.responder.usersmessages.UserIdentifierADM import org.knora.webapi.messages.v2.routing.authenticationmessages.KnoraCredentialsV2.KnoraJWTTokenCredentialsV2 import org.knora.webapi.messages.v2.routing.authenticationmessages.KnoraCredentialsV2.KnoraPasswordCredentialsV2 @@ -32,6 +33,8 @@ class AuthenticatorSpec extends CoreSpec with ImplicitSender with PrivateMethodT implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance + private def testUserAdmFromIri(iri: String) = UserADM(iri, "", "", "", "", false, "") + "During Authentication" when { "called, the 'getUserADMByEmail' method " should { "succeed with the correct 'email' " in { @@ -89,7 +92,8 @@ class AuthenticatorSpec extends CoreSpec with ImplicitSender with PrivateMethodT "succeed with correct token" in { val resF = UnsafeZioRun.runToFuture( for { - token <- JwtService.createToken("http://rdfh.ch/users/X-T8IkfQTKa86UWuISpbOA") + token <- + JwtService.createJwt(testUserAdmFromIri("http://rdfh.ch/users/X-T8IkfQTKa86UWuISpbOA")).map(_.jwtString) tokenCreds = KnoraJWTTokenCredentialsV2(token) result <- Authenticator.authenticateCredentialsV2(Some(tokenCreds)) } yield result @@ -101,7 +105,8 @@ class AuthenticatorSpec extends CoreSpec with ImplicitSender with PrivateMethodT assertThrows[BadCredentialsException] { throw UnsafeZioRun .run(for { - token <- JwtService.createToken("http://rdfh.ch/users/X-T8IkfQTKa86UWuISpbOA") + token <- + JwtService.createJwt(testUserAdmFromIri("http://rdfh.ch/users/X-T8IkfQTKa86UWuISpbOA")).map(_.jwtString) tokenCreds = KnoraJWTTokenCredentialsV2(token) _ = CacheUtil.put(AUTHENTICATION_INVALIDATION_CACHE_NAME, tokenCreds.jwtToken, tokenCreds.jwtToken) result <- Authenticator.authenticateCredentialsV2(Some(tokenCreds)) diff --git a/webapi/src/it/scala/org/knora/webapi/routing/JwtServiceSpec.scala b/webapi/src/it/scala/org/knora/webapi/routing/JwtServiceSpec.scala index 1e6b8404af..971a8cfdaa 100644 --- a/webapi/src/it/scala/org/knora/webapi/routing/JwtServiceSpec.scala +++ b/webapi/src/it/scala/org/knora/webapi/routing/JwtServiceSpec.scala @@ -20,7 +20,7 @@ class JwtServiceSpec extends CoreSpec with ImplicitSender { "create a token" in { val runZio = for { - token <- JwtService.createToken(SharedTestDataADM.anythingUser1.id, Map("foo" -> JsString("bar"))) + token <- JwtService.createJwt(SharedTestDataADM.anythingUser1, Map("foo" -> JsString("bar"))).map(_.jwtString) useriri <- JwtService.extractUserIriFromToken(token) } yield useriri diff --git a/webapi/src/it/scala/org/knora/webapi/store/iiif/impl/IIIFServiceMockImpl.scala b/webapi/src/it/scala/org/knora/webapi/store/iiif/impl/IIIFServiceMockImpl.scala index d655aef82c..61fb7252d9 100644 --- a/webapi/src/it/scala/org/knora/webapi/store/iiif/impl/IIIFServiceMockImpl.scala +++ b/webapi/src/it/scala/org/knora/webapi/store/iiif/impl/IIIFServiceMockImpl.scala @@ -6,9 +6,12 @@ package org.knora.webapi.store.iiif.impl import zio._ +import zio.nio.file.Path +import org.knora.webapi.messages.admin.responder.usersmessages.UserADM import org.knora.webapi.messages.store.sipimessages._ import org.knora.webapi.messages.v2.responder.SuccessResponseV2 +import org.knora.webapi.slice.admin.domain.service.Asset import org.knora.webapi.store.iiif.api.IIIFService import org.knora.webapi.store.iiif.errors.SipiException @@ -56,6 +59,8 @@ case class IIIFServiceMockImpl() extends IIIFService { override def getTextFileRequest(textFileRequest: SipiGetTextFileRequest): Task[SipiGetTextFileResponse] = ??? override def getStatus(): Task[IIIFServiceStatusResponse] = ZIO.succeed(IIIFServiceStatusOK) + + override def downloadAsset(asset: Asset, targetDir: Path, user: UserADM): Task[Option[Path]] = ??? } object IIIFServiceMockImpl { diff --git a/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala b/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala index c8802fa5b6..1a1fb08f8c 100644 --- a/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala @@ -84,6 +84,8 @@ import org.knora.webapi.routing.admin.AuthenticatorService import org.knora.webapi.routing.admin.ProjectsRouteZ import org.knora.webapi.slice.admin.api.service.ProjectADMRestService import org.knora.webapi.slice.admin.api.service.ProjectsADMRestServiceLive +import org.knora.webapi.slice.admin.domain.service.AssetService +import org.knora.webapi.slice.admin.domain.service.AssetServiceLive import org.knora.webapi.slice.admin.domain.service.KnoraProjectRepo import org.knora.webapi.slice.admin.domain.service.ProjectADMService import org.knora.webapi.slice.admin.domain.service.ProjectADMServiceLive @@ -129,6 +131,7 @@ object LayersLive { with ApiRoutes with AppConfig with AppRouter + with AssetService with Authenticator with CacheService with CacheServiceRequestMessageHandler @@ -200,6 +203,7 @@ object LayersLive { ApiRoutes.layer, AppConfig.layer, AppRouter.layer, + AssetServiceLive.layer, AuthenticationMiddleware.layer, AuthenticatorLive.layer, AuthenticatorService.layer, diff --git a/webapi/src/main/scala/org/knora/webapi/routing/Authenticator.scala b/webapi/src/main/scala/org/knora/webapi/routing/Authenticator.scala index a2744b717c..df9b12d9b6 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/Authenticator.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/Authenticator.scala @@ -198,16 +198,15 @@ final case class AuthenticatorLive( val credentials: Option[KnoraCredentialsV2] = extractCredentialsV2(requestContext) for { - userADM <- getUserADMThroughCredentialsV2(credentials) - userProfile = userADM.asUserProfileV1 - cookieDomain = Some(appConfig.cookieDomain) - sessionToken <- jwtService.createToken(userProfile.userData.user_id.get) + userADM <- getUserADMThroughCredentialsV2(credentials) + cookieDomain = Some(appConfig.cookieDomain) + jwt <- jwtService.createJwt(userADM) httpResponse = HttpResponse( headers = List( headers.`Set-Cookie`( HttpCookie( calculateCookieName(), - sessionToken, + jwt.jwtString, domain = cookieDomain, path = Some("/"), httpOnly = true @@ -220,8 +219,8 @@ final case class AuthenticatorLive( JsObject( "status" -> JsNumber(0), "message" -> JsString("credentials are OK"), - "sid" -> JsString(sessionToken), - "userProfile" -> userProfile.ofType(UserProfileTypeV1.RESTRICTED).toJsValue + "sid" -> JsString(jwt.jwtString), + "userProfile" -> userADM.asUserProfileV1.ofType(UserProfileTypeV1.RESTRICTED).toJsValue ).compactPrint ) ) @@ -240,14 +239,14 @@ final case class AuthenticatorLive( _ <- authenticateCredentialsV2(credentials = Some(credentials)) userADM <- getUserByIdentifier(credentials.identifier) cookieDomain = Some(appConfig.cookieDomain) - token <- jwtService.createToken(userADM.id) + jwtString <- jwtService.createJwt(userADM).map(_.jwtString) httpResponse = HttpResponse( headers = List( headers.`Set-Cookie`( HttpCookie( calculateCookieName(), - token, + jwtString, domain = cookieDomain, path = Some("/"), httpOnly = true @@ -258,7 +257,7 @@ final case class AuthenticatorLive( entity = HttpEntity( ContentTypes.`application/json`, JsObject( - "token" -> JsString(token) + "token" -> JsString(jwtString) ).compactPrint ) ) @@ -753,6 +752,8 @@ object AuthenticatorLive { ZLayer.fromFunction(AuthenticatorLive.apply _) } +case class Jwt(jwtString: String, expiration: Long) + /** * Provides functions for creating, decoding, and validating JWT tokens. */ @@ -762,11 +763,11 @@ trait JwtService { /** * Creates a JWT. * - * @param userIri the user IRI that will be encoded into the token. + * @param user the user IRI that will be encoded into the token. * @param content any other content to be included in the token. * @return a [[String]] containing the JWT. */ - def createToken(userIri: IRI, content: Map[String, JsValue] = Map.empty): Task[String] + def createJwt(user: UserADM, content: Map[String, JsValue] = Map.empty): UIO[Jwt] /** * Validates a JWT, taking the invalidation cache into account. The invalidation cache holds invalidated @@ -797,13 +798,7 @@ final case class JwtServiceLive(private val config: AppConfig, stringFormatter: private val logger = Logger(LoggerFactory.getLogger(this.getClass)) - /** - * Creates a JWT. - * - * @param userIri the user IRI that will be encoded into the token. - * @return a [[String]] containing the JWT. - */ - override def createToken(userIri: IRI, content: Map[String, JsValue] = Map.empty): Task[String] = + override def createJwt(user: UserADM, content: Map[String, JsValue] = Map.empty): UIO[Jwt] = for { now <- Clock.instant uuid <- ZIO.random.flatMap(_.nextUUID) @@ -812,13 +807,13 @@ final case class JwtServiceLive(private val config: AppConfig, stringFormatter: claim = JwtClaim( content = JsObject(content).compactPrint, issuer = Some(issuer), - subject = Some(userIri), + subject = Some(user.id), audience = Some(Set("Knora", "Sipi")), issuedAt = Some(now.getEpochSecond), expiration = Some(exp), jwtId = jwtId ).toJson - } yield JwtSprayJson.encode(header, claim, secret, algorithm) + } yield Jwt(JwtSprayJson.encode(header, claim, secret, algorithm), exp) /** * Validates a JWT, taking the invalidation cache into account. The invalidation cache holds invalidated diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/ProjectsADMRestService.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/ProjectsADMRestService.scala index 288b84c6fa..3c472674b0 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/ProjectsADMRestService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/ProjectsADMRestService.scala @@ -238,7 +238,7 @@ final case class ProjectsADMRestServiceLive( projectIri <- IriIdentifier.fromString(projectIri).toZIO.orElseFail(BadRequestException(s"Invalid project IRI: $projectIri")) project <- projectRepo.findById(projectIri).someOrFail(NotFoundException(s"Project $projectIri not found.")) - zipFile <- projectExportService.exportProject(project) + zipFile <- projectExportService.exportProject(project, requestingUser) } yield ProjectExportResponse(zipFile.toString) } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/ProjectExportService.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/ProjectExportService.scala index 5ebde1198c..8e40af10fa 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/ProjectExportService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/ProjectExportService.scala @@ -24,18 +24,22 @@ import zio.nio.file.Path import java.io.OutputStream import scala.collection.mutable -import org.knora.webapi.messages.twirl +import org.knora.webapi.messages.OntologyConstants.KnoraBase.KnoraBaseOntologyIri +import org.knora.webapi.messages.admin.responder.usersmessages.UserADM +import org.knora.webapi.messages.twirl.queries.sparql.admin.txt._ import org.knora.webapi.messages.util.rdf.TriG import org.knora.webapi.slice.admin.AdminConstants.adminDataNamedGraph import org.knora.webapi.slice.admin.AdminConstants.permissionsDataNamedGraph import org.knora.webapi.slice.admin.domain.model.KnoraProject +import org.knora.webapi.slice.ontology.domain.service.OntologyRepo import org.knora.webapi.slice.resourceinfo.domain.InternalIri +import org.knora.webapi.store.iiif.api.IIIFService import org.knora.webapi.store.triplestore.api.TriplestoreService import org.knora.webapi.util.ZScopedJavaIoStreams @accessible trait ProjectExportService { - def exportProject(project: KnoraProject): Task[Path] + def exportProject(project: KnoraProject, user: UserADM): Task[Path] /** * Exports a project to a file. @@ -118,7 +122,8 @@ private object TriGCombiner { final case class ProjectExportServiceLive( private val projectService: ProjectADMService, - private val triplestoreService: TriplestoreService + private val triplestoreService: TriplestoreService, + private val assetService: AssetService ) extends ProjectExportService { override def exportProjectTriples(project: KnoraProject): Task[Path] = @@ -147,11 +152,22 @@ final case class ProjectExportServiceLive( ) } yield files - private def downloadProjectAdminData(project: KnoraProject, tempDir: Path): Task[NamedGraphTrigFile] = { + /** + * Downloads the admin related project metadata. + * The data is saved to a file in TriG format. + * The data contains: + * * the project itself + * * the users which are members of the project + * * the groups which belong to the project + * @param project The project to be exported. + * @param targetFolder The folder in which the file is to be saved. + * @return A [[NamedGraphTrigFile]] containing the named graph and location of the file. + */ + private def downloadProjectAdminData(project: KnoraProject, targetFolder: Path): Task[NamedGraphTrigFile] = { val graphIri = adminDataNamedGraph - val file = NamedGraphTrigFile(graphIri, tempDir) + val file = NamedGraphTrigFile(graphIri, targetFolder) for { - query <- ZIO.attempt(twirl.queries.sparql.admin.txt.getProjectAdminData(project.id.value)) + query <- ZIO.attempt(getProjectAdminData(project.id.value)) _ <- triplestoreService.sparqlHttpConstructFile(query.toString(), graphIri, file.dataFile, TriG) } yield file } @@ -160,7 +176,7 @@ final case class ProjectExportServiceLive( val graphIri = permissionsDataNamedGraph val file = NamedGraphTrigFile(graphIri, tempDir) for { - query <- ZIO.attempt(twirl.queries.sparql.admin.txt.getProjectPermissions(project.id.value)) + query <- ZIO.attempt(getProjectPermissions(project.id.value)) _ <- triplestoreService.sparqlHttpConstructFile(query.toString(), graphIri, file.dataFile, TriG) } yield file } @@ -168,25 +184,75 @@ final case class ProjectExportServiceLive( private def mergeDataToFile(allData: Seq[NamedGraphTrigFile], targetFile: Path): Task[Path] = TriGCombiner.combineTrigFiles(allData.map(_.dataFile), targetFile) - override def exportProject(project: KnoraProject): Task[Path] = ZIO.scoped { + override def exportProject(project: KnoraProject, user: UserADM): Task[Path] = ZIO.scoped { for { - exportDir <- Files.createTempDirectory(Some(s"export-${project.shortname}"), fileAttributes = Nil) - collectDir <- Files.createTempDirectoryScoped(Some(project.shortname), fileAttributes = Nil) - _ <- exportProjectTriples(project, trigExportFile(project, collectDir)) - _ <- exportProjectAssets(project, collectDir) - zipped <- ZipUtility.zipFolder(collectDir, exportDir) + exportDir <- Files.createTempDirectory(Some(s"export-${project.shortname}"), fileAttributes = Nil) + collectDir <- Files.createTempDirectoryScoped(Some(project.shortname), fileAttributes = Nil) + _ <- exportProjectTriples(project, trigExportFile(project, collectDir)) + _ <- exportProjectAssets(project, collectDir, user) + zipped <- ZipUtility.zipFolder(collectDir, exportDir) + fileSize <- Files.size(zipped) + absolutePath <- zipped.toAbsolutePath + _ <- ZIO.logInfo(s"Exported project ${project.shortname} to $absolutePath ($fileSize bytes)") } yield zipped } - private def exportProjectAssets(project: KnoraProject, tempDir: Path) = { + private def exportProjectAssets(project: KnoraProject, tempDir: Path, user: UserADM): ZIO[Any, Throwable, Path] = { val exportedAssetsDir = tempDir / "assets" for { _ <- Files.createDirectory(exportedAssetsDir) + _ <- assetService.exportProjectAssets(project, exportedAssetsDir, user) } yield exportedAssetsDir } } +trait AssetService { + def exportProjectAssets(project: KnoraProject, tempDir: Path, user: UserADM): Task[Path] +} +case class Asset(belongsToProject: KnoraProject, internalFilename: String) +object Asset { + def logString(asset: Asset) = s"asset:${asset.belongsToProject.shortcode}/${asset.internalFilename}" +} + +final case class AssetServiceLive( + private val triplestoreService: TriplestoreService, + private val sipiClient: IIIFService, + private val ontologyRepo: OntologyRepo +) extends AssetService { + override def exportProjectAssets(project: KnoraProject, directory: Path, user: UserADM): Task[Path] = for { + _ <- ZIO.logDebug(s"Exporting assets ${project.id}") + assets <- determineAssets(project) + .tap(it => ZIO.logInfo(s"Found ${it.size} assets for project ${project.shortcode}")) + _ <- + ZIO + .foreachPar(assets)(sipiClient.downloadAsset(_, directory, user)) + .withParallelism(10) + .tap { downloadedAssets => + val nrPresent = assets.size + val nrDownloaded = downloadedAssets.flatten.size + ZIO.logInfo(s"Successfully downloaded $nrDownloaded/$nrPresent files for project ${project.shortcode}") + } + } yield directory + + private def determineAssets(project: KnoraProject): Task[List[Asset]] = { + val projectGraph = ProjectADMService.projectDataNamedGraphV2(project) + for { + ontologyGraphs <- ontologyRepo.findOntologyGraphsByProject(project) + query = findAllAssets(ontologyGraphs :+ InternalIri(KnoraBaseOntologyIri), projectGraph) + _ <- ZIO.logDebug(s"Querying assets for project ${project.id} = $query") + result <- triplestoreService.sparqlHttpSelect(query) + bindings = result.results.bindings + assets = bindings.flatMap(row => row.rowMap.get("internalFilename")).map(Asset(project, _)).toList + } yield assets + } +} + +object AssetServiceLive { + val layer: URLayer[OntologyRepo with IIIFService with TriplestoreService, AssetServiceLive] = + ZLayer.fromFunction(AssetServiceLive.apply _) +} + object ProjectExportServiceLive { - val layer: URLayer[ProjectADMService with TriplestoreService, ProjectExportService] = + val layer: URLayer[AssetService with ProjectADMService with TriplestoreService, ProjectExportService] = ZLayer.fromFunction(ProjectExportServiceLive.apply _) } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/service/OntologyRepo.scala b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/service/OntologyRepo.scala index 16eb140d01..2470b89fd6 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/service/OntologyRepo.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/service/OntologyRepo.scala @@ -23,6 +23,8 @@ trait OntologyRepo extends Repository[ReadOntologyV2, InternalIri] { def findByProject(project: KnoraProject): Task[List[ReadOntologyV2]] = findByProject(project.id) def findByProject(projectId: InternalIri): Task[List[ReadOntologyV2]] + def findOntologyGraphsByProject(project: KnoraProject): Task[List[InternalIri]] = + findByProject(project).map(_.map(_.ontologyMetadata.ontologyIri.toInternalIri)) def findClassBy(classIri: InternalIri): Task[Option[ReadClassInfoV2]] diff --git a/webapi/src/main/scala/org/knora/webapi/store/iiif/api/IIIFService.scala b/webapi/src/main/scala/org/knora/webapi/store/iiif/api/IIIFService.scala index 0b698fb070..c087b70440 100644 --- a/webapi/src/main/scala/org/knora/webapi/store/iiif/api/IIIFService.scala +++ b/webapi/src/main/scala/org/knora/webapi/store/iiif/api/IIIFService.scala @@ -7,7 +7,9 @@ package org.knora.webapi.store.iiif.api import zio._ import zio.macros.accessible +import zio.nio.file.Path +import org.knora.webapi.messages.admin.responder.usersmessages.UserADM import org.knora.webapi.messages.store.sipimessages.DeleteTemporaryFileRequest import org.knora.webapi.messages.store.sipimessages.GetFileMetadataRequest import org.knora.webapi.messages.store.sipimessages.GetFileMetadataResponse @@ -16,6 +18,7 @@ import org.knora.webapi.messages.store.sipimessages.MoveTemporaryFileToPermanent import org.knora.webapi.messages.store.sipimessages.SipiGetTextFileRequest import org.knora.webapi.messages.store.sipimessages.SipiGetTextFileResponse import org.knora.webapi.messages.v2.responder.SuccessResponseV2 +import org.knora.webapi.slice.admin.domain.service.Asset @accessible trait IIIFService { @@ -57,4 +60,13 @@ trait IIIFService { * Tries to access the IIIF Service. */ def getStatus(): Task[IIIFServiceStatusResponse] + + /** + * Downloads an asset from Sipi. + * @param asset The asset to download. + * @param targetDir The target directory in which the asset should be stored. + * @param user The user who is downloading the asset. + * @return The path to the downloaded asset. If the asset could not be downloaded, [[None]] is returned. + */ + def downloadAsset(asset: Asset, targetDir: Path, user: UserADM): Task[Option[Path]] } diff --git a/webapi/src/main/scala/org/knora/webapi/store/iiif/impl/IIIFServiceSipiImpl.scala b/webapi/src/main/scala/org/knora/webapi/store/iiif/impl/IIIFServiceSipiImpl.scala index a23e01bd57..66dfeea27b 100644 --- a/webapi/src/main/scala/org/knora/webapi/store/iiif/impl/IIIFServiceSipiImpl.scala +++ b/webapi/src/main/scala/org/knora/webapi/store/iiif/impl/IIIFServiceSipiImpl.scala @@ -6,8 +6,10 @@ package org.knora.webapi.store.iiif.impl import org.apache.http.Consts +import org.apache.http.HttpEntity import org.apache.http.HttpHost import org.apache.http.HttpRequest +import org.apache.http.HttpResponse import org.apache.http.NameValuePair import org.apache.http.client.config.RequestConfig import org.apache.http.client.entity.UrlEncodedFormEntity @@ -15,6 +17,7 @@ import org.apache.http.client.methods.CloseableHttpResponse import org.apache.http.client.methods.HttpDelete import org.apache.http.client.methods.HttpGet import org.apache.http.client.methods.HttpPost +import org.apache.http.client.methods.HttpUriRequest import org.apache.http.client.protocol.HttpClientContext import org.apache.http.config.SocketConfig import org.apache.http.impl.client.CloseableHttpClient @@ -24,33 +27,48 @@ import org.apache.http.message.BasicNameValuePair import org.apache.http.util.EntityUtils import spray.json._ import zio._ +import zio.nio.file.Path +import java.net.URI import java.util import dsp.errors.BadRequestException import dsp.errors.NotFoundException import org.knora.webapi.config.AppConfig +import org.knora.webapi.config.Sipi +import org.knora.webapi.messages.admin.responder.usersmessages.UserADM import org.knora.webapi.messages.store.sipimessages._ import org.knora.webapi.messages.v2.responder.SuccessResponseV2 +import org.knora.webapi.routing.Jwt import org.knora.webapi.routing.JwtService +import org.knora.webapi.slice.admin.domain.service.Asset import org.knora.webapi.store.iiif.api.IIIFService import org.knora.webapi.store.iiif.domain._ import org.knora.webapi.store.iiif.errors.SipiException import org.knora.webapi.util.SipiUtil +import org.knora.webapi.util.ZScopedJavaIoStreams /** * Makes requests to Sipi. * - * @param appConfig The application's configuration - * @param jwt The JWT Service to handle JWT Tokens + * @param sipiConfig The application's configuration + * @param jwtService The JWT Service to handle JWT Tokens * @param httpClient The HTTP Client */ -case class IIIFServiceSipiImpl( - appConfig: AppConfig, - jwt: JwtService, - httpClient: CloseableHttpClient +final case class IIIFServiceSipiImpl( + private val sipiConfig: Sipi, + private val jwtService: JwtService, + private val httpClient: CloseableHttpClient ) extends IIIFService { + private object SipiRoutes { + def file(asset: Asset): UIO[URI] = makeUri(s"${assetBase(asset)}/file") + def knoraJson(asset: Asset): UIO[URI] = makeUri(s"${assetBase(asset)}/knora.json") + private def makeUri(uri: String): UIO[URI] = ZIO.attempt(URI.create(uri)).logError.orDie + private def assetBase(asset: Asset): String = + s"${sipiConfig.internalBaseUrl}/${asset.belongsToProject.shortcode}/${asset.internalFilename}" + } + /** * Asks Sipi for metadata about a file, served from the 'knora.json' route. * @@ -61,7 +79,7 @@ case class IIIFServiceSipiImpl( import SipiKnoraJsonResponseProtocol._ for { - url <- ZIO.succeed(appConfig.sipi.internalBaseUrl + getFileMetadataRequest.filePath + "/knora.json") + url <- ZIO.succeed(sipiConfig.internalBaseUrl + getFileMetadataRequest.filePath + "/knora.json") request <- ZIO.succeed(new HttpGet(url)) sipiResponseStr <- doSipiRequest(request) sipiResponse <- ZIO.attempt(sipiResponseStr.parseJson.convertTo[SipiKnoraJsonResponse]) @@ -88,8 +106,8 @@ case class IIIFServiceSipiImpl( ): Task[SuccessResponseV2] = { // create the JWT token with the necessary permission - val jwtToken: Task[String] = jwt.createToken( - moveTemporaryFileToPermanentStorageRequestV2.requestingUser.id, + val jwt = jwtService.createJwt( + moveTemporaryFileToPermanentStorageRequestV2.requestingUser, Map( "knora-data" -> JsObject( Map( @@ -103,7 +121,7 @@ case class IIIFServiceSipiImpl( // builds the url for the operation def moveFileUrl(token: String) = - ZIO.succeed(s"${appConfig.sipi.internalBaseUrl}/${appConfig.sipi.moveFileRoute}?token=$token") + ZIO.succeed(s"${sipiConfig.internalBaseUrl}/${sipiConfig.moveFileRoute}?token=$token") // build the form to send together with the request val formParams = new util.ArrayList[NameValuePair]() @@ -119,8 +137,8 @@ case class IIIFServiceSipiImpl( } for { - token <- jwtToken - url <- moveFileUrl(token) + token <- jwt + url <- moveFileUrl(token.jwtString) entity <- ZIO.succeed(requestEntity) request <- ZIO.succeed(request(url, entity)) _ <- doSipiRequest(request) @@ -135,8 +153,8 @@ case class IIIFServiceSipiImpl( */ def deleteTemporaryFile(deleteTemporaryFileRequestV2: DeleteTemporaryFileRequest): Task[SuccessResponseV2] = { - val jwtToken: Task[String] = jwt.createToken( - deleteTemporaryFileRequestV2.requestingUser.id, + val jwt = jwtService.createJwt( + deleteTemporaryFileRequestV2.requestingUser, Map( "knora-data" -> JsObject( Map( @@ -149,12 +167,12 @@ case class IIIFServiceSipiImpl( def deleteUrl(token: String): Task[String] = ZIO.succeed( - s"${appConfig.sipi.internalBaseUrl}/${appConfig.sipi.deleteTempFileRoute}/${deleteTemporaryFileRequestV2.internalFilename}?token=$token" + s"${sipiConfig.internalBaseUrl}/${sipiConfig.deleteTempFileRoute}/${deleteTemporaryFileRequestV2.internalFilename}?token=$token" ) for { - token <- jwtToken - url <- deleteUrl(token) + token <- jwt + url <- deleteUrl(token.jwtString) request <- ZIO.succeed(new HttpDelete(url)) _ <- doSipiRequest(request) } yield SuccessResponseV2("Deleted temporary file.") @@ -213,7 +231,7 @@ case class IIIFServiceSipiImpl( */ def getStatus(): Task[IIIFServiceStatusResponse] = for { - request <- ZIO.succeed(new HttpGet(appConfig.sipi.internalBaseUrl + "/server/test.html")) + request <- ZIO.succeed(new HttpGet(sipiConfig.internalBaseUrl + "/server/test.html")) response <- doSipiRequest(request).fold(_ => IIIFServiceStatusNOK, _ => IIIFServiceStatusOK) } yield response @@ -225,7 +243,7 @@ case class IIIFServiceSipiImpl( */ private def doSipiRequest(request: HttpRequest): Task[String] = { val targetHost: HttpHost = - new HttpHost(appConfig.sipi.internalHost, appConfig.sipi.internalPort, appConfig.sipi.internalProtocol) + new HttpHost(sipiConfig.internalHost, sipiConfig.internalPort, sipiConfig.internalProtocol) val httpContext: HttpClientContext = HttpClientContext.create() var maybeResponse: Option[CloseableHttpResponse] = None @@ -263,15 +281,74 @@ case class IIIFServiceSipiImpl( case None => () } - sipiRequest.catchAll(error => - error match { - case badRequestException: BadRequestException => ZIO.fail(badRequestException) - case notFoundException: NotFoundException => ZIO.fail(notFoundException) - case sipiException: SipiException => ZIO.fail(sipiException) - case e: Exception => ZIO.logError(e.getMessage) *> ZIO.fail(SipiException("Failed to connect to Sipi", e)) + sipiRequest.catchAll { + case badRequestException: BadRequestException => ZIO.fail(badRequestException) + case notFoundException: NotFoundException => ZIO.fail(notFoundException) + case sipiException: SipiException => ZIO.fail(sipiException) + case e: Exception => ZIO.logError(e.getMessage) *> ZIO.fail(SipiException("Failed to connect to Sipi", e)) + } + } + + /** + * Downloads an asset and its knora.json from Sipi. + * + * @param asset The asset to download. + * @param targetDir The target directory in which the asset should be stored. + * @param user The user who is downloading the asset. + * @return The path to the downloaded asset. If the asset could not be downloaded, [[None]] is returned. + */ + override def downloadAsset(asset: Asset, targetDir: Path, user: UserADM): Task[Option[Path]] = { + def statusCode(response: HttpResponse): Int = response.getStatusLine.getStatusCode + def executeDownloadRequest(uri: URI, jwt: Jwt, targetFilename: String) = ZIO.scoped { + makeGetRequestWithAuthorization(uri, jwt).flatMap { + sendRequest(_) + .filterOrElseWith(statusCode(_) == 200)(it => ZIO.fail(new Exception(s"${statusCode(it)} code from sipi"))) + .flatMap(response => saveToFile(response.getEntity, targetDir / targetFilename)) + .tapBoth( + e => ZIO.logWarning(s"Failed downloading $uri: ${e.getMessage}"), + _ => ZIO.logDebug(s"Downloaded $uri") + ) + .fold(_ => None, Some(_)) } - ) + } + def downloadAsset(asset: Asset, jwt: Jwt): Task[Option[Path]] = + ZIO.logDebug(s"Downloading ${Asset.logString(asset)}") *> + SipiRoutes.file(asset).flatMap(executeDownloadRequest(_, jwt, asset.internalFilename)) + def downloadKnoraJson(asset: Asset, jwt: Jwt): Task[Option[Path]] = + ZIO.logDebug(s"Downloading knora.json for ${Asset.logString(asset)}") *> + SipiRoutes.knoraJson(asset).flatMap(executeDownloadRequest(_, jwt, s"${asset.internalFilename}_knora.json")) + + for { + jwt <- jwtService.createJwt(user) + assetDownloaded <- downloadAsset(asset, jwt) + _ <- downloadKnoraJson(asset, jwt).when(assetDownloaded.isDefined) + } yield assetDownloaded + } + + private def makeGetRequestWithAuthorization(uri: URI, jwt: Jwt): UIO[HttpGet] = { + val request = new HttpGet(uri) + addAuthHeader(request, jwt) + ZIO.succeed(request) + } + + private def addAuthHeader(request: HttpUriRequest, jwt: Jwt): Unit = + request.addHeader("Authorization", s"Bearer ${jwt.jwtString}") + + private def sendRequest(request: HttpUriRequest): ZIO[Scope, Throwable, HttpResponse] = { + def acquire = ZIO + .attemptBlocking(httpClient.execute(request)) + .tapErrorTrace(it => ZIO.logError(s"Failed to execute request $request: ${it._1}\n${it._2}}")) + + def release(response: CloseableHttpResponse) = ZIO.attempt(response.close()).logError.ignore + + ZIO.acquireRelease(acquire)(release) } + + private def saveToFile(entity: HttpEntity, targetFile: Path) = + ZScopedJavaIoStreams + .fileOutputStream(targetFile) + .flatMap(out => ZIO.attemptBlocking(entity.getContent.transferTo(out))) + .as(targetFile) } object IIIFServiceSipiImpl { @@ -280,10 +357,10 @@ object IIIFServiceSipiImpl { * Acquires a configured httpClient, backed by a connection pool, * to be used in communicating with SIPI. */ - private def acquire(config: AppConfig): UIO[CloseableHttpClient] = ZIO.attemptBlocking { + private def acquire(sipiConfig: Sipi): UIO[CloseableHttpClient] = ZIO.attemptBlocking { // timeout from config - val sipiTimeoutMillis: Int = config.sipi.timeoutInSeconds.toMillis.toInt + val sipiTimeoutMillis: Int = sipiConfig.timeoutInSeconds.toMillis.toInt // Create a connection manager with custom configuration. val connManager: PoolingHttpClientConnectionManager = new PoolingHttpClientConnectionManager() @@ -330,7 +407,7 @@ object IIIFServiceSipiImpl { val layer: URLayer[AppConfig with JwtService, IIIFService] = ZLayer.scoped { for { - config <- ZIO.service[AppConfig] + config <- ZIO.serviceWith[AppConfig](_.sipi) jwtService <- ZIO.service[JwtService] httpClient <- ZIO.acquireRelease(acquire(config))(release) } yield IIIFServiceSipiImpl(config, jwtService, httpClient) diff --git a/webapi/src/main/scala/org/knora/webapi/util/ZScopedJavaIoStreams.scala b/webapi/src/main/scala/org/knora/webapi/util/ZScopedJavaIoStreams.scala index e6c32b4299..8a930da26d 100644 --- a/webapi/src/main/scala/org/knora/webapi/util/ZScopedJavaIoStreams.scala +++ b/webapi/src/main/scala/org/knora/webapi/util/ZScopedJavaIoStreams.scala @@ -46,13 +46,14 @@ object ZScopedJavaIoStreams { } } - def fileOutputStream(path: Path): ZIO[Scope, Throwable, FileOutputStream] = fileOutputStream(path.toFile) + def fileOutputStream(path: zio.nio.file.Path): ZIO[Scope, Throwable, FileOutputStream] = fileOutputStream(path.toFile) + def fileOutputStream(path: Path): ZIO[Scope, Throwable, FileOutputStream] = fileOutputStream(path.toFile) def fileOutputStream(file: File): ZIO[Scope, Throwable, FileOutputStream] = { def acquire = ZIO.attempt(new FileOutputStream(file)) ZIO.acquireRelease(acquire)(release) } - def zipOutputStream(file: File) = { + def zipOutputStream(file: File): ZIO[Scope, Throwable, ZipOutputStream] = { def acquire(fos: FileOutputStream) = ZIO.attempt(new ZipOutputStream(fos)) fileOutputStream(file).flatMap(fos => ZIO.acquireRelease(acquire(fos))(release)) } diff --git a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/admin/findAllAssets.scala.txt b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/admin/findAllAssets.scala.txt new file mode 100644 index 0000000000..aa8e661484 --- /dev/null +++ b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/admin/findAllAssets.scala.txt @@ -0,0 +1,35 @@ +@* + * Copyright © 2021 - 2023 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + *@ + +@import org.knora.webapi.slice.resourceinfo.domain.InternalIri + +@** + * Finds all assets belonging to a project. + * + * @param ontologyGraphs the ontology graphs belonging to the project. + * @param projectGraph the data graph for the project. + *@ + +@( + ontologyGraphs: List[InternalIri], + projectGraph: InternalIri, +) + +PREFIX rdf: +PREFIX : +PREFIX rdfs: + +SELECT ?internalFilename +# ontology graphs +@for(graph <- ontologyGraphs) { +FROM <@{graph.value}> +} +# project graphs +FROM <@{projectGraph.value}> +WHERE { + ?fileValueType rdfs:subClassOf* :FileValue . + ?s a ?fileValueType . + ?s ?internalFilename +} diff --git a/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/service/ProjectExportServiceStub.scala b/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/service/ProjectExportServiceStub.scala index 2120fcd608..46c3886944 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/service/ProjectExportServiceStub.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/service/ProjectExportServiceStub.scala @@ -11,6 +11,7 @@ import zio.ZLayer import zio.nio.file import zio.nio.file.Path +import org.knora.webapi.messages.admin.responder.usersmessages.UserADM import org.knora.webapi.slice.admin.domain.model.KnoraProject object ProjectExportServiceStub { @@ -43,6 +44,6 @@ object ProjectExportServiceStub { */ override def exportProjectTriples(project: KnoraProject, targetFile: Path): Task[Path] = ??? - override def exportProject(project: KnoraProject): Task[file.Path] = ??? + override def exportProject(project: KnoraProject, user: UserADM): Task[file.Path] = ??? }) }