diff --git a/.github/workflows/check-labels.yaml b/.github/workflows/check-labels.yaml new file mode 100644 index 00000000000..75a4a1445bd --- /dev/null +++ b/.github/workflows/check-labels.yaml @@ -0,0 +1,9 @@ +name: CORE4 label enforcement +permissions: + contents: read +on: + pull_request: + types: [opened, labeled, unlabeled, synchronize, reopened, edited] +jobs: + require-label: + uses: guardian/.github/.github/workflows/require-label.yaml@4cb5024736632ffcc564b7f4b772c38b8e5ce739 # v2.0.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a09aa8f7dd2..0d64a7f674b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: pull-requests: write services: elasticsearch: - image: docker.elastic.co/elasticsearch/elasticsearch:7.16.2 + image: docker.elastic.co/elasticsearch/elasticsearch:7.17.29 # Wait for elasticsearch to report healthy before continuing. # see https://github.com/actions/example-services/blob/master/.github/workflows/postgres-service.yml#L28 options: -e "discovery.type=single-node" --expose 9200 --health-cmd "curl localhost:9200/_cluster/health" --health-interval 10s --health-timeout 5s --health-retries 10 @@ -36,11 +36,13 @@ jobs: --health-timeout 5s --health-retries 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version-file: ".nvmrc" + cache: npm + cache-dependency-path: "cdk/package-lock.json" - name: Kahuna (client-side) working-directory: ./kahuna run: | @@ -72,7 +74,11 @@ jobs: npm install-clean npm test npm run build - - uses: guardian/actions-riff-raff@v4.2.3 + - name: Image embedder cdk + run: ./scripts/embedder-deploy/cdk.sh + - name: Image embedder bundling + run: ./scripts/embedder-deploy/build.sh + - uses: guardian/actions-riff-raff@v4.2.4 if: "! github.event.pull_request.head.repo.fork" with: githubToken: ${{ secrets.GITHUB_TOKEN }} @@ -107,5 +113,8 @@ jobs: image-counter-lambda: - image-counter-lambda/dist/image-counter-lambda.zip - - + + cdk.out: + - cdk/cdk.out + image-embedder-lambda: + - image-embedder.zip \ No newline at end of file diff --git a/.github/workflows/push-pr.yml b/.github/workflows/push-pr.yml index 0474a499083..0b204133620 100644 --- a/.github/workflows/push-pr.yml +++ b/.github/workflows/push-pr.yml @@ -18,7 +18,7 @@ jobs: contents: write actions: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: push-pr env: PR: ${{ inputs.prNumber }} diff --git a/.github/workflows/sbt-dependency-graph.yaml b/.github/workflows/sbt-dependency-graph.yaml index 809be3caffb..f9aad713eca 100644 --- a/.github/workflows/sbt-dependency-graph.yaml +++ b/.github/workflows/sbt-dependency-graph.yaml @@ -10,19 +10,19 @@ jobs: steps: - name: Checkout branch id: checkout - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 # v6.0.1 - name: Install Java id: java - uses: actions/setup-java@b36c23c0d998641eff861008f374ee103c25ac73 # v4.2.0 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: corretto java-version: 17 - name: Install sbt id: sbt - uses: sbt/setup-sbt@8a071aa780c993c7a204c785d04d3e8eb64ef272 # v1.1.0 + uses: sbt/setup-sbt@3e125ece5c3e5248e18da9ed8d2cce3d335ec8dd # v1.1.14 - name: Submit dependencies id: submit - uses: scalacenter/sbt-dependency-submission@64084844d2b0a9b6c3765f33acde2fbe3f5ae7d3 # v3.1.0 + uses: scalacenter/sbt-dependency-submission@f43202114d7522a4b233e052f82c2eea8d658134 # v3.2.1 - name: Log snapshot for user validation id: validate run: cat ${{ steps.submit.outputs.snapshot-json-path }} | jq diff --git a/.gitignore b/.gitignore index ddc1910d454..78a6bf9e0f5 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,8 @@ dev/config/permissions.json coverage/ .bsp + +image-embedder.zip + +# Integration test data (cached S3 downloads and outputs) +image-embedder-lambda/__tests__/integration/test-data/ \ No newline at end of file diff --git a/auth/app/auth/AuthComponents.scala b/auth/app/auth/AuthComponents.scala index 1077bb81186..0882a1086ac 100644 --- a/auth/app/auth/AuthComponents.scala +++ b/auth/app/auth/AuthComponents.scala @@ -1,6 +1,6 @@ package auth -import com.gu.mediaservice.lib.management.{InnerServiceStatusCheckController, Management} +import com.gu.mediaservice.lib.management.Management import com.gu.mediaservice.lib.play.GridComponents import play.api.ApplicationLoader.Context import play.api.{Configuration, Environment} @@ -14,10 +14,9 @@ class AuthComponents(context: Context) extends GridComponents(context, new AuthC val controller = new AuthController(auth, providers, config, controllerComponents, authorisation) val permissionsAwareManagement = new Management(controllerComponents, buildInfo) - val InnerServiceStatusCheckController = new InnerServiceStatusCheckController(auth, controllerComponents, config.services, wsClient) - override val router = new Routes(httpErrorHandler, controller, permissionsAwareManagement, InnerServiceStatusCheckController) + override val router = new Routes(httpErrorHandler, controller, permissionsAwareManagement) } object AuthHttpConfig { diff --git a/auth/app/auth/AuthConfig.scala b/auth/app/auth/AuthConfig.scala index c16150cf8ad..be228e5e44e 100644 --- a/auth/app/auth/AuthConfig.scala +++ b/auth/app/auth/AuthConfig.scala @@ -1,8 +1,10 @@ package auth import com.gu.mediaservice.lib.config.{CommonConfig, GridConfigResources} +import com.gu.mediaservice.model.Instance class AuthConfig(resources: GridConfigResources) extends CommonConfig(resources) { - val rootUri: String = services.authBaseUri - val mediaApiUri: String = services.apiBaseUri + val rootUri: Instance => String = services.authBaseUri + val rootInstanceUri: Instance => String = services.authBaseInstanceUri + val mediaApiUri: Instance => String = services.apiBaseUri } diff --git a/auth/app/auth/AuthController.scala b/auth/app/auth/AuthController.scala index 73c180991ac..0df2c0a8874 100644 --- a/auth/app/auth/AuthController.scala +++ b/auth/app/auth/AuthController.scala @@ -6,9 +6,11 @@ import com.gu.mediaservice.lib.auth.Authentication.{InnerServicePrincipal, Machi import com.gu.mediaservice.lib.auth.Permissions.{DeleteImage, ShowPaid, UploadImages} import com.gu.mediaservice.lib.auth.provider.AuthenticationProviders import com.gu.mediaservice.lib.auth.{Authentication, Authorisation, Internal} +import com.gu.mediaservice.lib.config.InstanceForRequest import com.gu.mediaservice.lib.guardian.auth.PandaAuthenticationProvider +import com.gu.mediaservice.model.Instance import play.api.libs.json.Json -import play.api.mvc.{BaseController, ControllerComponents, Result} +import play.api.mvc.{AnyContent, BaseController, ControllerComponents, Request, Result} import java.net.URI import java.util.Date @@ -19,15 +21,15 @@ class AuthController(auth: Authentication, providers: AuthenticationProviders, v override val controllerComponents: ControllerComponents, authorisation: Authorisation)(implicit ec: ExecutionContext) extends BaseController - with ArgoHelpers { + with ArgoHelpers with InstanceForRequest { - val indexResponse = { + def indexResponse()(implicit instance: Instance) = { val indexData = Map("description" -> "This is the Auth API") val indexLinks = List( - Link("root", config.mediaApiUri), - Link("login", config.services.loginUriTemplate), - Link("ui:logout", s"${config.rootUri}/logout"), - Link("session", s"${config.rootUri}/session") + Link("root", config.mediaApiUri(instance)), + Link("login", config.services.loginUriTemplate(instance)), + Link("ui:logout", s"${config.rootUri(instance)}/logout"), + Link("session", s"${config.rootInstanceUri(instance)}/session") ) respond(indexData, indexLinks) } @@ -42,7 +44,10 @@ class AuthController(auth: Authentication, providers: AuthenticationProviders, v } } - def index = auth { indexResponse } + def index = auth { request => + implicit val instance: Instance = instanceOf(request) + indexResponse() + } def session = auth { request => val showPaid = authorisation.hasPermissionTo(ShowPaid)(request.user) diff --git a/auth/conf/routes b/auth/conf/routes index 9a58260935c..4423cc15e99 100644 --- a/auth/conf/routes +++ b/auth/conf/routes @@ -16,7 +16,6 @@ GET /cookieMonster auth.AuthController.cookieMonster # Management GET /management/healthcheck com.gu.mediaservice.lib.management.Management.healthCheck GET /management/manifest com.gu.mediaservice.lib.management.Management.manifest -GET /management/whoAmI com.gu.mediaservice.lib.management.InnerServiceStatusCheckController.whoAmI(depth: Int) # Shoo robots away GET /robots.txt com.gu.mediaservice.lib.management.Management.disallowRobots diff --git a/build.sbt b/build.sbt index fb0fb38af11..f215bb1b094 100644 --- a/build.sbt +++ b/build.sbt @@ -1,11 +1,10 @@ -import play.sbt.PlayImport.PlayKeys._ +import com.typesafe.sbt.packager.docker.* +import play.sbt.PlayImport.PlayKeys.* import sbt.Package.FixedTimestamp -import scala.sys.process._ +import scala.collection.JavaConverters.* +import scala.sys.process.* import scala.util.control.NonFatal -import scala.collection.JavaConverters._ - -import com.typesafe.sbt.packager.debian.JDebPackaging // We need to keep the timestamps to allow caching headers to work as expected on assets. // The below should work, but some problem in one of the plugins (possible the play plugin? or sbt-web?) causes @@ -21,7 +20,7 @@ ThisBuild / libraryDependencySchemes += "org.scala-lang.modules" %% "scala-java8-compat" % VersionScheme.Always val commonSettings = Seq( - scalaVersion := "2.13.16", + scalaVersion := "2.13.18", description := "grid", organization := "com.gu", version := "0.1", @@ -39,6 +38,7 @@ val commonSettings = Seq( "org.scalatestplus" %% "mockito-3-4" % "3.1.4.0" % Test, "org.mockito" % "mockito-core" % "2.18.0" % Test, "org.scalamock" %% "scalamock" % "5.1.0" % Test, + "org.testcontainers" % "localstack" % "1.21.4" % Test ), dependencyOverrides += "com.fasterxml.jackson.module" %% "jackson-module-scala" % "2.19.0", @@ -63,7 +63,7 @@ Global / concurrentRestrictions := Seq( ) val awsSdkVersion = "1.12.470" -val awsSdkV2Version = "2.31.12" +val awsSdkV2Version = "2.32.33" val elastic4sVersion = "8.18.2" val okHttpVersion = "3.12.1" @@ -84,9 +84,9 @@ lazy val commonLib = project("common-lib").settings( "com.amazonaws" % "aws-java-sdk-cloudwatch" % awsSdkVersion, "com.amazonaws" % "aws-java-sdk-cloudfront" % awsSdkVersion, "com.amazonaws" % "aws-java-sdk-sqs" % awsSdkVersion, + "software.amazon.awssdk" % "sqs" % awsSdkV2Version, "com.amazonaws" % "aws-java-sdk-sns" % awsSdkVersion, "com.amazonaws" % "aws-java-sdk-sts" % awsSdkVersion, - "com.amazonaws" % "aws-java-sdk-dynamodb" % awsSdkVersion, "com.amazonaws" % "aws-java-sdk-kinesis" % awsSdkVersion, "nl.gn0s1s" %% "elastic4s-core" % elastic4sVersion, "nl.gn0s1s" %% "elastic4s-client-esjava" % elastic4sVersion, @@ -94,6 +94,7 @@ lazy val commonLib = project("common-lib").settings( "com.gu" %% "thrift-serializer" % "5.0.2", "org.scalaz" %% "scalaz-core" % "7.3.8", "org.im4java" % "im4java" % "1.4.0", + "app.photofox.vips-ffm" % "vips-ffm-core" % "1.9.6", "com.gu" % "kinesis-logback-appender" % "1.4.4", "net.logstash.logback" % "logstash-logback-encoder" % "5.0", logback, // play-logback; needed when running the scripts @@ -107,8 +108,13 @@ lazy val commonLib = project("common-lib").settings( "org.scanamo" %% "scanamo" % "2.0.0", // declare explicit dependency on desired version of aws sdk v2 dynamo "software.amazon.awssdk" % "dynamodb" % awsSdkV2Version, + "software.amazon.awssdk" % "dynamodb-enhanced" % awsSdkV2Version, + // declare explicit dependency on desired version of aws sdk v2 bedrock runtime + "software.amazon.awssdk" % "bedrockruntime" % awsSdkV2Version, + "software.amazon.awssdk" % "s3vectors" % awsSdkV2Version, + "com.adobe.xmp" % "xmpcore" % "6.1.11", ws, - "org.testcontainers" % "elasticsearch" % "1.19.2" % Test + "org.testcontainers" % "testcontainers-elasticsearch" % "2.0.2" % Test, ), dependencyOverrides += "ch.qos.logback" % "logback-classic" % "1.2.13" % Test ) @@ -125,11 +131,11 @@ lazy val auth = playProject("auth", 9011) lazy val collections = playProject("collections", 9010) -lazy val cropper = playProject("cropper", 9006) +lazy val cropper = playImageLoaderProject("cropper", 9006) -lazy val imageLoader = playProject("image-loader", 9003).settings { +lazy val imageLoader = playImageLoaderProject("image-loader", 9003).settings { libraryDependencies ++= Seq( - "org.apache.tika" % "tika-core" % "1.28.5", + "org.apache.tika" % "tika-core" % "3.2.3", "com.drewnoakes" % "metadata-extractor" % "2.19.0" ) } @@ -164,8 +170,9 @@ lazy val thrall = playProject("thrall", 9002) "software.amazon.awssdk" % "kinesis" % awsSdkV2Version, "software.amazon.awssdk" % "dynamodb" % awsSdkV2Version, "com.gu" %% "kcl-pekko-stream" % "0.1.0", - "org.testcontainers" % "elasticsearch" % "1.19.2" % Test, - "com.google.protobuf" % "protobuf-java" % "3.19.6" + "org.testcontainers" % "testcontainers-elasticsearch" % "2.0.2" % Test, + "com.google.protobuf" % "protobuf-java" % "3.19.6", + "software.amazon.awssdk" % "sqs" % awsSdkV2Version ), // amazon-kinesis-client 2.6.0 brings in a critically vulnerable version of apache avro, // but we cannot upgrade amazon-kinesis-client further without performing the v2->v3 upgrade https://docs.aws.amazon.com/streams/latest/dev/kcl-migration-from-2-3.html @@ -177,8 +184,6 @@ lazy val thrall = playProject("thrall", 9002) lazy val usage = playProject("usage", 9009).settings( libraryDependencies ++= Seq( - "com.gu" %% "content-api-client-default" % "32.0.0", - "com.gu" %% "content-api-client-aws" % "0.7.6", "io.reactivex" %% "rxscala" % "0.27.0", // amazon-kinesis-client brings in a critical vulnerability warning through apache avro, resolved in versions 1.11.4 and 1.12.0. // updating amazon-kinesis-client? check if the override below can be removed @@ -233,39 +238,53 @@ val buildInfo = Seq( ) def playProject(projectName: String, port: Int, path: Option[String] = None): Project = { - val commonProject = project(projectName, path) - .enablePlugins(PlayScala, JDebPackaging, SystemdPlugin, BuildInfoPlugin) + project(projectName, path) + .enablePlugins(PlayScala, BuildInfoPlugin, DockerPlugin) .dependsOn(restLib) .settings(commonSettings ++ buildInfo ++ Seq( + dockerBaseImage := "eclipse-temurin:25", + dockerExposedPorts := Seq(port), playDefaultPort := port, - debianPackageDependencies := Seq("java11-runtime-headless"), - Linux / maintainer := "Guardian Developers ", - Linux / packageSummary := description.value, - packageDescription := description.value, bashScriptEnvConfigLocation := Some("/etc/environment"), - Debian / makeEtcDefault := None, - Debian / packageBin := { - val originalFileName = (Debian / packageBin).value - val (base, ext) = originalFileName.baseAndExt - val newBase = base.replace(s"_${version.value}_all","") - val newFileName = file(originalFileName.getParent) / s"$newBase.$ext" - IO.move(originalFileName, newFileName) - println(s"Renamed $originalFileName to $newFileName") - newFileName - }, Universal / mappings ++= Seq( file("common-lib/src/main/resources/application.conf") -> "conf/application.conf", file("common-lib/src/main/resources/logback.xml") -> "conf/logback.xml" ), Universal / javaOptions ++= Seq( "-Dpidfile.path=/dev/null", - s"-Dconfig.file=/usr/share/$projectName/conf/application.conf", - s"-Dlogger.file=/usr/share/$projectName/conf/logback.xml", - "-J-Xlog:gc*", - s"-J-Xlog:gc:/var/log/$projectName/gc.log" - ) - )) - //Add the BBC library dependency if defined - maybeBBCLib.fold(commonProject){commonProject.dependsOn(_)} + s"-Dconfig.file=/opt/docker/conf/application.conf", + s"-Dlogger.file=/opt/docker/conf/logback.xml", + "-XX:+PrintCommandLineFlags", "-XX:MaxRAMPercentage=40" + )) + ) +} + +def playImageLoaderProject(projectName: String, port: Int, path: Option[String] = None): Project = { + project(projectName, path) + .enablePlugins(PlayScala, BuildInfoPlugin, DockerPlugin) + .dependsOn(restLib) + .settings(commonSettings ++ buildInfo ++ Seq( + dockerBaseImage := "eu.gcr.io/grid-301122/jdk-vips:25-8.18.2", + dockerExposedPorts := Seq(port), + dockerCommands ++= Seq( + Cmd("ENV", "LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so") + ), + playDefaultPort := port, + + bashScriptEnvConfigLocation := Some("/etc/environment"), + Universal / mappings ++= Seq( + file("common-lib/src/main/resources/application.conf") -> "conf/application.conf", + file("common-lib/src/main/resources/logback.xml") -> "conf/logback.xml", + file("image-loader/cmyk.icc") -> "cmyk.icc", + file("image-loader/facebook-TINYsRGB_c2.icc") -> "facebook-TINYsRGB_c2.icc", + file("image-loader/grayscale.icc") -> "grayscale.icc", + file("image-loader/srgb.icc") -> "srgb.icc" + ), + Universal / javaOptions ++= Seq( + "-Dpidfile.path=/dev/null", + s"-Dconfig.file=/opt/docker/conf/application.conf", + s"-Dlogger.file=/opt/docker/conf/logback.xml", + "-XX:+PrintCommandLineFlags", "-XX:MaxRAMPercentage=20" + ))) } diff --git a/cdk/.gitignore b/cdk/.gitignore new file mode 100644 index 00000000000..bcc57feb1ed --- /dev/null +++ b/cdk/.gitignore @@ -0,0 +1,11 @@ +*.js +!jest.config.js +!jest.setup.js +!.eslintrc.js +*.d.ts +node_modules +dist + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/cdk/README.md b/cdk/README.md new file mode 100644 index 00000000000..c1cd3a05471 --- /dev/null +++ b/cdk/README.md @@ -0,0 +1,5 @@ +# Infrastructure + +This directory defines the components to be deployed to AWS. + +See [`package.json`](./package.json) for a list of available scripts. diff --git a/cdk/bin/cdk.ts b/cdk/bin/cdk.ts new file mode 100644 index 00000000000..56d5ed58e68 --- /dev/null +++ b/cdk/bin/cdk.ts @@ -0,0 +1,7 @@ +import "source-map-support/register"; +import { GuRoot } from "@guardian/cdk/lib/constructs/root"; +import { ImageEmbedder } from "../lib/image-embedder-lambda"; + +const app = new GuRoot(); +new ImageEmbedder(app, "ImageEmbedderLambda-euwest-1-PROD", { stack: "media-service", stage: "PROD", env: { region: "eu-west-1" } }); +new ImageEmbedder(app, "ImageEmbedderLambda-euwest-1-TEST", { stack: "media-service", stage: "TEST", env: { region: "eu-west-1" } }); diff --git a/cdk/cdk.json b/cdk/cdk.json new file mode 100644 index 00000000000..da639850a5b --- /dev/null +++ b/cdk/cdk.json @@ -0,0 +1,7 @@ +{ + "app": "npx ts-node bin/cdk.ts", + "context": { + "aws-cdk:enableDiffNoFail": "true", + "@aws-cdk/core:stackRelativeExports": "true" + } +} diff --git a/cdk/eslint.config.mjs b/cdk/eslint.config.mjs new file mode 100644 index 00000000000..2003a00bde8 --- /dev/null +++ b/cdk/eslint.config.mjs @@ -0,0 +1,6 @@ +import guardian from '@guardian/eslint-config'; + +export default [ + ...guardian.configs.recommended, + ...guardian.configs.jest +]; \ No newline at end of file diff --git a/cdk/jest.setup.js b/cdk/jest.setup.js new file mode 100644 index 00000000000..78fa78c4c66 --- /dev/null +++ b/cdk/jest.setup.js @@ -0,0 +1,2 @@ +/* eslint-disable no-undef -- jest globals are missing due to guardian.recommended.jest eslint rules not covering jest.setup.js */ +jest.mock("@guardian/cdk/lib/constants/tracking-tag"); diff --git a/cdk/lib/__snapshots__/image-embedder-lambda.test.ts.snap b/cdk/lib/__snapshots__/image-embedder-lambda.test.ts.snap new file mode 100644 index 00000000000..a08aafbd7e8 --- /dev/null +++ b/cdk/lib/__snapshots__/image-embedder-lambda.test.ts.snap @@ -0,0 +1,469 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`The MediaService stack matches the snapshot 1`] = ` +{ + "Metadata": { + "gu:cdk:constructs": [ + "GuDistributionBucketParameter", + "GuLambdaFunction", + "GuS3Bucket", + ], + "gu:cdk:version": "TEST", + }, + "Parameters": { + "DistributionBucketName": { + "Default": "/account/services/artifact.bucket", + "Description": "SSM parameter containing the S3 bucket name holding distribution artifacts", + "Type": "AWS::SSM::Parameter::Value", + }, + }, + "Resources": { + "DownscaledImageBucketImageembedderE1B3D588": { + "DeletionPolicy": "Retain", + "Properties": { + "BucketName": "media-service-test-image-embedder-downscaled-images", + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": true, + "BlockPublicPolicy": true, + "IgnorePublicAcls": true, + "RestrictPublicBuckets": true, + }, + "Tags": [ + { + "Key": "App", + "Value": "image-embedder", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/grid", + }, + { + "Key": "Stack", + "Value": "media-service", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + }, + "ImageEmbedderHandler2C9410B9": { + "DependsOn": [ + "ImageEmbedderHandlerServiceRoleDefaultPolicy613DB8F0", + "ImageEmbedderHandlerServiceRoleD4456D21", + ], + "Properties": { + "Architectures": [ + "arm64", + ], + "Code": { + "S3Bucket": { + "Ref": "DistributionBucketName", + }, + "S3Key": "media-service/TEST/image-embedder-lambda/image-embedder.zip", + }, + "Environment": { + "Variables": { + "APP": "image-embedder-lambda", + "DOWNSCALED_IMAGE_BUCKET": "media-service-test-image-embedder-downscaled-images", + "STACK": "media-service", + "STAGE": "TEST", + }, + }, + "FunctionName": "image-embedder-TEST", + "Handler": "index.handler", + "LoggingConfig": { + "LogFormat": "JSON", + }, + "MemorySize": 512, + "Role": { + "Fn::GetAtt": [ + "ImageEmbedderHandlerServiceRoleD4456D21", + "Arn", + ], + }, + "Runtime": "nodejs24.x", + "Tags": [ + { + "Key": "App", + "Value": "image-embedder-lambda", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/grid", + }, + { + "Key": "Stack", + "Value": "media-service", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + "Timeout": 30, + }, + "Type": "AWS::Lambda::Function", + }, + "ImageEmbedderHandlerServiceRoleD4456D21": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + "Tags": [ + { + "Key": "App", + "Value": "image-embedder-lambda", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/grid", + }, + { + "Key": "Stack", + "Value": "media-service", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "ImageEmbedderHandlerServiceRoleDefaultPolicy613DB8F0": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":s3:::", + { + "Ref": "DistributionBucketName", + }, + ], + ], + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":s3:::", + { + "Ref": "DistributionBucketName", + }, + "/media-service/TEST/image-embedder-lambda/image-embedder.zip", + ], + ], + }, + ], + }, + { + "Action": "ssm:GetParametersByPath", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:ssm:", + { + "Ref": "AWS::Region", + }, + ":", + { + "Ref": "AWS::AccountId", + }, + ":parameter/TEST/media-service/image-embedder-lambda", + ], + ], + }, + }, + { + "Action": [ + "ssm:GetParameters", + "ssm:GetParameter", + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:ssm:", + { + "Ref": "AWS::Region", + }, + ":", + { + "Ref": "AWS::AccountId", + }, + ":parameter/TEST/media-service/image-embedder-lambda/*", + ], + ], + }, + }, + { + "Action": [ + "sqs:ReceiveMessage", + "sqs:ChangeMessageVisibility", + "sqs:GetQueueUrl", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes", + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "imageEmbedder9F00A33D", + "Arn", + ], + }, + }, + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + "s3:DeleteObject*", + "s3:PutObject", + "s3:PutObjectLegalHold", + "s3:PutObjectRetention", + "s3:PutObjectTagging", + "s3:PutObjectVersionTagging", + "s3:Abort*", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "DownscaledImageBucketImageembedderE1B3D588", + "Arn", + ], + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "DownscaledImageBucketImageembedderE1B3D588", + "Arn", + ], + }, + "/*", + ], + ], + }, + ], + }, + { + "Action": "s3vectors:PutVectors", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:s3vectors:eu-central-1:", + { + "Ref": "AWS::AccountId", + }, + ":bucket/image-embeddings-test/index/*", + ], + ], + }, + }, + { + "Action": "bedrock:InvokeModel", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:bedrock:", + { + "Ref": "AWS::Region", + }, + "::foundation-model/cohere.embed-english-v3", + ], + ], + }, + }, + { + "Action": "s3:GetObject", + "Effect": "Allow", + "Resource": [ + "arn:aws:s3:::media-service-test-imagebucket-1qt2lbcwnpgl0/*", + "arn:aws:s3:::media-service-prod-imagebucket-1luk2yux3owkh/*", + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "ImageEmbedderHandlerServiceRoleDefaultPolicy613DB8F0", + "Roles": [ + { + "Ref": "ImageEmbedderHandlerServiceRoleD4456D21", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "ImageEmbedderHandlerSqsEventSourceImageEmbedderLambdaimageEmbedder4169931F5A3D671C": { + "Properties": { + "EventSourceArn": { + "Fn::GetAtt": [ + "imageEmbedder9F00A33D", + "Arn", + ], + }, + "FunctionName": { + "Ref": "ImageEmbedderHandler2C9410B9", + }, + "FunctionResponseTypes": [ + "ReportBatchItemFailures", + ], + "Tags": [ + { + "Key": "App", + "Value": "image-embedder-lambda", + }, + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/grid", + }, + { + "Key": "Stack", + "Value": "media-service", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::Lambda::EventSourceMapping", + }, + "imageEmbedder9F00A33D": { + "DeletionPolicy": "Delete", + "Properties": { + "QueueName": "image-embedder-TEST", + "RedrivePolicy": { + "deadLetterTargetArn": { + "Fn::GetAtt": [ + "imageEmbedderDLQ4A6F48C5", + "Arn", + ], + }, + "maxReceiveCount": 3, + }, + "Tags": [ + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/grid", + }, + { + "Key": "Stack", + "Value": "media-service", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + "VisibilityTimeout": 60, + }, + "Type": "AWS::SQS::Queue", + "UpdateReplacePolicy": "Delete", + }, + "imageEmbedderDLQ4A6F48C5": { + "DeletionPolicy": "Delete", + "Properties": { + "MessageRetentionPeriod": 1209600, + "QueueName": "image-embedder-DLQ-TEST", + "Tags": [ + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/grid", + }, + { + "Key": "Stack", + "Value": "media-service", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::SQS::Queue", + "UpdateReplacePolicy": "Delete", + }, + }, +} +`; diff --git a/cdk/lib/image-embedder-lambda.test.ts b/cdk/lib/image-embedder-lambda.test.ts new file mode 100644 index 00000000000..32d75e61bff --- /dev/null +++ b/cdk/lib/image-embedder-lambda.test.ts @@ -0,0 +1,12 @@ +import { App } from "aws-cdk-lib"; +import { Template } from "aws-cdk-lib/assertions"; +import { ImageEmbedder } from "./image-embedder-lambda"; + +describe("The MediaService stack", () => { + it("matches the snapshot", () => { + const app = new App(); + const stack = new ImageEmbedder(app, "ImageEmbedderLambda", { stack: "media-service", stage: "TEST" }); + const template = Template.fromStack(stack); + expect(template.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/cdk/lib/image-embedder-lambda.ts b/cdk/lib/image-embedder-lambda.ts new file mode 100644 index 00000000000..7272c1b04ca --- /dev/null +++ b/cdk/lib/image-embedder-lambda.ts @@ -0,0 +1,93 @@ +import type { GuStackProps } from '@guardian/cdk/lib/constructs/core'; +import { GuStack } from '@guardian/cdk/lib/constructs/core'; +import { GuLambdaFunction } from '@guardian/cdk/lib/constructs/lambda'; +import { GuS3Bucket } from '@guardian/cdk/lib/constructs/s3'; +import type { App } from 'aws-cdk-lib'; +import { Duration, aws_lambda as lambda, Stack } from 'aws-cdk-lib'; +import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { Architecture } from 'aws-cdk-lib/aws-lambda'; +import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources'; +import { Queue } from 'aws-cdk-lib/aws-sqs'; + +export class ImageEmbedder extends GuStack { + constructor(scope: App, id: string, props: GuStackProps) { + super(scope, id, props); + + const LAMBDA_NODE_VERSION = lambda.Runtime.NODEJS_24_X; + + const appName = 'image-embedder' + const downscaledImageBucketName = `${this.stack}-${props.stage.toLowerCase()}-${appName}-downscaled-images`; + + const imageEmbedderLambda = new GuLambdaFunction( + this, + 'ImageEmbedderHandler', + { + fileName: `${appName}.zip`, + functionName: `${appName}-${props.stage}`, + runtime: LAMBDA_NODE_VERSION, + architecture: Architecture.ARM_64, + handler: 'index.handler', + app: `${appName}-lambda`, + environment: { + STAGE: props.stage, + DOWNSCALED_IMAGE_BUCKET: downscaledImageBucketName, + }, + }, + ); + + const imageEmbedderDLQ = new Queue(this, 'imageEmbedderDLQ', { + queueName: `${appName}-DLQ-${this.stage}`, + retentionPeriod: Duration.days(14), + }); + + const imageEmbedderQueue = new Queue(this, 'imageEmbedder', { + queueName: `${appName}-${this.stage}`, + visibilityTimeout: Duration.seconds(60), + deadLetterQueue: { + queue: imageEmbedderDLQ, + maxReceiveCount: 3, + }, + }); + imageEmbedderLambda.addEventSource( + new SqsEventSource(imageEmbedderQueue, { + reportBatchItemFailures: true, + }), + ); + const downscaledImageBucket = new GuS3Bucket(this, 'DownscaledImageBucket', { + app: appName, + bucketName: downscaledImageBucketName, + }); + downscaledImageBucket.grantReadWrite(imageEmbedderLambda); + + // Allow writing vectors to S3 vector index + imageEmbedderLambda.role?.addToPrincipalPolicy( + new PolicyStatement({ + actions: ['s3vectors:PutVectors'], + resources: [ + `arn:aws:s3vectors:eu-central-1:${Stack.of(this).account}:bucket/image-embeddings-${props.stage.toLowerCase()}/index/*`, + ], + }), + ); + + // Allow invoking the Bedrock Cohere embeddings model + imageEmbedderLambda.role?.addToPrincipalPolicy( + new PolicyStatement({ + actions: ['bedrock:InvokeModel'], + resources: [ + `arn:aws:bedrock:${Stack.of(this).region}::foundation-model/cohere.embed-english-v3`, + ], + }), + ); + + // Allow fetching the image from S3 + imageEmbedderLambda.role?.addToPrincipalPolicy( + new PolicyStatement({ + actions: ['s3:GetObject'], + resources: [ + `arn:aws:s3:::media-service-test-imagebucket-1qt2lbcwnpgl0/*`, + `arn:aws:s3:::media-service-prod-imagebucket-1luk2yux3owkh/*`, + ], + }), + ); + } +} diff --git a/cdk/package-lock.json b/cdk/package-lock.json new file mode 100644 index 00000000000..a874aceba27 --- /dev/null +++ b/cdk/package-lock.json @@ -0,0 +1,13091 @@ +{ + "name": "cdk", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cdk", + "version": "0.0.0", + "devDependencies": { + "@guardian/cdk": "^62.1.2", + "@guardian/eslint-config": "^13.0.0", + "@guardian/prettier": "^9.0.0", + "@guardian/tsconfig": "^1.0.0", + "@types/jest": "^30.0.0", + "@types/node": "^24.10.1", + "aws-cdk": "^2.1033.0", + "aws-cdk-lib": "^2.231.0", + "constructs": "^10.4.3", + "eslint": "^9.34.0", + "jest": "^30.2.0", + "prettier": "^3.6.2", + "source-map-support": "^0.5.20", + "ts-jest": "^29.4.2", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@aws-cdk/asset-awscli-v1": { + "version": "2.2.242", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.242.tgz", + "integrity": "sha512-4c1bAy2ISzcdKXYS1k4HYZsNrgiwbiDzj36ybwFVxEWZXVAP0dimQTCaB9fxu7sWzEjw3d+eaw6Fon+QTfTIpQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.0.tgz", + "integrity": "sha512-7bY3J8GCVxLupn/kNmpPc5VJz8grx+4RKfnnJiO1LG+uxkZfANZG3RMHhE+qQxxwkyQ9/MfPtTpf748UhR425A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/cloud-assembly-schema": { + "version": "48.20.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-48.20.0.tgz", + "integrity": "sha512-+eeiav9LY4wbF/EFuCt/vfvi/Zoxo8bf94PW5clbMraChEliq83w4TbRVy0jB9jE0v1ooFTtIjSQkowSPkfISg==", + "bundleDependencies": [ + "jsonschema", + "semver" + ], + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "~1.4.1", + "semver": "^7.7.2" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { + "version": "1.4.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { + "version": "7.7.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.943.0.tgz", + "integrity": "sha512-XkuokRF2IQ+VLBn0AwrwfFOkZ2c1IXACwQdn3CDnpBZpT1s2hgH3MX0DoH9+41w4ar2QCSI09uAJiv9PX4DLoQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.943.0", + "@aws-sdk/credential-provider-node": "3.943.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.936.0", + "@aws-sdk/middleware-user-agent": "3.943.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.943.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.5", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.12", + "@smithy/middleware-retry": "^4.4.12", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.8", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.11", + "@smithy/util-defaults-mode-node": "^4.2.14", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-ec2": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ec2/-/client-ec2-3.943.0.tgz", + "integrity": "sha512-YdbGMwqUkNqXyq5plwgrz1Lw7dI7mmSwDooTpYBfEOA/zXMXi5OMgWral8fCqvfafnpxaAGnRpH1gppmldgQQg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.943.0", + "@aws-sdk/credential-provider-node": "3.943.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.936.0", + "@aws-sdk/middleware-sdk-ec2": "3.936.0", + "@aws-sdk/middleware-user-agent": "3.943.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.943.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.5", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.12", + "@smithy/middleware-retry": "^4.4.12", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.8", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.11", + "@smithy/util-defaults-mode-node": "^4.2.14", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-ssm": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.943.0.tgz", + "integrity": "sha512-1sIlqHD/MGqlXMrpYqqF6+hymWRWO8telVElIEujZXA1MMFrrB6X4HX2fx3pT1EdZ/4ume34EG4uSplNves1hA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.943.0", + "@aws-sdk/credential-provider-node": "3.943.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.936.0", + "@aws-sdk/middleware-user-agent": "3.943.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.943.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.5", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.12", + "@smithy/middleware-retry": "^4.4.12", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.8", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.11", + "@smithy/util-defaults-mode-node": "^4.2.14", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.943.0.tgz", + "integrity": "sha512-kOTO2B8Ks2qX73CyKY8PAajtf5n39aMe2spoiOF5EkgSzGV7hZ/HONRDyADlyxwfsX39Q2F2SpPUaXzon32IGw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.943.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.936.0", + "@aws-sdk/middleware-user-agent": "3.943.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.943.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.5", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.12", + "@smithy/middleware-retry": "^4.4.12", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.8", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.11", + "@smithy/util-defaults-mode-node": "^4.2.14", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.943.0.tgz", + "integrity": "sha512-8CBy2hI9ABF7RBVQuY1bgf/ue+WPmM/hl0adrXFlhnhkaQP0tFY5zhiy1Y+n7V+5f3/ORoHBmCCQmcHDDYJqJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@aws-sdk/xml-builder": "3.930.0", + "@smithy/core": "^3.18.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/signature-v4": "^5.3.5", + "@smithy/smithy-client": "^4.9.8", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-cognito-identity": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.943.0.tgz", + "integrity": "sha512-jZJ0uHjNlhfjx2ZX7YVYnh1wfSkLAvQmecGCSl9C6LJRNXy4uWFPbGjPqcA0tWp0WWIsUYhqjasgvCOMZIY8nw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-cognito-identity": "3.943.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.943.0.tgz", + "integrity": "sha512-WnS5w9fK9CTuoZRVSIHLOMcI63oODg9qd1vXMYb7QGLGlfwUm4aG3hdu7i9XvYrpkQfE3dzwWLtXF4ZBuL1Tew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.943.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.943.0.tgz", + "integrity": "sha512-SA8bUcYDEACdhnhLpZNnWusBpdmj4Vl67Vxp3Zke7SvoWSYbuxa+tiDiC+c92Z4Yq6xNOuLPW912ZPb9/NsSkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.943.0", + "@aws-sdk/types": "3.936.0", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.8", + "@smithy/types": "^4.9.0", + "@smithy/util-stream": "^4.5.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.943.0.tgz", + "integrity": "sha512-BcLDb8l4oVW+NkuqXMlO7TnM6lBOWW318ylf4FRED/ply5eaGxkQYqdGvHSqGSN5Rb3vr5Ek0xpzSjeYD7C8Kw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.943.0", + "@aws-sdk/credential-provider-env": "3.943.0", + "@aws-sdk/credential-provider-http": "3.943.0", + "@aws-sdk/credential-provider-login": "3.943.0", + "@aws-sdk/credential-provider-process": "3.943.0", + "@aws-sdk/credential-provider-sso": "3.943.0", + "@aws-sdk/credential-provider-web-identity": "3.943.0", + "@aws-sdk/nested-clients": "3.943.0", + "@aws-sdk/types": "3.936.0", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.943.0.tgz", + "integrity": "sha512-9iCOVkiRW+evxiJE94RqosCwRrzptAVPhRhGWv4osfYDhjNAvUMyrnZl3T1bjqCoKNcETRKEZIU3dqYHnUkcwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.943.0", + "@aws-sdk/nested-clients": "3.943.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.943.0.tgz", + "integrity": "sha512-14eddaH/gjCWoLSAELVrFOQNyswUYwWphIt+PdsJ/FqVfP4ay2HsiZVEIYbQtmrKHaoLJhiZKwBQRjcqJDZG0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.943.0", + "@aws-sdk/credential-provider-http": "3.943.0", + "@aws-sdk/credential-provider-ini": "3.943.0", + "@aws-sdk/credential-provider-process": "3.943.0", + "@aws-sdk/credential-provider-sso": "3.943.0", + "@aws-sdk/credential-provider-web-identity": "3.943.0", + "@aws-sdk/types": "3.936.0", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.943.0.tgz", + "integrity": "sha512-GIY/vUkthL33AdjOJ8r9vOosKf/3X+X7LIiACzGxvZZrtoOiRq0LADppdiKIB48vTL63VvW+eRIOFAxE6UDekw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.943.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.943.0.tgz", + "integrity": "sha512-1c5G11syUrru3D9OO6Uk+ul5e2lX1adb+7zQNyluNaLPXP6Dina6Sy6DFGRLu7tM8+M7luYmbS3w63rpYpaL+A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.943.0", + "@aws-sdk/core": "3.943.0", + "@aws-sdk/token-providers": "3.943.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.943.0.tgz", + "integrity": "sha512-VtyGKHxICSb4kKGuaqotxso8JVM8RjCS3UYdIMOxUt9TaFE/CZIfZKtjTr+IJ7M0P7t36wuSUb/jRLyNmGzUUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.943.0", + "@aws-sdk/nested-clients": "3.943.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-providers": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.943.0.tgz", + "integrity": "sha512-uZurSNsS01ehhrSwEPwcKdqp9lmd/x9q++BYO351bXyjSj1LzA/2lfUIxI2tCz/wAjJWOdnnlUdJj6P9I1uNvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-cognito-identity": "3.943.0", + "@aws-sdk/core": "3.943.0", + "@aws-sdk/credential-provider-cognito-identity": "3.943.0", + "@aws-sdk/credential-provider-env": "3.943.0", + "@aws-sdk/credential-provider-http": "3.943.0", + "@aws-sdk/credential-provider-ini": "3.943.0", + "@aws-sdk/credential-provider-login": "3.943.0", + "@aws-sdk/credential-provider-node": "3.943.0", + "@aws-sdk/credential-provider-process": "3.943.0", + "@aws-sdk/credential-provider-sso": "3.943.0", + "@aws-sdk/credential-provider-web-identity": "3.943.0", + "@aws-sdk/nested-clients": "3.943.0", + "@aws-sdk/types": "3.936.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.5", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.936.0.tgz", + "integrity": "sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.936.0.tgz", + "integrity": "sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.936.0.tgz", + "integrity": "sha512-l4aGbHpXM45YNgXggIux1HgsCVAvvBoqHPkqLnqMl9QVapfuSTjJHfDYDsx1Xxct6/m7qSMUzanBALhiaGO2fA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@aws/lambda-invoke-store": "^0.2.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-ec2": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-ec2/-/middleware-sdk-ec2-3.936.0.tgz", + "integrity": "sha512-8TaXLPUhAsTsJMPRGSVBL+wwqoM7mNLn8Ad2V26H00Ude5pyw1CYe7BxAjO3VY92Z3SCh4pOcxnlbDmB2IT4oQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-format-url": "3.936.0", + "@smithy/middleware-endpoint": "^4.3.12", + "@smithy/protocol-http": "^5.3.5", + "@smithy/signature-v4": "^5.3.5", + "@smithy/smithy-client": "^4.9.8", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.943.0.tgz", + "integrity": "sha512-956n4kVEwFNXndXfhSAN5wO+KRgqiWEEY+ECwLvxmmO8uQ0NWOa8l6l65nTtyuiWzMX81c9BvlyNR5EgUeeUvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.943.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@smithy/core": "^3.18.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.943.0.tgz", + "integrity": "sha512-anFtB0p2FPuyUnbOULwGmKYqYKSq1M73c9uZ08jR/NCq6Trjq9cuF5TFTeHwjJyPRb4wMf2Qk859oiVfFqnQiw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.943.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.936.0", + "@aws-sdk/middleware-user-agent": "3.943.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.943.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.5", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.12", + "@smithy/middleware-retry": "^4.4.12", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.8", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.11", + "@smithy/util-defaults-mode-node": "^4.2.14", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.936.0.tgz", + "integrity": "sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.943.0.tgz", + "integrity": "sha512-cRKyIzwfkS+XztXIFPoWORuaxlIswP+a83BJzelX4S1gUZ7FcXB4+lj9Jxjn8SbQhR4TPU3Owbpu+S7pd6IRbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.943.0", + "@aws-sdk/nested-clients": "3.943.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.936.0.tgz", + "integrity": "sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.936.0.tgz", + "integrity": "sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-endpoints": "^3.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.936.0.tgz", + "integrity": "sha512-MS5eSEtDUFIAMHrJaMERiHAvDPdfxc/T869ZjDNFAIiZhyc037REw0aoTNeimNXDNy2txRNZJaAUn/kE4RwN+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/querystring-builder": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.893.0.tgz", + "integrity": "sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.936.0.tgz", + "integrity": "sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.943.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.943.0.tgz", + "integrity": "sha512-gn+ILprVRrgAgTIBk2TDsJLRClzIOdStQFeFTcN0qpL8Z4GBCqMFhw7O7X+MM55Stt5s4jAauQ/VvoqmCADnQg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.943.0", + "@aws-sdk/types": "3.936.0", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.930.0.tgz", + "integrity": "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.1.tgz", + "integrity": "sha512-sIyFcoPZkTtNu9xFeEoynMef3bPJIAbOfUh+ueYcfhVl6xm2VRtMcMclSxmZCMnHHd4hlYKJeq/aggmBEWynww==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@emnapi/core": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", + "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-plugin-eslint-comments": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.5.0.tgz", + "integrity": "sha512-MAhuTKlr4y/CE3WYX26raZjy+I/kS2PLKSzvfmDCGrBLTFHOYwqROZdr4XwPgXwX3K9rjzMr4pSmUWGnzsUyMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "ignore": "^5.2.4" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@guardian/cdk": { + "version": "62.1.2", + "resolved": "https://registry.npmjs.org/@guardian/cdk/-/cdk-62.1.2.tgz", + "integrity": "sha512-JFd9w4luhbmzvlMjkJLdXYr12DSn0PUhTu3k2mgMyxZUQXAnkvcuz9rJr/rZm2I3f8opz2sZsIdFFFZkFeFTWA==", + "dev": true, + "dependencies": { + "@aws-sdk/client-ec2": "^3.908.0", + "@aws-sdk/client-ssm": "^3.925.0", + "@aws-sdk/credential-providers": "^3.925.0", + "@oclif/core": "3.26.6", + "chalk": "^4.1.2", + "codemaker": "^1.114.1", + "git-url-parse": "^16.0.1", + "js-yaml": "^4.1.0", + "read-pkg-up": "7.0.1", + "yargs": "^17.7.2" + }, + "bin": { + "gu-cdk": "bin/gu-cdk" + }, + "peerDependencies": { + "aws-cdk": "^2.1030.0", + "aws-cdk-lib": "^2.219.0", + "constructs": "^10.4.2" + } + }, + "node_modules/@guardian/eslint-config": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@guardian/eslint-config/-/eslint-config-13.0.0.tgz", + "integrity": "sha512-/U+EwCpyEX6x/foOSE4ft+wCErbEMmm/H/fF8Xw1R4Y1X9QTgruzPMZ+H/tqJXnuXFH8ElTU1DzPFQ4Ks8CoXw==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-plugin-eslint-comments": "4.5.0", + "@eslint/js": "9.39.1", + "@stylistic/eslint-plugin": "5.5.0", + "eslint-config-prettier": "10.1.8", + "eslint-import-resolver-typescript": "4.4.4", + "eslint-plugin-import-x": "4.16.1", + "eslint-plugin-jsx-a11y": "6.10.2", + "eslint-plugin-react": "7.37.5", + "eslint-plugin-react-hooks": "7.0.1", + "eslint-plugin-storybook": "10.0.6", + "globals": "16.5.0", + "read-package-up": "12.0.0", + "typescript-eslint": "8.46.3" + }, + "peerDependencies": { + "eslint": "^9.39.1" + } + }, + "node_modules/@guardian/prettier": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@guardian/prettier/-/prettier-9.0.0.tgz", + "integrity": "sha512-iAiNWPW0RAVKmUbMEzvANsYU5sskyi045PG+nH4/I7yGWMEdSmrnytbuaFNnSg/Ug77EZxdWXShvgPNd7qtTAQ==", + "dev": true, + "peerDependencies": { + "prettier": "^3.6.2", + "tslib": "^2.8.1" + } + }, + "node_modules/@guardian/tsconfig": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@guardian/tsconfig/-/tsconfig-1.0.1.tgz", + "integrity": "sha512-PB24nZ6WTBB8aZ9EyxJw1vC5FYkGqwMQ/O5Oogp6P5HCQU5MN0JpUXvpYci7kwV2oXD1Az06UBnLyyVXOVMadQ==", + "dev": true + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", + "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", + "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.2.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.2.0", + "jest-config": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-resolve-dependencies": "30.2.0", + "jest-runner": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "jest-watcher": "30.2.0", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.2.0", + "jest-snapshot": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", + "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/types": "30.2.0", + "jest-mock": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", + "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", + "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", + "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.2.0", + "@jest/types": "30.2.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", + "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", + "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "micromatch": "^4.0.8", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oclif/core": { + "version": "3.26.6", + "resolved": "https://registry.npmjs.org/@oclif/core/-/core-3.26.6.tgz", + "integrity": "sha512-+FiTw1IPuJTF9tSAlTsY8bGK4sgthehjz7c2SvYdgQncTkxI2xvUch/8QpjNYGLEmUneNygvYMRBax2KJcLccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cli-progress": "^3.11.5", + "ansi-escapes": "^4.3.2", + "ansi-styles": "^4.3.0", + "cardinal": "^2.1.1", + "chalk": "^4.1.2", + "clean-stack": "^3.0.1", + "cli-progress": "^3.12.0", + "color": "^4.2.3", + "debug": "^4.3.4", + "ejs": "^3.1.10", + "get-package-type": "^0.1.0", + "globby": "^11.1.0", + "hyperlinker": "^1.0.0", + "indent-string": "^4.0.0", + "is-wsl": "^2.2.0", + "js-yaml": "^3.14.1", + "minimatch": "^9.0.4", + "natural-orderby": "^2.0.3", + "object-treeify": "^1.1.33", + "password-prompt": "^1.1.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "supports-color": "^8.1.1", + "supports-hyperlinks": "^2.2.0", + "widest-line": "^3.1.0", + "wordwrap": "^1.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oclif/core/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@oclif/core/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.41", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", + "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.5.tgz", + "integrity": "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.3.tgz", + "integrity": "sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.18.6", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.18.6.tgz", + "integrity": "sha512-8Q/ugWqfDUEU1Exw71+DoOzlONJ2Cn9QA8VeeDzLLjzO/qruh9UKFzbszy4jXcIYgGofxYiT0t1TT6+CT/GupQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.6", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-stream": "^4.5.6", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.5.tgz", + "integrity": "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.6.tgz", + "integrity": "sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.5", + "@smithy/querystring-builder": "^4.2.5", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.5.tgz", + "integrity": "sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.5.tgz", + "integrity": "sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.5.tgz", + "integrity": "sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.3.13", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.13.tgz", + "integrity": "sha512-X4za1qCdyx1hEVVXuAWlZuK6wzLDv1uw1OY9VtaYy1lULl661+frY7FeuHdYdl7qAARUxH2yvNExU2/SmRFfcg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.18.6", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-middleware": "^4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.13.tgz", + "integrity": "sha512-RzIDF9OrSviXX7MQeKOm8r/372KTyY8Jmp6HNKOOYlrguHADuM3ED/f4aCyNhZZFLG55lv5beBin7nL0Nzy1Dw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/service-error-classification": "^4.2.5", + "@smithy/smithy-client": "^4.9.9", + "@smithy/types": "^4.9.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.6.tgz", + "integrity": "sha512-VkLoE/z7e2g8pirwisLz8XJWedUSY8my/qrp81VmAdyrhi94T+riBfwP+AOEEFR9rFTSonC/5D2eWNmFabHyGQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.5.tgz", + "integrity": "sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.5.tgz", + "integrity": "sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.5.tgz", + "integrity": "sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/querystring-builder": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.5.tgz", + "integrity": "sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.5.tgz", + "integrity": "sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.5.tgz", + "integrity": "sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.5.tgz", + "integrity": "sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.5.tgz", + "integrity": "sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.0.tgz", + "integrity": "sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.5.tgz", + "integrity": "sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.9.9", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.9.tgz", + "integrity": "sha512-SUnZJMMo5yCmgjopJbiNeo1vlr8KvdnEfIHV9rlD77QuOGdRotIVBcOrBuMr+sI9zrnhtDtLP054bZVbpZpiQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.18.6", + "@smithy/middleware-endpoint": "^4.3.13", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-stream": "^4.5.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.9.0.tgz", + "integrity": "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.5.tgz", + "integrity": "sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.12.tgz", + "integrity": "sha512-TKc6FnOxFULKxLgTNHYjcFqdOYzXVPFFVm5JhI30F3RdhT7nYOtOsjgaOwfDRmA/3U66O9KaBQ3UHoXwayRhAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.5", + "@smithy/smithy-client": "^4.9.9", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.15", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.15.tgz", + "integrity": "sha512-94NqfQVo+vGc5gsQ9SROZqOvBkGNMQu6pjXbnn8aQvBUhc31kx49gxlkBEqgmaZQHUUfdRUin5gK/HlHKmbAwg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.3", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/smithy-client": "^4.9.9", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.5.tgz", + "integrity": "sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.5.tgz", + "integrity": "sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.5.tgz", + "integrity": "sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.6.tgz", + "integrity": "sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.5.tgz", + "integrity": "sha512-Dbun99A3InifQdIrsXZ+QLcC0PGBPAdrl4cj1mTgJvyc9N2zf7QSxg8TBkzsCmGJdE3TLbO9ycwpY0EkWahQ/g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@storybook/global": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz", + "integrity": "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@storybook/icons": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.0.1.tgz", + "integrity": "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.5.0.tgz", + "integrity": "sha512-IeZF+8H0ns6prg4VrkhgL+yrvDXWDH2cKchrbh80ejG9dQgZWp10epHMbgRuQvgchLII/lfh6Xn3lu6+6L86Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.0", + "@typescript-eslint/types": "^8.46.1", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/cli-progress": { + "version": "3.11.6", + "resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.6.tgz", + "integrity": "sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/parse-path": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz", + "integrity": "sha512-LriObC2+KYZD3FzCrgWGv/qufdUy4eXrxcLgQMfYXgPbLIecKIsVBaQgUPmxSSLcjmYbDTQbMgr6qr6l/eb7Bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz", + "integrity": "sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.46.3", + "@typescript-eslint/type-utils": "8.46.3", + "@typescript-eslint/utils": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.46.3", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/project-service": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.3.tgz", + "integrity": "sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.46.3", + "@typescript-eslint/types": "^8.46.3", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.3.tgz", + "integrity": "sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.3.tgz", + "integrity": "sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.3.tgz", + "integrity": "sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.3.tgz", + "integrity": "sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.46.3", + "@typescript-eslint/tsconfig-utils": "8.46.3", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.3.tgz", + "integrity": "sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.46.3", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/typescript-estree": "8.46.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.3.tgz", + "integrity": "sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.3", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.3.tgz", + "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.46.3", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/typescript-estree": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.3.tgz", + "integrity": "sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.46.3", + "@typescript-eslint/types": "^8.46.3", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.3.tgz", + "integrity": "sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.3.tgz", + "integrity": "sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.3.tgz", + "integrity": "sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.3.tgz", + "integrity": "sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.46.3", + "@typescript-eslint/tsconfig-utils": "8.46.3", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.3.tgz", + "integrity": "sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.3", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", + "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.1", + "@typescript-eslint/types": "^8.48.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", + "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", + "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.3.tgz", + "integrity": "sha512-ZPCADbr+qfz3aiTTYNNkCbUt+cjNwI/5McyANNrFBpVxPt7GqpEYz5ZfdwuFyGUnJ9FdDXbGODUu6iRCI6XRXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/typescript-estree": "8.46.3", + "@typescript-eslint/utils": "8.46.3", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.3.tgz", + "integrity": "sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.46.3", + "@typescript-eslint/types": "^8.46.3", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.3.tgz", + "integrity": "sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.3.tgz", + "integrity": "sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.3.tgz", + "integrity": "sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.3.tgz", + "integrity": "sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.46.3", + "@typescript-eslint/tsconfig-utils": "8.46.3", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.3.tgz", + "integrity": "sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.46.3", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/typescript-estree": "8.46.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.3.tgz", + "integrity": "sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.3", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", + "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", + "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.48.1", + "@typescript-eslint/tsconfig-utils": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/visitor-keys": "8.48.1", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", + "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.1", + "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/typescript-estree": "8.48.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", + "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansicolors": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz", + "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/aws-cdk": { + "version": "2.1033.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1033.0.tgz", + "integrity": "sha512-Pit2k7cVAwxoYI7RMVsOyltuy7/HGENLupJ4KAm/d8mGzOfX+SLOo9YQsx5CKY9J6ErCZ1ViLerklTfjytvQww==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "cdk": "bin/cdk" + }, + "engines": { + "node": ">= 18.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/aws-cdk-lib": { + "version": "2.231.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.231.0.tgz", + "integrity": "sha512-RMt88F1vhsM28j81EjvIXRoPeYQdtk72EGh9xAP6LjuyF8df1hDBIy5cawUvagdp5eCBPVHrPJ2U0eaUUKtjFg==", + "bundleDependencies": [ + "@balena/dockerignore", + "case", + "fs-extra", + "ignore", + "jsonschema", + "minimatch", + "punycode", + "semver", + "table", + "yaml", + "mime-types" + ], + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-cdk/asset-awscli-v1": "2.2.242", + "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.0", + "@aws-cdk/cloud-assembly-schema": "^48.6.0", + "@balena/dockerignore": "^1.0.2", + "case": "1.6.3", + "fs-extra": "^11.3.1", + "ignore": "^5.3.2", + "jsonschema": "^1.5.0", + "mime-types": "^2.1.35", + "minimatch": "^3.1.2", + "punycode": "^2.3.1", + "semver": "^7.7.2", + "table": "^6.9.0", + "yaml": "1.10.2" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "constructs": "^10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/aws-cdk-lib/node_modules/ajv": { + "version": "8.17.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aws-cdk-lib/node_modules/astral-regex": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/aws-cdk-lib/node_modules/case": { + "version": "1.6.3", + "dev": true, + "inBundle": true, + "license": "(MIT OR GPL-3.0-or-later)", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fast-uri": { + "version": "3.1.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "inBundle": true, + "license": "BSD-3-Clause" + }, + "node_modules/aws-cdk-lib/node_modules/fs-extra": { + "version": "11.3.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/aws-cdk-lib/node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/aws-cdk-lib/node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/aws-cdk-lib/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/jsonfile": { + "version": "6.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/jsonschema": { + "version": "1.5.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/lodash.truncate": { + "version": "4.4.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/mime-db": { + "version": "1.52.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/mime-types": { + "version": "2.1.35", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/aws-cdk-lib/node_modules/require-from-string": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/semver": { + "version": "7.7.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aws-cdk-lib/node_modules/slice-ansi": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/aws-cdk-lib/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/table": { + "version": "6.9.0", + "dev": true, + "inBundle": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/yaml": { + "version": "1.10.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/axe-core": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", + "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/babel-jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", + "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.2.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", + "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", + "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", + "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bowser": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", + "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cardinal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-2.1.1.tgz", + "integrity": "sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansicolors": "~0.3.2", + "redeyed": "~2.1.0" + }, + "bin": { + "cdl": "bin/cdl.js" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 16" + } + }, + "node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.1.tgz", + "integrity": "sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/clean-stack": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz", + "integrity": "sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/codemaker": { + "version": "1.120.0", + "resolved": "https://registry.npmjs.org/codemaker/-/codemaker-1.120.0.tgz", + "integrity": "sha512-WRa7z7dK8bNQYnBGNL31cPtB7Ar/s18IaiJ8HKqGBgNi+HhOJoarAJyV2Uqeras/3mt82/VCGvcrLPzlvk/POw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "camelcase": "^6.3.0", + "decamelize": "^5.0.1", + "fs-extra": "^10.1.0" + }, + "engines": { + "node": ">= 14.17.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/comment-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", + "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/constructs": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.4.3.tgz", + "integrity": "sha512-3+ZB67qWGM1vEstNpj6pGaLNN1qz4gxC1CBhEUhZDZk0PqzQWY65IzC1Doq17MGPa9xa2wJ1G/DJ3swU8kWAHQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-5.0.1.tgz", + "integrity": "sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dedent": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.263", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.263.tgz", + "integrity": "sha512-DrqJ11Knd+lo+dv+lltvfMDLU27g14LMdH2b0O3Pio4uk0x+z7OR+JrmyacTPN2M8w3BrZ7/RTwG3R9B7irPlg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz", + "integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==", + "dev": true, + "license": "ISC", + "dependencies": { + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "get-tsconfig": "^4.10.1", + "is-bun-module": "^2.0.0", + "stable-hash-x": "^0.2.0", + "tinyglobby": "^0.2.14", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-import-x": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.16.1.tgz", + "integrity": "sha512-vPZZsiOKaBAIATpFE2uMI4w5IRwdv/FpQ+qZZMR4E+PeOcM4OeoEbqxRMnywdxP19TyB/3h6QBB0EWon7letSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "^8.35.0", + "comment-parser": "^1.4.1", + "debug": "^4.4.1", + "eslint-import-context": "^0.1.9", + "is-glob": "^4.0.3", + "minimatch": "^9.0.3 || ^10.0.1", + "semver": "^7.7.2", + "stable-hash-x": "^0.2.0", + "unrs-resolver": "^1.9.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-import-x" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "eslint-import-resolver-node": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/utils": { + "optional": true + }, + "eslint-import-resolver-node": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-storybook": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.0.6.tgz", + "integrity": "sha512-llPShAqO86PgjswUDXwkxq1hGBjM7NiZE+nScbTjM3NVx0XwqbSZ+FRV2p5nv6vrZeREfhhY/KJF2WuSaYkXiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.8.1" + }, + "peerDependencies": { + "eslint": ">=8", + "storybook": "^10.0.6" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/git-up": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/git-up/-/git-up-8.1.1.tgz", + "integrity": "sha512-FDenSF3fVqBYSaJoYy1KSc2wosx0gCvKP+c+PRBht7cAaiCeQlBtfBDX9vgnNOHmdePlSFITVcn4pFfcgNvx3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-ssh": "^1.4.0", + "parse-url": "^9.2.0" + } + }, + "node_modules/git-url-parse": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/git-url-parse/-/git-url-parse-16.1.0.tgz", + "integrity": "sha512-cPLz4HuK86wClEW7iDdeAKcCVlWXmrLpb2L+G9goW0Z1dtpNS6BXXSOckUTlJT/LDQViE1QZKstNORzHsLnobw==", + "dev": true, + "license": "MIT", + "dependencies": { + "git-up": "^8.1.0" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", + "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/hyperlinker": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hyperlinker/-/hyperlinker-1.0.0.tgz", + "integrity": "sha512-Ty8UblRWFEcfSuIaajM34LdPXIhbs1ajEX/BBPv24J+enSVaEVY63xQ6lTO9VRYS5LAoghIG0IDJ+p+IPzKUQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-ssh": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.4.1.tgz", + "integrity": "sha512-JNeu1wQsHjyHgn9NcWTaXq6zWSR6hqE0++zhfZlkFBbScNkyvxCdeV8sRkSBaeLKxmbpR21brail63ACNxJ0Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "protocols": "^2.0.1" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", + "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.2.0", + "@jest/types": "30.2.0", + "import-local": "^3.2.0", + "jest-cli": "30.2.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", + "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.2.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", + "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "p-limit": "^3.1.0", + "pretty-format": "30.2.0", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", + "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", + "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.2.0", + "@jest/types": "30.2.0", + "babel-jest": "30.2.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-circus": "30.2.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-runner": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "micromatch": "^4.0.8", + "parse-json": "^5.2.0", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", + "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "jest-util": "30.2.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", + "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", + "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "micromatch": "^4.0.8", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-haste-map/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", + "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", + "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", + "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", + "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.2.0", + "@jest/environment": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-leak-detector": "30.2.0", + "jest-message-util": "30.2.0", + "jest-resolve": "30.2.0", + "jest-runtime": "30.2.0", + "jest-util": "30.2.0", + "jest-watcher": "30.2.0", + "jest-worker": "30.2.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", + "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/globals": "30.2.0", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", + "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-diff": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "pretty-format": "30.2.0", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", + "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-watcher": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", + "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.2.0", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", + "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.2.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-orderby": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-2.0.3.tgz", + "integrity": "sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-package-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object-treeify": { + "version": "1.1.33", + "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", + "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-path": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse-path/-/parse-path-7.1.0.tgz", + "integrity": "sha512-EuCycjZtfPcjWk7KTksnJ5xPMvWGA/6i4zrLYhRG0hGvC3GPU/jGUj3Cy+ZR0v30duV3e23R95T1lE2+lsndSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "protocols": "^2.0.0" + } + }, + "node_modules/parse-url": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/parse-url/-/parse-url-9.2.0.tgz", + "integrity": "sha512-bCgsFI+GeGWPAvAiUv63ZorMeif3/U0zaXABGJbOWt5OH2KCaPHF6S+0ok4aqM9RuIPGyZdx9tR9l13PsW4AYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/parse-path": "^7.0.0", + "parse-path": "^7.0.0" + }, + "engines": { + "node": ">=14.13.0" + } + }, + "node_modules/password-prompt": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/password-prompt/-/password-prompt-1.1.3.tgz", + "integrity": "sha512-HkrjG2aJlvF0t2BMH0e2LB/EHf3Lcq3fNMzy4GYHcQblAvOl+QQji1Lx7WRBMqpVK8p+KR7bCg7oqAMXtdgqyw==", + "dev": true, + "license": "0BSD", + "dependencies": { + "ansi-escapes": "^4.3.2", + "cross-spawn": "^7.0.3" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/protocols": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.2.tgz", + "integrity": "sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/read-package-up": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz", + "integrity": "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.1", + "read-pkg": "^10.0.0", + "type-fest": "^5.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-package-up/node_modules/type-fest": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.3.0.tgz", + "integrity": "sha512-d9CwU93nN0IA1QL+GSNDdwLAu1Ew5ZjTwupvedwg3WdfoH6pIDvYQ2hV0Uc2nKBLPq7NB5apCx57MLS5qlmO5g==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.0.0.tgz", + "integrity": "sha512-A70UlgfNdKI5NSvTTfHzLQj7NJRpJ4mT5tGafkllJ4wh71oYuGm/pzphHcmW4s35iox56KSK721AihodoXSc/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.4", + "normalize-package-data": "^8.0.0", + "parse-json": "^8.3.0", + "type-fest": "^5.2.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/read-pkg-up/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/read-pkg-up/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/read-pkg/node_modules/type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg/node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/parse-json/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.3.0.tgz", + "integrity": "sha512-d9CwU93nN0IA1QL+GSNDdwLAu1Ew5ZjTwupvedwg3WdfoH6pIDvYQ2hV0Uc2nKBLPq7NB5apCx57MLS5qlmO5g==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redeyed": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz", + "integrity": "sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esprima": "~4.0.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.22", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", + "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/storybook": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.1.4.tgz", + "integrity": "sha512-FrBjm8I8O+pYEOPHcdW9xWwgXSZxte7lza9q2lN3jFN4vuW79m5j0OnTQeR8z9MmIbBTvkIpp3yMBebl53Yt5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/icons": "^2.0.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.6.1", + "@vitest/expect": "3.2.4", + "@vitest/spy": "3.2.4", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", + "recast": "^0.23.5", + "semver": "^7.6.2", + "use-sync-external-store": "^1.5.0", + "ws": "^8.18.0" + }, + "bin": { + "storybook": "dist/bin/dispatcher.js" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "prettier": "^2 || ^3" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", + "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-jest": { + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.3.tgz", + "integrity": "sha512-bAfgMavTuGo+8n6/QQDVQz4tZ4f7Soqg53RbrlZQEoAltYop/XR4RAts/I0BrO3TTClTSTFJ0wYbla+P8cEWJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.46.3", + "@typescript-eslint/parser": "8.46.3", + "@typescript-eslint/typescript-estree": "8.46.3", + "@typescript-eslint/utils": "8.46.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/project-service": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.3.tgz", + "integrity": "sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.46.3", + "@typescript-eslint/types": "^8.46.3", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.3.tgz", + "integrity": "sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.3.tgz", + "integrity": "sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.3.tgz", + "integrity": "sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.3.tgz", + "integrity": "sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.46.3", + "@typescript-eslint/tsconfig-utils": "8.46.3", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/visitor-keys": "8.46.3", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.3.tgz", + "integrity": "sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.46.3", + "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/typescript-estree": "8.46.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.46.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.3.tgz", + "integrity": "sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.3", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/cdk/package.json b/cdk/package.json new file mode 100644 index 00000000000..1f5401b9af1 --- /dev/null +++ b/cdk/package.json @@ -0,0 +1,44 @@ +{ + "name": "cdk", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "tsc", + "test": "jest", + "test-update": "jest -u", + "format": "prettier --write \"{lib,bin}/**/*.ts\"", + "lint": "eslint lib/** bin/** --no-error-on-unmatched-pattern", + "synth": "cdk synth --path-metadata false --version-reporting false", + "diff": "cdk diff --path-metadata false --version-reporting false" + }, + "devDependencies": { + "@guardian/cdk": "^62.1.2", + "@guardian/eslint-config": "^13.0.0", + "@guardian/prettier": "^9.0.0", + "@guardian/tsconfig": "^1.0.0", + "@types/jest": "^30.0.0", + "@types/node": "^24.10.1", + "aws-cdk": "^2.1033.0", + "aws-cdk-lib": "^2.231.0", + "constructs": "^10.4.3", + "eslint": "^9.34.0", + "jest": "^30.2.0", + "prettier": "^3.6.2", + "source-map-support": "^0.5.20", + "ts-jest": "^29.4.2", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" + }, + "prettier": "@guardian/prettier", + "jest": { + "testMatch": [ + "/lib/**/*.test.ts" + ], + "transform": { + "^.+\\.tsx?$": "ts-jest" + }, + "setupFilesAfterEnv": [ + "./jest.setup.js" + ] + } +} \ No newline at end of file diff --git a/cdk/tsconfig.json b/cdk/tsconfig.json new file mode 100644 index 00000000000..d3899d53df6 --- /dev/null +++ b/cdk/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@guardian/tsconfig/tsconfig.json", + "compilerOptions": { + "module": "CommonJS" + }, + "include": ["lib/**/*", "bin/**/*"], + "exclude": [ + "node_modules", + "cdk.out", + "lib/**/__snapshots__/**" + ] +} diff --git a/cloudbuild-kahuna-only.yaml b/cloudbuild-kahuna-only.yaml new file mode 100644 index 00000000000..8907d469693 --- /dev/null +++ b/cloudbuild-kahuna-only.yaml @@ -0,0 +1,25 @@ +options: + machineType: 'N1_HIGHCPU_8' +steps: + - name: 'node:24-alpine' + entrypoint: 'npm' + dir: 'kahuna' + args: [ 'install' ] + - name: 'node:24-alpine' + entrypoint: 'npm' + dir: 'kahuna' + args: [ 'run', 'test' ] + - name: 'node:24-alpine' + entrypoint: 'npm' + dir: 'kahuna' + args: [ 'run', 'dist' ] + + - name: 'gcr.io/$PROJECT_ID/scala-sbt:1.11.7-jdk-25' + args: ['kahuna/docker:publishLocal'] + env: + - 'DOCKER_API_VERSION=1.41' + + - name: 'gcr.io/cloud-builders/docker' + args: ['tag', 'kahuna:0.1', 'eu.gcr.io/$PROJECT_ID/kahuna:$BRANCH_NAME'] + - name: 'gcr.io/cloud-builders/docker' + args: ['push', 'eu.gcr.io/$PROJECT_ID/kahuna:$BRANCH_NAME'] diff --git a/cloudbuild.yaml b/cloudbuild.yaml new file mode 100644 index 00000000000..afff3526883 --- /dev/null +++ b/cloudbuild.yaml @@ -0,0 +1,69 @@ +options: + machineType: 'N1_HIGHCPU_8' +steps: + - name: 'node:24-alpine' + entrypoint: 'npm' + dir: 'kahuna' + args: [ 'install' ] + - name: 'node:24-alpine' + entrypoint: 'npm' + dir: 'kahuna' + args: [ 'run', 'test' ] + - name: 'node:24-alpine' + entrypoint: 'npm' + dir: 'kahuna' + args: [ 'run', 'dist' ] + + - name: 'gcr.io/$PROJECT_ID/scala-sbt:1.11.7-jdk-25' + args: ['docker:publishLocal'] + env: + - 'DOCKER_API_VERSION=1.41' + - name: 'gcr.io/cloud-builders/docker' + args: ['tag', 'auth:0.1', 'eu.gcr.io/$PROJECT_ID/auth:$BRANCH_NAME'] + - name: 'gcr.io/cloud-builders/docker' + args: ['push', 'eu.gcr.io/$PROJECT_ID/auth:$BRANCH_NAME'] + + - name: 'gcr.io/cloud-builders/docker' + args: ['tag', 'cropper:0.1', 'eu.gcr.io/$PROJECT_ID/cropper:$BRANCH_NAME'] + - name: 'gcr.io/cloud-builders/docker' + args: ['push', 'eu.gcr.io/$PROJECT_ID/cropper:$BRANCH_NAME'] + + - name: 'gcr.io/cloud-builders/docker' + args: ['tag', 'collections:0.1', 'eu.gcr.io/$PROJECT_ID/collections:$BRANCH_NAME'] + - name: 'gcr.io/cloud-builders/docker' + args: ['push', 'eu.gcr.io/$PROJECT_ID/collections:$BRANCH_NAME'] + + - name: 'gcr.io/cloud-builders/docker' + args: ['tag', 'image-loader:0.1', 'eu.gcr.io/$PROJECT_ID/image-loader:$BRANCH_NAME'] + - name: 'gcr.io/cloud-builders/docker' + args: ['push', 'eu.gcr.io/$PROJECT_ID/image-loader:$BRANCH_NAME'] + + - name: 'gcr.io/cloud-builders/docker' + args: ['tag', 'kahuna:0.1', 'eu.gcr.io/$PROJECT_ID/kahuna:$BRANCH_NAME'] + - name: 'gcr.io/cloud-builders/docker' + args: ['push', 'eu.gcr.io/$PROJECT_ID/kahuna:$BRANCH_NAME'] + + - name: 'gcr.io/cloud-builders/docker' + args: ['tag', 'leases:0.1', 'eu.gcr.io/$PROJECT_ID/leases:$BRANCH_NAME'] + - name: 'gcr.io/cloud-builders/docker' + args: ['push', 'eu.gcr.io/$PROJECT_ID/leases:$BRANCH_NAME'] + + - name: 'gcr.io/cloud-builders/docker' + args: ['tag', 'media-api:0.1', 'eu.gcr.io/$PROJECT_ID/media-api:$BRANCH_NAME'] + - name: 'gcr.io/cloud-builders/docker' + args: ['push', 'eu.gcr.io/$PROJECT_ID/media-api:$BRANCH_NAME'] + + - name: 'gcr.io/cloud-builders/docker' + args: ['tag', 'metadata-editor:0.1', 'eu.gcr.io/$PROJECT_ID/metadata-editor:$BRANCH_NAME'] + - name: 'gcr.io/cloud-builders/docker' + args: ['push', 'eu.gcr.io/$PROJECT_ID/metadata-editor:$BRANCH_NAME'] + + - name: 'gcr.io/cloud-builders/docker' + args: ['tag', 'thrall:0.1', 'eu.gcr.io/$PROJECT_ID/thrall:$BRANCH_NAME'] + - name: 'gcr.io/cloud-builders/docker' + args: ['push', 'eu.gcr.io/$PROJECT_ID/thrall:$BRANCH_NAME'] + + - name: 'gcr.io/cloud-builders/docker' + args: ['tag', 'usage:0.1', 'eu.gcr.io/$PROJECT_ID/usage:$BRANCH_NAME'] + - name: 'gcr.io/cloud-builders/docker' + args: ['push', 'eu.gcr.io/$PROJECT_ID/usage:$BRANCH_NAME'] diff --git a/collections/app/CollectionsComponents.scala b/collections/app/CollectionsComponents.scala index 046c99e065f..66755c0423a 100644 --- a/collections/app/CollectionsComponents.scala +++ b/collections/app/CollectionsComponents.scala @@ -1,22 +1,22 @@ -import com.gu.mediaservice.lib.management.InnerServiceStatusCheckController import com.gu.mediaservice.lib.play.GridComponents import controllers.{CollectionsController, ImageCollectionsController} import lib.{CollectionsConfig, CollectionsMetrics, Notifications} import play.api.ApplicationLoader.Context import router.Routes -import store.CollectionsStore +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient +import store.{CollectionsStore, ImageCollectionsStore} class CollectionsComponents(context: Context) extends GridComponents(context, new CollectionsConfig(_)) { final override val buildInfo = utils.buildinfo.BuildInfo - val store = new CollectionsStore(config) + private val collectionsStore = new CollectionsStore(config.collectionsTable, config.withAWSCredentialsV2(DynamoDbAsyncClient.builder()).build()) + val imageCollectionsStore = new ImageCollectionsStore(config.imageCollectionsTable, config.withAWSCredentialsV2(DynamoDbAsyncClient.builder()).build()) val metrics = new CollectionsMetrics(config, actorSystem, applicationLifecycle) val notifications = new Notifications(config) - val collections = new CollectionsController(auth, config, store, controllerComponents) - val imageCollections = new ImageCollectionsController(auth, config, notifications, controllerComponents) - val InnerServiceStatusCheckController = new InnerServiceStatusCheckController(auth, controllerComponents, config.services, wsClient) + val collections = new CollectionsController(auth, config, collectionsStore, controllerComponents) + val imageCollections = new ImageCollectionsController(auth, notifications, imageCollectionsStore, controllerComponents) - override val router = new Routes(httpErrorHandler, collections, imageCollections, management, InnerServiceStatusCheckController) + override val router = new Routes(httpErrorHandler, collections, imageCollections, management) } diff --git a/collections/app/controllers/CollectionsController.scala b/collections/app/controllers/CollectionsController.scala index 232f4eb0585..92142ec9f07 100644 --- a/collections/app/controllers/CollectionsController.scala +++ b/collections/app/controllers/CollectionsController.scala @@ -1,24 +1,27 @@ package controllers import java.net.URI - import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.argo.model.{EmbeddedEntity, Link} import com.gu.mediaservice.lib.auth.Authentication import com.gu.mediaservice.lib.auth.Authentication.getIdentity import com.gu.mediaservice.lib.collections.CollectionsManager -import com.gu.mediaservice.model.{ActionData, Collection} +import com.gu.mediaservice.lib.config.InstanceForRequest +import com.gu.mediaservice.model.{ActionData, Collection, Instance} import lib.CollectionsConfig import model.Node import org.joda.time.DateTime import play.api.libs.functional.syntax._ import play.api.libs.json._ -import play.api.mvc.{BaseController, ControllerComponents} +import play.api.mvc.{BaseController, ControllerComponents, Request} import store.{CollectionsStore, CollectionsStoreError} -import com.gu.mediaservice.lib.net.{ URI => UriOps } +import com.gu.mediaservice.lib.net.{URI => UriOps} +import software.amazon.awssdk.services.dynamodb.model.AttributeValue +import com.gu.mediaservice.lib.net.{URI => UriOps} import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future +import scala.jdk.CollectionConverters.{CollectionHasAsScala, MapHasAsJava, MapHasAsScala, SeqHasAsJava} case class HasChildrenError(message: String) extends Throwable @@ -30,31 +33,31 @@ object AppIndex { } class CollectionsController(authenticated: Authentication, config: CollectionsConfig, store: CollectionsStore, - val controllerComponents: ControllerComponents) extends BaseController with ArgoHelpers { + val controllerComponents: ControllerComponents) extends BaseController with ArgoHelpers with InstanceForRequest { import CollectionsManager.{getCssColour, isValidPathBit, pathToUri, uriToPath} // Stupid name clash between Argo and Play import com.gu.mediaservice.lib.argo.model.{Action => ArgoAction} - def uri(u: String) = URI.create(u) - val collectionUri = uri(s"${config.rootUri}/collections") - def collectionUri(p: List[String] = Nil) = { + private def uri(u: String) = URI.create(u) + private def collectionUri()(implicit instance: Instance) = uri(s"${config.rootUri(instance)}/collections") + private def collectionUri(p: List[String] = Nil)(implicit instance: Instance) = { val path = if(p.nonEmpty) s"/${pathToUri(p)}" else "" - uri(s"${config.rootUri}/collections$path") + uri(s"${config.rootUri(instance)}/collections$path") } - val appIndex = AppIndex("media-collections", "The one stop shop for collections") - val indexLinks = List(Link("collections", collectionUri.toString)) + private val appIndex = AppIndex("media-collections", "The one stop shop for collections") + private def indexLinks()(implicit instance: Instance) = List(Link("collections", collectionUri().toString)) - def getNodeAction(n: Node[Collection]): Option[Link] = Some(Link("collection", collectionUri(n.fullPath).toString)) - def addChildAction(pathId: List[String] = Nil): Option[ArgoAction] = Some(ArgoAction("add-child", collectionUri(pathId), "POST")) - def addChildAction(n: Node[Collection]): Option[ArgoAction] = addChildAction(n.fullPath) - def removeNodeAction(n: Node[Collection]): Option[ArgoAction] = if (n.children.nonEmpty) None else Some( + private def getNodeAction(n: Node[Collection])(implicit instance: Instance): Option[Link] = Some(Link("collection", collectionUri(n.fullPath).toString)) + private def addChildAction(pathId: List[String] = Nil)(implicit instance: Instance): Option[ArgoAction] = Some(ArgoAction("add-child", collectionUri(pathId), "POST")) + private def addChildAction(n: Node[Collection])(implicit instance: Instance): Option[ArgoAction] = addChildAction(n.fullPath) + private def removeNodeAction(n: Node[Collection])(implicit instance: Instance): Option[ArgoAction] = if (n.children.nonEmpty) None else Some( ArgoAction("remove", collectionUri(n.fullPath), "DELETE") ) def index = authenticated { req => - respond(appIndex, links = indexLinks) + respond(appIndex, links = indexLinks()(instanceOf(req))) } def collectionNotFound(path: String) = @@ -69,15 +72,16 @@ class CollectionsController(authenticated: Authentication, config: CollectionsCo def storeError(message: String) = respondError(InternalServerError, "collection-store-error", message) - def getActions(n: Node[Collection]): List[ArgoAction] = { + def getActions(n: Node[Collection])(implicit instance: Instance): List[ArgoAction] = { List(addChildAction(n), removeNodeAction(n)).flatten } - def getLinks(n: Node[Collection]): List[Link] = { + private def getLinks(n: Node[Collection])(implicit instance: Instance): List[Link] = { List(getNodeAction(n)).flatten } def correctedCollections = authenticated.async { req => + implicit val instance: Instance = instanceOf(req) store.getAll flatMap { collections => val tree = Node.fromList[Collection]( collections, @@ -99,14 +103,15 @@ class CollectionsController(authenticated: Authentication, config: CollectionsCo } } - def allCollections = store.getAll.map { collections => + def allCollections()(implicit instance: Instance)= store.getAll.map { collections => Node.fromList[Collection]( collections, (collection) => collection.path, (collection) => collection.description) } - def getCollection(collectionPathId: String) = authenticated.async { + def getCollection(collectionPathId: String) = authenticated.async { request => + implicit val instance: Instance = instanceOf(request) store.get(uriToPath(collectionPathId)).map { case Some(collection) => val node = Node(collection.path.last, Nil, collection.path, collection.path, Some(collection)) @@ -119,7 +124,18 @@ class CollectionsController(authenticated: Authentication, config: CollectionsCo } def getCollections = authenticated.async { req => - allCollections.map { tree => + implicit val instance: Instance = instanceOf(req) + implicit def asArgo: Writes[Node[Collection]] = ( + (__ \ "basename").write[String] ~ + (__ \ "children").lazyWrite[CollectionsEntity](Writes[CollectionsEntity] + // This is so we don't have to rewrite the Write[Seq[T]] + (seq => Json.toJson(seq))).contramap(collectionsEntity(_: List[Node[Collection]])) ~ + (__ \ "fullPath").write[List[String]] ~ + (__ \ "data").writeNullable[Collection] ~ + (__ \ "cssColour").writeNullable[String] + )(node => (node.basename, node.children, node.fullPath, node.data, getCssColour(node.fullPath))) + + allCollections().map { tree => respond( Json.toJson(tree)(asArgo), actions = List(addChildAction()).flatten @@ -133,6 +149,7 @@ class CollectionsController(authenticated: Authentication, config: CollectionsCo def addChildToRoot = addChildTo(None) def addChildToCollection(collectionPathId: String) = addChildTo(Some(collectionPathId)) def addChildTo(collectionPathId: Option[String]) = authenticated.async(parse.json) { req => + implicit val instance: Instance = instanceOf(req) (req.body \ "data").asOpt[String] map { child => if (isValidPathBit(child)) { val path = collectionPathId.map(uriToPath).getOrElse(Nil) :+ child @@ -152,8 +169,8 @@ class CollectionsController(authenticated: Authentication, config: CollectionsCo } type MaybeTree = Option[Node[Collection]] - def hasChildren(path: List[String]): Future[Boolean] = - allCollections.map { tree => + private def hasChildren(path: List[String])(implicit instance: Instance) = + allCollections().map { tree => // Traverse the tree using the path val maybeTree = path @@ -167,6 +184,7 @@ class CollectionsController(authenticated: Authentication, config: CollectionsCo } def removeCollection(collectionPath: String) = authenticated.async { req => + implicit val instance: Instance = instanceOf(req) val path = CollectionsManager.uriToPath(UriOps.encodePlus(collectionPath)) hasChildren(path).flatMap { noRemove => @@ -194,18 +212,8 @@ class CollectionsController(authenticated: Authentication, config: CollectionsCo )(node => (node.basename, node.children, node.fullPath, node.data)) type CollectionsEntity = Seq[EmbeddedEntity[Node[Collection]]] - implicit def asArgo: Writes[Node[Collection]] = ( - (__ \ "basename").write[String] ~ - (__ \ "children").lazyWrite[CollectionsEntity](Writes[CollectionsEntity] - // This is so we don't have to rewrite the Write[Seq[T]] - (seq => Json.toJson(seq))).contramap(collectionsEntity) ~ - (__ \ "fullPath").write[List[String]] ~ - (__ \ "data").writeNullable[Collection] ~ - (__ \ "cssColour").writeNullable[String] - )(node => (node.basename, node.children, node.fullPath, node.data, getCssColour(node.fullPath))) - - def collectionsEntity(nodes: List[Node[Collection]]): CollectionsEntity = { + private def collectionsEntity(nodes: List[Node[Collection]])(implicit instance: Instance): CollectionsEntity = { nodes.map(n => EmbeddedEntity(collectionUri(n.fullPath), Some(n), links = getLinks(n), actions = getActions(n))) } diff --git a/collections/app/controllers/ImageCollectionsController.scala b/collections/app/controllers/ImageCollectionsController.scala index 0b2435fb62e..ae26f7746bf 100644 --- a/collections/app/controllers/ImageCollectionsController.scala +++ b/collections/app/controllers/ImageCollectionsController.scala @@ -3,30 +3,31 @@ package controllers import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.auth.Authentication import com.gu.mediaservice.lib.auth.Authentication.getIdentity -import com.gu.mediaservice.lib.aws.{DynamoDB, NoItemFound, UpdateMessage} +import com.gu.mediaservice.lib.aws.{NoItemFound, UpdateMessage} import com.gu.mediaservice.lib.collections.CollectionsManager +import com.gu.mediaservice.lib.config.InstanceForRequest import com.gu.mediaservice.lib.net.{URI => UriOps} -import com.gu.mediaservice.model.{ActionData, Collection} +import com.gu.mediaservice.model.{ActionData, Collection, Instance} import com.gu.mediaservice.syntax.MessageSubjects -import lib.{CollectionsConfig, Notifications} +import lib.Notifications import org.joda.time.DateTime -import play.api.libs.json.Json import play.api.mvc.{BaseController, ControllerComponents} +import store.ImageCollectionsStore import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future -class ImageCollectionsController(authenticated: Authentication, config: CollectionsConfig, notifications: Notifications, +class ImageCollectionsController(authenticated: Authentication, notifications: Notifications, + imageCollectionsStore: ImageCollectionsStore, override val controllerComponents: ControllerComponents) - extends BaseController with MessageSubjects with ArgoHelpers { + extends BaseController with MessageSubjects with ArgoHelpers with InstanceForRequest { import CollectionsManager.onlyLatest - val dynamo = new DynamoDB[Collection](config, config.imageCollectionsTable) - def getCollections(id: String) = authenticated.async { req => - dynamo.listGet(id, "collections").map { collections => + implicit val instance: Instance = instanceOf(req) + imageCollectionsStore.get(id).map { collections => respond(onlyLatest(collections)) } recover { case NoItemFound => respondNotFound("No collections found") @@ -34,9 +35,10 @@ class ImageCollectionsController(authenticated: Authentication, config: Collecti } def addCollection(id: String) = authenticated.async(parse.json) { req => + implicit val instance: Instance = instanceOf(req) (req.body \ "data").asOpt[List[String]].map { path => val collection = Collection.build(path, ActionData(getIdentity(req.user), DateTime.now())) - dynamo.listAdd(id, "collections", collection) + imageCollectionsStore.add(id, collection) .map(publish(id)) .map(cols => respond(collection)) } getOrElse Future.successful(respondError(BadRequest, "invalid-form-data", "Invalid form data")) @@ -44,17 +46,19 @@ class ImageCollectionsController(authenticated: Authentication, config: Collecti def removeCollection(id: String, collectionString: String) = authenticated.async { req => + implicit val instance: Instance = instanceOf(req) val path = CollectionsManager.uriToPath(UriOps.encodePlus(collectionString)) // We do a get to be able to find the index of the current collection, then remove it. // Given that we're using Dynamo Lists this seemed like a decent way to do it. // Dynamo Lists, like other lists do respect order. - dynamo.listGet(id, "collections") flatMap { collections => + imageCollectionsStore.get(id) flatMap { collections => CollectionsManager.findIndexes(path, collections) match { case Nil => Future.successful(respondNotFound(s"Collection $collectionString not found")) case indexes => - dynamo.listRemoveIndexes(id, "collections", indexes) - .map(publish(id)) + val updatedCollections = CollectionsManager.filterCollectionsByIndexes(indexes, collections) + imageCollectionsStore.update(id, updatedCollections) + .map(cols => publish(id)(cols)) .map(cols => respond(cols)) } } recover { @@ -62,9 +66,9 @@ class ImageCollectionsController(authenticated: Authentication, config: Collecti } } - def publish(id: String)(collections: List[Collection]): List[Collection] = { + def publish(id: String)(collections: List[Collection])(implicit instance: Instance): List[Collection] = { val onlyLatestCollections = onlyLatest(collections) - val updateMessage = UpdateMessage(subject = SetImageCollections, id = Some(id), collections = Some(onlyLatestCollections)) + val updateMessage = UpdateMessage(subject = SetImageCollections, id = Some(id), collections = Some(onlyLatestCollections), instance = instance) notifications.publish(updateMessage) onlyLatestCollections } diff --git a/collections/app/lib/CollectionsConfig.scala b/collections/app/lib/CollectionsConfig.scala index 9570409bc2d..20dcb8ff52e 100644 --- a/collections/app/lib/CollectionsConfig.scala +++ b/collections/app/lib/CollectionsConfig.scala @@ -1,11 +1,12 @@ package lib import com.gu.mediaservice.lib.config.{CommonConfig, GridConfigResources} +import com.gu.mediaservice.model.Instance class CollectionsConfig(resources: GridConfigResources) extends CommonConfig(resources) { val collectionsTable = string("dynamo.table.collections") val imageCollectionsTable = string("dynamo.table.imageCollections") - val rootUri = services.collectionsBaseUri + val rootUri: Instance => String = services.collectionsBaseUri } diff --git a/collections/app/store/CollectionsStore.scala b/collections/app/store/CollectionsStore.scala index 815aa5a5866..52ef88ed4d7 100644 --- a/collections/app/store/CollectionsStore.scala +++ b/collections/app/store/CollectionsStore.scala @@ -1,49 +1,61 @@ package store -import com.gu.mediaservice.lib.aws.{DynamoDB, NoItemFound} +import cats.implicits._ import com.gu.mediaservice.lib.collections.CollectionsManager -import com.gu.mediaservice.model.Collection -import lib.CollectionsConfig -import play.api.libs.json.JsValue +import com.gu.mediaservice.model.{ActionData, Collection, Instance} +import org.joda.time.DateTime +import org.scanamo.generic.auto.genericDerivedFormat +import org.scanamo.syntax._ +import org.scanamo.{DynamoFormat, ScanamoAsync, Table} +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future -class CollectionsStore(config: CollectionsConfig) { - val dynamo = new DynamoDB[Collection](config, config.collectionsTable) +case class Record(id: String, collection: Collection, instance: String) - def getAll: Future[List[Collection]] = dynamo.scan() map { jsonList => - jsonList.flatMap(json => (json \ "collection").asOpt[Collection]) - } recover { - case e => throw CollectionsStoreError(e) +class CollectionsStore(val tableName: String, client: DynamoDbAsyncClient) extends DynamoHelpers { + import org.scanamo.generic.semiauto._ + implicit val dateTimeFormat: Typeclass[DateTime] = + DynamoFormat.coercedXmap[DateTime, String, IllegalArgumentException](DateTime.parse, _.toString) + implicit val actionData: DynamoFormat[ActionData] = deriveDynamoFormat[ActionData] + implicit val collection: DynamoFormat[Collection] = deriveDynamoFormat[Collection] + implicit val Record: DynamoFormat[Record] = deriveDynamoFormat[Record] + + private lazy val collectionsTable = Table[Record](tableName) + + def getAll(implicit instance: Instance): Future[List[Collection]] = { + ScanamoAsync(client).exec(collectionsTable.query("instance" === instance.id)).map(_.sequence).flatMap(res => + handleResponse(res)(records => records.map(_.collection)) + ) } - def add(collection: Collection): Future[Collection] = { - dynamo.objPut(collection.pathId, "collection", collection) - } recover { - case e => throw CollectionsStoreError(e) + def add(collection: Collection)(implicit instance: Instance): Future[Collection] = { + ScanamoAsync(client).exec( + collectionsTable.update( + "id" === collection.pathId and "instance" === instance.id, + set("collection", collection) + ) + ).flatMap(res => handleResponse(res)(record => record.collection)) } - def get(collectionPath: List[String]): Future[Option[Collection]] = { + def get(collectionPath: List[String])(implicit instance: Instance): Future[Option[Collection]] = { val path = CollectionsManager.pathToPathId(collectionPath) - dynamo.get(path).map(json => (json \ "collection").asOpt[Collection]) - } recover { - case NoItemFound => None - case e => throw CollectionsStoreError(e) + ScanamoAsync(client).exec(collectionsTable.get("id" === path and "instance" === instance.id)).flatMap(maybeEither => + maybeEither.fold[Future[Option[Collection]]]( + Future.successful(None) + )(res => + handleResponse(res)(record => Some(record.collection)) + ) + ) } - def remove(collectionPath: List[String]): Future[Unit] = { + def remove(collectionPath: List[String])(implicit instance: Instance): Future[Unit] = { val path = CollectionsManager.pathToPathId(collectionPath) - dynamo.deleteItem(path) - } recover { - case e => throw CollectionsStoreError(e) + ScanamoAsync(client).exec(collectionsTable.delete("id" === path and "instance" === instance.id)) } } -case class InvalidCollectionJson(json: JsValue) extends Throwable { - val message: String = s"Invalid Collection JSON: ${json.toString}" -} - case class CollectionsStoreError(e: Throwable) extends Throwable { val message: String = s"Error accessing collection store: ${e.getMessage}" } diff --git a/collections/app/store/DynamoHelpers.scala b/collections/app/store/DynamoHelpers.scala new file mode 100644 index 00000000000..0e4e15126bc --- /dev/null +++ b/collections/app/store/DynamoHelpers.scala @@ -0,0 +1,19 @@ +package store + +import org.scanamo.DynamoReadError + +import scala.concurrent.Future + +trait DynamoHelpers { + val tableName: String + def handleResponse[T, U](result: Either[DynamoReadError, T])(f: T => U): Future[U] = { + result.fold( + error => Future.failed(StoreDynamoError(error, tableName)), + success => Future.successful(f(success)) + ) + } +} + +case class StoreDynamoError(err: DynamoReadError, tableName: String) extends Throwable { + val message: String = s"Error accessing ${tableName} store: ${err.toString}" +} diff --git a/collections/app/store/ImageCollectionsStore.scala b/collections/app/store/ImageCollectionsStore.scala new file mode 100644 index 00000000000..a9970d03a9f --- /dev/null +++ b/collections/app/store/ImageCollectionsStore.scala @@ -0,0 +1,51 @@ +package store + +import com.gu.mediaservice.model.{ActionData, Collection, Instance} +import lib.CollectionsConfig +import org.joda.time.DateTime +import org.scanamo.generic.auto.genericDerivedFormat +import org.scanamo.{DynamoFormat, ScanamoAsync, Table} +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient + +import scala.concurrent.Future +import cats.implicits._ +import com.gu.mediaservice.lib.aws.NoItemFound +import org.scanamo.syntax._ + +import scala.concurrent.ExecutionContext.Implicits.global + +case class ImageRecord(id: String, instance: String, collections: List[Collection]) + +class ImageCollectionsStore(val tableName: String, val client: DynamoDbAsyncClient) extends DynamoHelpers { + + import org.scanamo.generic.semiauto._ + implicit val dateTimeFormat: Typeclass[DateTime] = + DynamoFormat.coercedXmap[DateTime, String, IllegalArgumentException](DateTime.parse, _.toString) + implicit val actionData: DynamoFormat[ActionData] = deriveDynamoFormat[ActionData] + implicit val collection: DynamoFormat[Collection] = deriveDynamoFormat[Collection] + implicit val imageRecord: DynamoFormat[ImageRecord] = deriveDynamoFormat[ImageRecord] + + private lazy val imageCollectionsTable = Table[ImageRecord](tableName) + + def get(id: String)(implicit instance: Instance): Future[List[Collection]] = { + ScanamoAsync(client).exec(imageCollectionsTable.get("id" === id and "instance" === instance.id)).flatMap(maybeEither => + maybeEither.fold[Future[List[Collection]]]( + Future.failed(NoItemFound) + )(res => + handleResponse(res)(record => record.collections) + ) + ) + } + + def add(id: String, collection: Collection)(implicit instance: Instance): Future[List[Collection]] = { + ScanamoAsync(client).exec(imageCollectionsTable.update("id" === id and "instance" === instance.id, append("collections", collection))).flatMap(res => { + handleResponse(res)(res => res.collections) + }) + } + + def update(id: String, collections: List[Collection])(implicit instance: Instance): Future[List[Collection]] = { + ScanamoAsync(client).exec(imageCollectionsTable.update("id" === id and "instance" === instance.id, set("collections", collections))).flatMap(res => { + handleResponse(res)(res => res.collections) + }) + } +} diff --git a/collections/conf/routes b/collections/conf/routes index 21acc8e1243..8fd9d4424dc 100644 --- a/collections/conf/routes +++ b/collections/conf/routes @@ -16,7 +16,6 @@ POST /corrected-collections controllers.CollectionsC # Management GET /management/healthcheck com.gu.mediaservice.lib.management.Management.healthCheck GET /management/manifest com.gu.mediaservice.lib.management.Management.manifest -GET /management/whoAmI com.gu.mediaservice.lib.management.InnerServiceStatusCheckController.whoAmI(depth: Int) # Shoo robots away GET /robots.txt com.gu.mediaservice.lib.management.Management.disallowRobots diff --git a/collections/test/store/CollectionsStoreTest.scala b/collections/test/store/CollectionsStoreTest.scala new file mode 100644 index 00000000000..78c2f5a9e71 --- /dev/null +++ b/collections/test/store/CollectionsStoreTest.scala @@ -0,0 +1,117 @@ +package store + +import com.gu.mediaservice.model.{ActionData, Collection, Instance} +import org.joda.time.DateTime +import org.scalatest.BeforeAndAfterAll +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.time.{Millis, Seconds, Span} +import org.testcontainers.containers.localstack.LocalStackContainer +import org.testcontainers.containers.localstack.LocalStackContainer.Service.DYNAMODB +import org.testcontainers.utility.DockerImageName +import software.amazon.awssdk.auth.credentials.{AwsBasicCredentials, StaticCredentialsProvider} +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient +import software.amazon.awssdk.services.dynamodb.model._ + +import java.util.UUID +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.jdk.CollectionConverters._ + + +class CollectionsStoreTest extends AnyFunSpec with Matchers with ScalaFutures with BeforeAndAfterAll { + + implicit val defaultPatience: PatienceConfig = PatienceConfig(timeout = Span(2, Seconds), interval = Span(100, Millis)) + + private val dynamoContainer = new LocalStackContainer(DockerImageName.parse("localstack/localstack:1.4.0")).withServices(DYNAMODB) + dynamoContainer.start() + + private val dynamoClient = DynamoDbAsyncClient.builder(). + endpointOverride(dynamoContainer.getEndpointOverride(DYNAMODB)). + region(Region.of(dynamoContainer.getRegion)). + credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(dynamoContainer.getAccessKey, dynamoContainer.getSecretKey))).build() + + private implicit val instance: Instance = Instance("an-instance") + + private val collectionsTable = "test-collections-table-" + UUID.randomUUID().toString + private val collectionsTableForAllTest = "test-collections-table-" + UUID.randomUUID().toString + private val store = new CollectionsStore(collectionsTable, dynamoClient) + private val storeForAllTest = new CollectionsStore(collectionsTableForAllTest, dynamoClient) + + override def beforeAll(): Unit = { + def createTableRequestFor(tableName: String): CreateTableRequest = { + val attributeDefinitions = List( + AttributeDefinition.builder.attributeName("id").attributeType(ScalarAttributeType.S).build(), + AttributeDefinition.builder.attributeName("instance").attributeType(ScalarAttributeType.S).build() + ) + val keySchema = List( + KeySchemaElement.builder.attributeName("instance").keyType(KeyType.HASH).build(), + KeySchemaElement.builder.attributeName("id").keyType(KeyType.RANGE).build() + ) + val provisionedThroughput = ProvisionedThroughput.builder.readCapacityUnits(1L).writeCapacityUnits(1L).build() + val request = CreateTableRequest.builder + .tableName(tableName) + .attributeDefinitions(attributeDefinitions.asJava) + .keySchema(keySchema.asJava) + .provisionedThroughput(provisionedThroughput) + .build() + request + } + + dynamoClient.createTable(createTableRequestFor(collectionsTable)).get() + dynamoClient.createTable(createTableRequestFor(collectionsTableForAllTest)).get() + } + + override def afterAll(): Unit = { + super.afterAll() + dynamoContainer.stop() + } + + describe("CollectionsStore") { + val collection = Collection(List("a", "b"), ActionData("author", DateTime.now()), "description") + + it("should be able to add a collection") { + val eventualResult = store.add(collection) + whenReady(eventualResult) { c => + c.pathId should be("a/b") + c.description should be("description") + } + } + + it("should be able to get a collection") { + val eventualResult = store.get(List("a", "b")) + whenReady(eventualResult) { c => + c.get.pathId should be("a/b") + c.get.description should be("description") + } + } + + it("should be able to get all collections") { + val collection1 = Collection(List("e", "f"), ActionData("author1", DateTime.now()), "description1") + val collection2 = Collection(List("g", "h"), ActionData("author2", DateTime.now()), "description2") + + val eventualAdded = Future.sequence(Seq(storeForAllTest.add(collection1), storeForAllTest.add(collection2))) + + val eventualResult = eventualAdded.flatMap(_ => storeForAllTest.getAll) + + whenReady(eventualResult) { collections => + collections.size should be(2) + collections.map(_.pathId) should contain allOf("e/f", "g/h") + } + } + + it("should be able to remove a collection") { + val eventualResult = store.remove(List("a", "b")).flatMap(_ => store.get(List("a", "b"))) + whenReady(eventualResult) { c => + c should be(None) + } + + val eventualReadback = store.get(List("a", "b")) + whenReady(eventualReadback) { r => + r should be(None) + } + } + } +} diff --git a/collections/test/store/ImageCollectionsStoreTest.scala b/collections/test/store/ImageCollectionsStoreTest.scala new file mode 100644 index 00000000000..77e366f42ba --- /dev/null +++ b/collections/test/store/ImageCollectionsStoreTest.scala @@ -0,0 +1,107 @@ +package store + +import com.gu.mediaservice.model.{ActionData, Collection, Instance} +import org.joda.time.DateTime +import org.scalatest.BeforeAndAfterAll +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.time.{Millis, Seconds, Span} +import org.testcontainers.containers.localstack.LocalStackContainer +import org.testcontainers.containers.localstack.LocalStackContainer.Service.DYNAMODB +import org.testcontainers.utility.DockerImageName +import software.amazon.awssdk.auth.credentials.{AwsBasicCredentials, StaticCredentialsProvider} +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient +import software.amazon.awssdk.services.dynamodb.model._ + +import java.util.UUID +import scala.jdk.CollectionConverters._ + +class ImageCollectionsStoreTest extends AnyFunSpec with Matchers with ScalaFutures with BeforeAndAfterAll { + + implicit val defaultPatience: PatienceConfig = PatienceConfig(timeout = Span(5, Seconds), interval = Span(500, Millis)) + + private val dynamoContainer = new LocalStackContainer(DockerImageName.parse("localstack/localstack:1.4.0")).withServices(DYNAMODB) + dynamoContainer.start() + + private val dynamoClient = DynamoDbAsyncClient.builder(). + endpointOverride(dynamoContainer.getEndpointOverride(DYNAMODB)). + region(Region.of(dynamoContainer.getRegion)). + credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(dynamoContainer.getAccessKey, dynamoContainer.getSecretKey))).build() + + private implicit val instance: Instance = Instance("an-instance") + + private val imageCollectionsTable = "test-image-collections-table-" + UUID.randomUUID().toString + private val store = new ImageCollectionsStore(imageCollectionsTable, dynamoClient) + + override def beforeAll(): Unit = { + val attributeDefinitions = List( + AttributeDefinition.builder.attributeName("id").attributeType(ScalarAttributeType.S).build(), + AttributeDefinition.builder.attributeName("instance").attributeType(ScalarAttributeType.S).build() + ) + val keySchema = List( + KeySchemaElement.builder.attributeName("instance").keyType(KeyType.HASH).build(), + KeySchemaElement.builder.attributeName("id").keyType(KeyType.RANGE).build() + ) + val provisionedThroughput = ProvisionedThroughput.builder.readCapacityUnits(1L).writeCapacityUnits(1L).build() + val request = CreateTableRequest.builder + .tableName(imageCollectionsTable) + .attributeDefinitions(attributeDefinitions.asJava) + .keySchema(keySchema.asJava) + .provisionedThroughput(provisionedThroughput) + .build() + dynamoClient.createTable(request).get() + } + + override def afterAll(): Unit = { + super.afterAll() + dynamoContainer.stop() + } + + describe("ImageCollectionsStore") { + val imageId = "test-image-id" + val collection1 = Collection(List("a", "b"), ActionData("author", DateTime.now()), "description 1") + val collection2 = Collection(List("c", "d"), ActionData("author", DateTime.now()), "description 2") + + it("should be able to add image to a collection") { + val eventualAddedResponse = store.add(imageId, collection1) + whenReady(eventualAddedResponse) { response => + response.size should be(1) + response.head.pathId should be("a/b") + } + + // Read back + val eventuallyReloaded = store.get(imageId) + whenReady(eventuallyReloaded) { collections => + collections.size should be(1) + collections.head.pathId should be("a/b") + } + + // Add another collection + whenReady(store.add(imageId, collection2)) { collections => + collections.size should be(2) + collections.map(_.pathId) should contain allOf("a/b", "c/d") + } + + whenReady(store.get(imageId)) { collections => + collections.size should be(2) + collections.head.pathId should be("a/b") + collections.last.pathId should be("c/d") + } + + // Update to replace all for this image + val newCollection = Collection(List("e", "f"), ActionData("new-author", DateTime.now()), "new description") + val eventuallyUpdated = store.update(imageId, List(newCollection)) + whenReady(eventuallyUpdated) { collections => + collections.size should be(1) + collections.head.pathId should be("e/f") + } + + whenReady(store.get(imageId)) { collections => + collections.size should be(1) + collections.head.pathId should be("e/f") + } + } + } +} diff --git a/common-lib/src/main/resources/logback.xml b/common-lib/src/main/resources/logback.xml index 733a850c110..47e666377ff 100644 --- a/common-lib/src/main/resources/logback.xml +++ b/common-lib/src/main/resources/logback.xml @@ -2,30 +2,6 @@ - - - - - - - - - - - ${LOGS_LOCATION}/application.log - - - ${LOGS_LOCATION}/application.log.%d{yyyy-MM-dd}.%i.gz - 100MB - 7 - 500MB - - - - %date - [%level] - from %logger in %thread markers=%marker %n%message%n%xException%n - - - @@ -39,12 +15,7 @@ - - - - - - + diff --git a/common-lib/src/main/scala/com/gu/mediaservice/GridClient.scala b/common-lib/src/main/scala/com/gu/mediaservice/GridClient.scala index 58373d23335..6192c5dac04 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/GridClient.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/GridClient.scala @@ -3,14 +3,14 @@ package com.gu.mediaservice import java.net.URL import com.gu.mediaservice.GridClient.{Error, Found, NotFound, Response} import com.gu.mediaservice.lib.config.Services -import com.gu.mediaservice.model.{Collection, Crop, Edits, Image, ImageMetadata, ImageStatusRecord, SyndicationRights} +import com.gu.mediaservice.model.{Collection, Crop, Edits, Image, ImageMetadata, ImageStatusRecord, Instance, SourceImage, SyndicationRights} import com.gu.mediaservice.model.leases.LeasesByMedia import com.gu.mediaservice.model.usage.Usage import com.typesafe.scalalogging.LazyLogging import play.api.http.HeaderNames -import play.api.libs.json.{JsArray, JsObject, JsValue, Json, Reads} +import play.api.libs.json.{JsArray, JsObject, JsString, JsValue, Json, Reads} -import scala.concurrent.duration.{Duration, DurationInt} +import scala.concurrent.duration.{Duration, DurationInt, SECONDS} import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success, Try} import play.api.libs.ws.{WSClient, WSRequest, WSResponse} @@ -104,12 +104,13 @@ class GridClient(services: Services)(implicit wsClient: WSClient) extends LazyLo * process before returning data. * See also https://www.playframework.com/documentation/2.6.x/ScalaWS#Configuring-Timeouts */ - def makeGetRequestAsync(url: URL, authFn: WSRequest => WSRequest, requestTimeout: Option[Duration] = None) + def makeGetRequestAsync(url: URL, authFn: WSRequest => WSRequest, requestTimeout: Option[Duration] = None, + queryStringParameters: Option[Seq[(String, String)]] = None) (implicit ec: ExecutionContext): Future[Response] = { - val request: WSRequest = wsClient.url(url.toString) + val request: WSRequest = wsClient.url(url.toString).withQueryStringParameters(queryStringParameters.getOrElse(Seq.empty): _*) val requestWithTimeout = requestTimeout.fold(request)(request.withRequestTimeout) val authorisedRequest = authFn(requestWithTimeout) - authorisedRequest.get().map { response => validateResponse(response, url)} + authorisedRequest.get().map { response => validateResponse(response, url) } } private def validateResponse( @@ -133,8 +134,8 @@ class GridClient(services: Services)(implicit wsClient: WSClient) extends LazyLo } } - def getProjectionDiff(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[Option[JsValue]] = { - val url = new URL(s"${services.apiBaseUri}/images/$mediaId/projection/diff") + def getProjectionDiff(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[Option[JsValue]] = { + val url = new URL(s"${services.apiBaseUri(instance)}/images/$mediaId/projection/diff") makeGetRequestAsync(url, authFn, requestTimeout = Some(120.seconds)).map { case Found(json, _) => Some(json) case NotFound(_, _) => None @@ -143,14 +144,15 @@ class GridClient(services: Services)(implicit wsClient: WSClient) extends LazyLo } def getImageLoaderProjection(mediaId: String, authFn: WSRequest => WSRequest) - (implicit ec: ExecutionContext): Future[Option[Image]] = { - getImageLoaderProjection(mediaId, services.projectionBaseUri, authFn) + (implicit ec: ExecutionContext, instance: Instance): Future[Option[Image]] = { + getImageLoaderProjection(mediaId, services.projectionBaseUri(instance), authFn) } def getImageLoaderProjection(mediaId: String, imageLoaderEndpoint: String, authFn: WSRequest => WSRequest) (implicit ec: ExecutionContext): Future[Option[Image]] = { - logger.info("attempt to get image projection from image-loader") - val url = new URL(s"$imageLoaderEndpoint/images/project/$mediaId") + val projectUrl = s"$imageLoaderEndpoint/images/project/$mediaId" + logger.info(s"attempt to get image projection from image-loader: $projectUrl") + val url = new URL(projectUrl) makeGetRequestAsync(url, authFn, requestTimeout = Some(300.seconds)) map { case Found(json, _) => Some(json.as[Image]) case NotFound(_, _) => None @@ -158,9 +160,9 @@ class GridClient(services: Services)(implicit wsClient: WSClient) extends LazyLo } } - def getLeases(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[LeasesByMedia] = { + def getLeases(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[LeasesByMedia] = { logger.info("attempt to get leases") - val url = new URL(s"${services.leasesBaseUri}/leases/media/$mediaId") + val url = new URL(s"${services.leasesBaseUri(instance)}/leases/media/$mediaId") makeGetRequestAsync(url, authFn) map { case Found(json, _) => (json \ "data").as[LeasesByMedia] case NotFound(_, _) => LeasesByMedia.empty @@ -168,9 +170,9 @@ class GridClient(services: Services)(implicit wsClient: WSClient) extends LazyLo } } - def getCollections(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[List[Collection]] = { + def getCollections(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[List[Collection]] = { logger.info("attempt to get collections") - val url = new URL(s"${services.collectionsBaseUri}/images/$mediaId") + val url = new URL(s"${services.collectionsBaseUri(instance)}/images/$mediaId") makeGetRequestAsync(url, authFn) map { case Found(json, _) => (json \ "data").as[List[Collection]] case NotFound(_, _) => Nil @@ -178,9 +180,24 @@ class GridClient(services: Services)(implicit wsClient: WSClient) extends LazyLo } } - def getEdits(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[Option[Edits]] = { + def createCollection(name: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[Option[Collection]] = { + val url = new URL(s"${services.collectionsBaseUri(instance)}/collections") + val request = wsClient.url(url.toString).withRequestTimeout(Duration(10, SECONDS)) + val authorisedRequest = authFn(request) + val data = Json.obj("data" -> JsString(name)) + authorisedRequest.post(data).map { response => + logger.info("Got new collection response: " + response.body) + validateResponse(response, url) match { + case Found(json, _) => (json \ "data" \ "data").toOption.map(_.as[Collection]) + case NotFound(_, _) => None + case e@Error(_, _, _) => e.logErrorAndThrowException() + } + } + } + + def getEdits(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[Option[Edits]] = { logger.info("attempt to get edits") - val url = new URL(s"${services.metadataBaseUri}/edits/$mediaId") + val url = new URL(s"${services.metadataBaseUri(instance)}/edits/$mediaId") makeGetRequestAsync(url, authFn) map { case Found(json, _) => Some((json \ "data").as[Edits]) case NotFound(_, _) => None @@ -188,9 +205,9 @@ class GridClient(services: Services)(implicit wsClient: WSClient) extends LazyLo } } - def getSoftDeletedMetadata(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[Option[ImageStatusRecord]] = { + def getSoftDeletedMetadata(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[Option[ImageStatusRecord]] = { logger.info("attempt to get soft deleted metadata") - val url = new URL(s"${services.apiBaseUri}/images/$mediaId/softDeletedMetadata") + val url = new URL(s"${services.apiBaseUri(instance)}/images/$mediaId/softDeletedMetadata") makeGetRequestAsync(url, authFn) map { case Found(json, _) => Some((json \ "data").as[ImageStatusRecord]) case NotFound(_, _) => None @@ -198,9 +215,9 @@ class GridClient(services: Services)(implicit wsClient: WSClient) extends LazyLo } } - def getUploadedBy(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[Option[String]] = { + def getUploadedBy(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[Option[String]] = { logger.info("attempt to get uploadedBy") - val url = new URL(s"${services.apiBaseUri}/images/$mediaId/uploadedBy") + val url = new URL(s"${services.apiBaseUri(instance)}/images/$mediaId/uploadedBy") makeGetRequestAsync(url, authFn) map { case Found(json, _) => Some((json \ "data").as[String]) case NotFound(_, _) => None @@ -208,9 +225,9 @@ class GridClient(services: Services)(implicit wsClient: WSClient) extends LazyLo } } - def getCrops(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[List[Crop]] = { + def getCrops(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[List[Crop]] = { logger.info("attempt to get crops") - val url = new URL(s"${services.cropperBaseUri}/crops/$mediaId") + val url = new URL(s"${services.cropperBaseUri(instance)}/crops/$mediaId") makeGetRequestAsync(url, authFn) map { case Found(json, _) => (json \ "data").as[List[Crop]] case NotFound(_, _) => Nil @@ -218,7 +235,7 @@ class GridClient(services: Services)(implicit wsClient: WSClient) extends LazyLo } } - def getUsages(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[List[Usage]] = { + def getUsages(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[List[Usage]] = { logger.info("attempt to get usages") def unpackUsagesFromEntityResponse(resBody: JsValue): List[JsValue] = { @@ -226,7 +243,7 @@ class GridClient(services: Services)(implicit wsClient: WSClient) extends LazyLo .map(entity => (entity.as[JsObject] \ "data").as[JsValue]).toList } - val url = new URL(s"${services.usageBaseUri}/usages/media/$mediaId") + val url = new URL(s"${services.usageBaseUri(instance)}/usages/media/$mediaId") makeGetRequestAsync(url, authFn) map { case Found(json, _) => unpackUsagesFromEntityResponse(json).map(_.as[Usage]) case NotFound(_, _) => Nil @@ -234,9 +251,19 @@ class GridClient(services: Services)(implicit wsClient: WSClient) extends LazyLo } } - def getMetadata(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext): Future[ImageMetadata] = { + def getSourceImage(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[SourceImage] = { + logger.info("attempt to get image") + val url = new URL(s"${services.apiBaseUri(instance)}/images/$mediaId") + makeGetRequestAsync(url, authFn, queryStringParameters = Some(Seq("include" -> "fileMetadata"))) map { + case Found(json, _) => json.as[SourceImage] + case nf@NotFound(_, _) => Error(nf.status, url, nf.underlying).logErrorAndThrowException() + case e@Error(_, _, _) => e.logErrorAndThrowException() + } + } + + def getMetadata(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance): Future[ImageMetadata] = { logger.info("attempt to get metadata") - val url = new URL(s"${services.apiBaseUri}/images/$mediaId") + val url = new URL(s"${services.apiBaseUri(instance)}/images/$mediaId") makeGetRequestAsync(url, authFn) map { case Found(json, _) => (json \ "data" \ "metadata").as[ImageMetadata] case nf@NotFound(_, _) => Error(nf.status, url, nf.underlying).logErrorAndThrowException() @@ -244,8 +271,8 @@ class GridClient(services: Services)(implicit wsClient: WSClient) extends LazyLo } } - def getSyndicationRights(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext) = { - val url = new URL(s"${services.metadataBaseUri}/metadata/$mediaId/syndication") + def getSyndicationRights(mediaId: String, authFn: WSRequest => WSRequest)(implicit ec: ExecutionContext, instance: Instance) = { + val url = new URL(s"${services.metadataBaseUri(instance)}/metadata/$mediaId/syndication") makeGetRequestAsync(url, authFn) map { case Found(json, _) => Some((json \ "data").as[SyndicationRights]) case _: NotFound => None diff --git a/common-lib/src/main/scala/com/gu/mediaservice/ImageDataMerger.scala b/common-lib/src/main/scala/com/gu/mediaservice/ImageDataMerger.scala index 463c94500ea..ca9f75fd8ca 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/ImageDataMerger.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/ImageDataMerger.scala @@ -4,11 +4,12 @@ import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker} import com.gu.mediaservice.model._ import org.joda.time.DateTime import play.api.libs.ws.WSRequest +import play.api.mvc.RequestHeader import scala.concurrent.{ExecutionContext, Future} object ImageDataMerger extends GridLogging { - def aggregate(image: Image, gridClient: GridClient, authFunction: WSRequest => WSRequest)(implicit ec: ExecutionContext, logMarker: LogMarker): Future[Image] = { + def aggregate(image: Image, gridClient: GridClient, authFunction: WSRequest => WSRequest)(implicit ec: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[Image] = { logger.info(logMarker, s"starting to aggregate image") val mediaId = image.id // NB original metadata should already be added, cleaned, and copied to metadata. diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/BaseStore.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/BaseStore.scala index 0aeb698cccb..5a0f2eb36d4 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/BaseStore.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/BaseStore.scala @@ -1,7 +1,7 @@ package com.gu.mediaservice.lib import org.apache.pekko.actor.{Cancellable, Scheduler} -import com.gu.mediaservice.lib.aws.S3 +import com.gu.mediaservice.lib.aws.{S3, S3Bucket} import com.gu.mediaservice.lib.config.CommonConfig import com.gu.mediaservice.lib.logging.GridLogging import org.joda.time.DateTime @@ -14,7 +14,7 @@ import scala.concurrent.duration._ import scala.util.control.NonFatal -abstract class BaseStore[TStoreKey, TStoreVal](bucket: String, config: CommonConfig)(implicit ec: ExecutionContext) +abstract class BaseStore[TStoreKey, TStoreVal](bucket: S3Bucket, config: CommonConfig)(implicit ec: ExecutionContext) extends GridLogging { val s3 = new S3(config) @@ -25,15 +25,14 @@ abstract class BaseStore[TStoreKey, TStoreVal](bucket: String, config: CommonCon protected def getS3Object(key: String): Option[String] = s3.getObjectAsString(bucket, key) protected def getLatestS3Stream: Option[InputStream] = { - val objects = s3.client - .listObjects(bucket).getObjectSummaries.asScala + val objects = s3.listObjects(bucket).getObjectSummaries.asScala .filterNot(_.getKey == "AMAZON_SES_SETUP_NOTIFICATION") if (objects.nonEmpty) { val obj = objects.maxBy(_.getLastModified) logger.info(s"Latest key ${obj.getKey} in bucket $bucket") - val stream = s3.client.getObject(bucket, obj.getKey).getObjectContent + val stream = s3.getObject(bucket, obj).getObjectContent Some(stream) } else { logger.error(s"Bucket $bucket is empty") diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageId.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageId.scala index 9ec8633e275..022eacf212f 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageId.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageId.scala @@ -1,12 +1,19 @@ package com.gu.mediaservice.lib import _root_.play.api.libs.json._ +import com.gu.mediaservice.lib.logging.GridLogging -trait ImageId { +trait ImageId extends GridLogging { - def withImageId[A](image: JsValue)(f: String => A): A = { - (image \ "id").validate[String].asOpt.map(f).getOrElse { - sys.error(s"No id field present in message body: $image") + def withImageIdAndInstance[A](image: JsValue)(f: (String, String) => A): A = { + (for { + id <- (image \ "id").validate[String].asOpt + instance <- (image \ "instance").validate[String].asOpt + } yield { + (id, instance) + }).map((a: (String, String)) => f(a._1, a._2)) + .getOrElse { + sys.error(s"No id and/or instance field present in message body: $image") } } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageIngestOperations.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageIngestOperations.scala index 0fdd57bb676..c158008cfdd 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageIngestOperations.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageIngestOperations.scala @@ -1,25 +1,28 @@ package com.gu.mediaservice.lib -import com.amazonaws.services.s3.model.{DeleteObjectsRequest, MultiObjectDeleteException} +import com.amazonaws.services.s3.model.MultiObjectDeleteException import java.io.File import com.gu.mediaservice.lib.config.CommonConfig -import com.gu.mediaservice.lib.aws.S3Object +import com.gu.mediaservice.lib.aws.{S3Bucket, S3Object} import com.gu.mediaservice.lib.logging.LogMarker -import com.gu.mediaservice.model.{MimeType, Png} +import com.gu.mediaservice.model.{Instance, MimeType, Png} +import com.typesafe.scalalogging.StrictLogging import org.joda.time.DateTime import scala.concurrent.Future import scala.jdk.CollectionConverters._ object ImageIngestOperations { - def fileKeyFromId(id: String): String = id.take(6).mkString("/") + "/" + id + def fileKeyFromId(id: String)(implicit instance: Instance): String = instance.id + "/" + snippetForId(id) - def optimisedPngKeyFromId(id: String): String = "optimised/" + fileKeyFromId(id: String) + def optimisedPngKeyFromId(id: String)(implicit instance: Instance): String = instance.id + "/" + "optimised/" + snippetForId(id: String) + + private def snippetForId(id: String) = id.take(6).mkString("/") + "/" + id } -class ImageIngestOperations(imageBucket: String, thumbnailBucket: String, config: CommonConfig, isVersionedS3: Boolean = false) - extends S3ImageStorage(config) { +class ImageIngestOperations(imageBucket: S3Bucket, thumbnailBucket: S3Bucket, config: CommonConfig, isVersionedS3: Boolean = false) + extends S3ImageStorage(config) with StrictLogging { import ImageIngestOperations.{fileKeyFromId, optimisedPngKeyFromId} @@ -31,50 +34,88 @@ class ImageIngestOperations(imageBucket: String, thumbnailBucket: String, config } private def storeOriginalImage(storableImage: StorableOriginalImage) - (implicit logMarker: LogMarker): Future[S3Object] = - storeImage(imageBucket, fileKeyFromId(storableImage.id), storableImage.file, Some(storableImage.mimeType), + (implicit logMarker: LogMarker): Future[S3Object] = { + val instanceSpecificKey = instanceAwareOriginalImageKey(storableImage) + logger.info(s"Storing original image to instance specific key:$imageBucket / $instanceSpecificKey") + storeImage(imageBucket, instanceSpecificKey, storableImage.file, Some(storableImage.mimeType), storableImage.meta, overwrite = false) + } private def storeThumbnailImage(storableImage: StorableThumbImage) - (implicit logMarker: LogMarker): Future[S3Object] = - storeImage(thumbnailBucket, fileKeyFromId(storableImage.id), storableImage.file, Some(storableImage.mimeType), + (implicit logMarker: LogMarker): Future[S3Object] = { + val instanceSpecificKey = instanceAwareThumbnailImageKey(storableImage) + logger.info(s"Storing thumbnail to instance specific key: ${thumbnailBucket.bucket} / $instanceSpecificKey") + storeImage(thumbnailBucket, instanceSpecificKey, storableImage.file, Some(storableImage.mimeType), overwrite = true) + } private def storeOptimisedImage(storableImage: StorableOptimisedImage) - (implicit logMarker: LogMarker): Future[S3Object] = - storeImage(imageBucket, optimisedPngKeyFromId(storableImage.id), storableImage.file, Some(storableImage.mimeType), + (implicit logMarker: LogMarker): Future[S3Object] = { + val instanceSpecificKey = optimisedPngKeyFromId(storableImage.id)(storableImage.instance) + logger.info(s"Storing optimised image to instance specific key: $thumbnailBucket / $instanceSpecificKey") + storeImage(imageBucket, instanceSpecificKey, storableImage.file, Some(storableImage.mimeType), overwrite = true) + } - private def bulkDelete(bucket: String, keys: List[String]): Future[Map[String, Boolean]] = keys match { + private def bulkDelete(bucket: S3Bucket, keys: List[String]): Future[Map[String, Boolean]] = keys match { case Nil => Future.successful(Map.empty) - case _ => Future { - try { - client.deleteObjects( - new DeleteObjectsRequest(bucket).withKeys(keys: _*) - ) - keys.map { key => - key -> true - }.toMap - } catch { - case partialFailure: MultiObjectDeleteException => - logger.warn(s"Partial failure when deleting images from $bucket: ${partialFailure.getMessage} ${partialFailure.getErrors}") - val errorKeys = partialFailure.getErrors.asScala.map(_.getKey).toSet + case _ => + val bulkDeleteImplemented = bucket.endpoint != "storage.googleapis.com" + if (bulkDeleteImplemented) { + Future { + try { + logger.info(s"Bulk deleting S3 objects from ${bucket.bucket}: " + keys.mkString(",")) + deleteObjects(bucket, keys) + keys.map { key => + key -> true + }.toMap + } catch { + case partialFailure: MultiObjectDeleteException => + logger.warn(s"Partial failure when deleting images from $bucket: ${partialFailure.getMessage} ${partialFailure.getErrors}") + val errorKeys = partialFailure.getErrors.asScala.map(_.getKey).toSet + keys.map { key => + key -> !errorKeys.contains(key) + }.toMap + } + } + + } else { + Future.sequence { keys.map { key => - key -> !errorKeys.contains(key) - }.toMap + Future { + logger.info(s"Deleting S3 objects from ${bucket.bucket}: " + key) + try { + deleteObject(bucket, key) + (key, true) + } catch { + case e: Exception => + logger.debug(s"Failure when deleting images from $bucket: $key, ${e.getMessage}") + (key, false) + } + } + } + }.map(_.toMap) } - } } - def deleteOriginal(id: String)(implicit logMarker: LogMarker): Future[Unit] = if(isVersionedS3) deleteVersionedImage(imageBucket, fileKeyFromId(id)) else deleteImage(imageBucket, fileKeyFromId(id)) - def deleteOriginals(ids: Set[String]) = bulkDelete(imageBucket, ids.map(fileKeyFromId).toList) - def deleteThumbnail(id: String)(implicit logMarker: LogMarker): Future[Unit] = deleteImage(thumbnailBucket, fileKeyFromId(id)) - def deleteThumbnails(ids: Set[String]) = bulkDelete(thumbnailBucket, ids.map(fileKeyFromId).toList) - def deletePNG(id: String)(implicit logMarker: LogMarker): Future[Unit] = deleteImage(imageBucket, optimisedPngKeyFromId(id)) - def deletePNGs(ids: Set[String]) = bulkDelete(imageBucket, ids.map(optimisedPngKeyFromId).toList) + def deleteOriginal(id: String)(implicit logMarker: LogMarker, instance: Instance): Future[Unit] = if(isVersionedS3) deleteVersionedImage(imageBucket, fileKeyFromId(id)) else deleteImage(imageBucket, fileKeyFromId(id)) + def deleteOriginals(ids: Set[String])(implicit instance: Instance) = bulkDelete(imageBucket, ids.map(id => fileKeyFromId(id)).toList) + def deleteThumbnail(id: String)(implicit logMarker: LogMarker, instance: Instance): Future[Unit] = deleteImage(thumbnailBucket, fileKeyFromId(id)) + def deleteThumbnails(ids: Set[String])(implicit instance: Instance) = bulkDelete(thumbnailBucket, ids.map(id => fileKeyFromId(id)).toList) + def deletePNG(id: String)(implicit logMarker: LogMarker, instance: Instance): Future[Unit] = deleteImage(imageBucket, optimisedPngKeyFromId(id)) + def deletePNGs(ids: Set[String])(implicit instance: Instance) = bulkDelete(imageBucket, ids.map(id => optimisedPngKeyFromId(id)).toList) + + def doesOriginalExist(id: String)(implicit instance: Instance): Boolean = + doesObjectExist(imageBucket, fileKeyFromId(id)) + + private def instanceAwareOriginalImageKey(storableImage: StorableOriginalImage) = { + fileKeyFromId(storableImage.id)(storableImage.instance) + } + + private def instanceAwareThumbnailImageKey(storableImage: StorableThumbImage) = { + fileKeyFromId(storableImage.id)(storableImage.instance) + } - def doesOriginalExist(id: String): Boolean = - client.doesObjectExist(imageBucket, fileKeyFromId(id)) } sealed trait ImageWrapper { @@ -82,11 +123,12 @@ sealed trait ImageWrapper { val file: File val mimeType: MimeType val meta: Map[String, String] + val instance: Instance } sealed trait StorableImage extends ImageWrapper { - def toProjectedS3Object(thumbBucket: String): S3Object = S3Object( + def toProjectedS3Object(thumbBucket: S3Bucket): S3Object = S3Object( thumbBucket, - ImageIngestOperations.fileKeyFromId(id), + ImageIngestOperations.fileKeyFromId(id)(instance), file, Some(mimeType), lastModified = None, @@ -94,21 +136,21 @@ sealed trait StorableImage extends ImageWrapper { ) } -case class StorableThumbImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty) extends StorableImage -case class StorableOriginalImage(id: String, file: File, mimeType: MimeType, lastModified: DateTime, meta: Map[String, String] = Map.empty) extends StorableImage { - override def toProjectedS3Object(thumbBucket: String): S3Object = S3Object( +case class StorableThumbImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty, instance: Instance) extends StorableImage +case class StorableOriginalImage(id: String, file: File, mimeType: MimeType, lastModified: DateTime, meta: Map[String, String] = Map.empty, instance: Instance) extends StorableImage { + override def toProjectedS3Object(thumbBucket: S3Bucket): S3Object = S3Object( thumbBucket, - ImageIngestOperations.fileKeyFromId(id), + ImageIngestOperations.fileKeyFromId(id)(instance), file, Some(mimeType), lastModified = Some(lastModified), meta ) } -case class StorableOptimisedImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty) extends StorableImage { - override def toProjectedS3Object(thumbBucket: String): S3Object = S3Object( +case class StorableOptimisedImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty, instance: Instance) extends StorableImage { + override def toProjectedS3Object(thumbBucket: S3Bucket): S3Object = S3Object( thumbBucket, - ImageIngestOperations.optimisedPngKeyFromId(id), + ImageIngestOperations.optimisedPngKeyFromId(id)(instance), file, Some(mimeType), lastModified = None, @@ -126,8 +168,8 @@ case class StorableOptimisedImage(id: String, file: File, mimeType: MimeType, me * Can be used in order to skip e.g. the stripping of incorrect colour profiles, * as in this case we have already inferred the profile upstream. */ -case class BrowserViewableImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty, isTransformedFromSource: Boolean = false) extends ImageWrapper { - def asStorableOptimisedImage = StorableOptimisedImage(id, file, mimeType, meta) - def asStorableThumbImage = StorableThumbImage(id, file, mimeType, meta) +case class BrowserViewableImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty, isTransformedFromSource: Boolean = false, instance: Instance) extends ImageWrapper { + def asStorableOptimisedImage = StorableOptimisedImage(id, file, mimeType, meta, instance) + def asStorableThumbImage = StorableThumbImage(id, file, mimeType, meta, instance) } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageQuarantineOperations.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageQuarantineOperations.scala index 9de53d94905..eaf6b82979c 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageQuarantineOperations.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageQuarantineOperations.scala @@ -1,19 +1,18 @@ package com.gu.mediaservice.lib import java.io.File - import com.gu.mediaservice.lib.config.CommonConfig -import com.gu.mediaservice.lib.aws.S3Object +import com.gu.mediaservice.lib.aws.{S3Bucket, S3Object} import com.gu.mediaservice.lib.logging.LogMarker -import com.gu.mediaservice.model.MimeType +import com.gu.mediaservice.model.{Instance, MimeType} import scala.concurrent.Future -class ImageQuarantineOperations(quarantineBucket: String, config: CommonConfig, isVersionedS3: Boolean = false) +class ImageQuarantineOperations(quarantineBucket: S3Bucket, config: CommonConfig, isVersionedS3: Boolean = false) extends S3ImageStorage(config) { def storeQuarantineImage(id: String, file: File, mimeType: Option[MimeType], meta: Map[String, String] = Map.empty) - (implicit logMarker: LogMarker): Future[S3Object] = + (implicit logMarker: LogMarker, instance: Instance): Future[S3Object] = storeImage(quarantineBucket, ImageIngestOperations.fileKeyFromId(id), file, mimeType, meta, overwrite = true) } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageStorage.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageStorage.scala index a0dbc5fd19a..a6c45c346cd 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageStorage.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/ImageStorage.scala @@ -2,11 +2,10 @@ package com.gu.mediaservice.lib import java.util.concurrent.Executors import java.io.File - import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.duration._ import scala.language.postfixOps -import com.gu.mediaservice.lib.aws.S3Object +import com.gu.mediaservice.lib.aws.{S3Bucket, S3Object} import com.gu.mediaservice.lib.logging.LogMarker import com.gu.mediaservice.model.MimeType @@ -17,6 +16,7 @@ object ImageStorageProps { val uploadTimeMetadataKey: String = "upload-time" val uploadedByMetadataKey: String = "uploaded-by" val identifierMetadataKeyPrefix: String = "identifier!" + val isFeedUploadMetadataKey = "is-feed-upload" } trait ImageStorage { @@ -34,9 +34,9 @@ trait ImageStorage { /** Store a copy of the given file and return the URI of that copy. * The file can safely be deleted afterwards. */ - def storeImage(bucket: String, id: String, file: File, mimeType: Option[MimeType], + def storeImage(bucket: S3Bucket, id: String, file: File, mimeType: Option[MimeType], meta: Map[String, String] = Map.empty, overwrite: Boolean) (implicit logMarker: LogMarker): Future[S3Object] - def deleteImage(bucket: String, id: String)(implicit logMarker: LogMarker): Future[Unit] + def deleteImage(bucket: S3Bucket, id: String)(implicit logMarker: LogMarker): Future[Unit] } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/S3ImageStorage.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/S3ImageStorage.scala index 7e164f876bb..a4d603f1a68 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/S3ImageStorage.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/S3ImageStorage.scala @@ -1,6 +1,6 @@ package com.gu.mediaservice.lib -import com.gu.mediaservice.lib.aws.S3 +import com.gu.mediaservice.lib.aws.{S3, S3Bucket} import com.gu.mediaservice.lib.config.CommonConfig import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker} import com.gu.mediaservice.model.MimeType @@ -14,10 +14,10 @@ import scala.concurrent.Future class S3ImageStorage(config: CommonConfig) extends S3(config) with ImageStorage with GridLogging { private val cacheSetting = Some(cacheForever) - def storeImage(bucket: String, id: String, file: File, mimeType: Option[MimeType], + def storeImage(bucket: S3Bucket, id: String, file: File, mimeType: Option[MimeType], meta: Map[String, String] = Map.empty, overwrite: Boolean) (implicit logMarker: LogMarker) = { - logger.info(logMarker, s"bucket: $bucket, id: $id, meta: $meta") + logger.info(logMarker, s"storeImage to bucket: ${bucket.bucket}, id: $id, meta: $meta") val eventualObject = if (overwrite) { store(bucket, id, file, mimeType, meta, cacheSetting) } else { @@ -27,20 +27,21 @@ class S3ImageStorage(config: CommonConfig) extends S3(config) with ImageStorage eventualObject } - def deleteImage(bucket: String, id: String)(implicit logMarker: LogMarker) = Future { - client.deleteObject(bucket, id) + def deleteImage(bucket: S3Bucket, id: String)(implicit logMarker: LogMarker) = Future { + deleteObject(bucket, id) logger.info(logMarker, s"Deleted image $id from bucket $bucket") } - def deleteVersionedImage(bucket: String, id: String)(implicit logMarker: LogMarker) = Future { - val objectVersion = client.getObjectMetadata(bucket, id).getVersionId - client.deleteVersion(bucket, id, objectVersion) + def deleteVersionedImage(bucket: S3Bucket, id: String)(implicit logMarker: LogMarker) = Future { + val objectVersion = getObjectMetadata(bucket, id).getVersionId + deleteVersion(bucket, id, objectVersion) logger.info(logMarker, s"Deleted image $id from bucket $bucket (version: $objectVersion)") } - def deleteFolder(bucket: String, id: String)(implicit logMarker: LogMarker) = Future { - val files = client.listObjects(bucket, id).getObjectSummaries.asScala - files.foreach(file => client.deleteObject(bucket, file.getKey)) + def deleteFolder(bucket: S3Bucket, id: String)(implicit logMarker: LogMarker) = Future { + val files = listObjects(bucket, id).getObjectSummaries.asScala + logger.info(s"Found ${files.size} files to delete in folder $id") + files.foreach(file => deleteObject(bucket, file.getKey)) logger.info(logMarker, s"Deleting images in folder $id from bucket $bucket") } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/argo/ArgoHelpers.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/argo/ArgoHelpers.scala index 1cba74dbbd4..988ff4a327a 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/argo/ArgoHelpers.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/argo/ArgoHelpers.scala @@ -1,7 +1,7 @@ package com.gu.mediaservice.lib.argo import java.net.URI -import play.api.libs.json.{Json, Writes} +import play.api.libs.json.{JsObject, Json, Writes} import play.api.mvc.{Cookie, Result, Results} import com.gu.mediaservice.lib.argo.model._ import com.gu.mediaservice.lib.logging.GridLogging @@ -25,7 +25,7 @@ trait ArgoHelpers extends Results with GridLogging { serializeAndWrap(response, Ok) } - def respondCollection[T](data: Seq[T], offset: Option[Long] = None, total: Option[Long] = None, maybeOrgOwnedCount: Option[Long] = None, + def respondCollection[T](data: Seq[T], offset: Option[Long] = None, total: Option[Long] = None, maybeExtraCounts: Option[ExtraCounts] = None, links: List[Link] = Nil, uri: Option[URI] = None) (implicit writes: Writes[T]): Result = { val response = CollectionResponse( @@ -35,8 +35,8 @@ trait ArgoHelpers extends Results with GridLogging { total = total, data = data, links = links, - //FIXME using actions below is a hack to get the org owned count into the response (until such time as we move theseus into the grid, so client can safely parse the response) - actions = maybeOrgOwnedCount + //FIXME using actions below is a hack to get extra counts into the response (until such time as we move theseus into the grid, so client can safely parse the response) + actions = maybeExtraCounts ) serializeAndWrap(response, Ok) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/argo/model/CollectionResponse.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/argo/model/CollectionResponse.scala index 2b7ce449ac3..bb8af295b12 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/argo/model/CollectionResponse.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/argo/model/CollectionResponse.scala @@ -1,12 +1,27 @@ package com.gu.mediaservice.lib.argo.model import java.net.URI - import play.api.libs.json._ import play.api.libs.functional.syntax._ - import com.gu.mediaservice.lib.argo.WriteHelpers +import com.sksamuel.elastic4s.requests.searches.aggs.AbstractAggregation + +case class ExtraCountConfig( + searchClause: String, + backgroundColour: String, + maybeSubAggregation: Option[AbstractAggregation] = None +) +case class ExtraCount( + value: Long, + searchClause: String, + backgroundColour: String, + subCounts: Option[Map[String, Long]] = None +) + +case class ExtraCounts( + tickerCounts: Map[String, ExtraCount] +) case class CollectionResponse[T]( uri: Option[URI] = None, @@ -16,11 +31,14 @@ case class CollectionResponse[T]( data: Seq[T], links: List[Link] = List(), // FIXME: the 'theseus' library used on the client mandates a strict set of top level keys, so crow-barring something else into 'actions' here (https://github.com/argo-rest/theseus [last updated 2015] needs to move into grid repo, so we can update it for this use-case and others) - actions: Option[Long] = None + actions: Option[ExtraCounts] = None ) object CollectionResponse extends WriteHelpers { + implicit val extraCountWrites: Writes[ExtraCount] = Json.writes[ExtraCount] + implicit val extraCountsWrites: Writes[ExtraCounts] = Json.writes[ExtraCounts] + implicit def collectionResponseWrites[T: Writes]: Writes[CollectionResponse[T]] = ( (__ \ "uri").writeNullable[String].contramap((_: Option[URI]).map(_.toString)) ~ (__ \ "offset").writeNullable[Long] ~ @@ -28,7 +46,7 @@ object CollectionResponse extends WriteHelpers { (__ \ "total").writeNullable[Long] ~ (__ \ "data").write[Seq[T]] ~ (__ \ "links").writeNullable[List[Link]].contramap(someListOrNone[Link]) ~ - (__ \ "actions").writeNullable[Long] + (__ \ "actions").writeNullable[ExtraCounts] )(unlift(CollectionResponse.unapply[T])) } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/ApiAccessor.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/ApiAccessor.scala index 3e1924451d5..4d321e82ca4 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/ApiAccessor.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/ApiAccessor.scala @@ -2,6 +2,7 @@ package com.gu.mediaservice.lib.auth import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.config.Services +import com.gu.mediaservice.model.Instance import play.api.mvc.{RequestHeader, Result} sealed trait Tier @@ -26,9 +27,12 @@ object ApiAccessor extends ArgoHelpers { ApiAccessor(name, tier) } - def hasAccess(apiKey: ApiAccessor, request: RequestHeader, services: Services): Boolean = apiKey.tier match { + def hasAccess(apiKey: ApiAccessor, request: RequestHeader, services: Services)(implicit instance: Instance): Boolean = apiKey.tier match { case Internal => true case ReadOnly => request.method == "GET" - case Syndication => request.method == "GET" && request.host == services.apiHost && request.path.startsWith("/images") + case Syndication => { + val isMediaApiRequest = request.uri.startsWith(services.apiBaseUri(instance)) // TODO check this! + request.method == "GET" && isMediaApiRequest && request.path.startsWith("/images") + } } } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/KeyStore.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/KeyStore.scala index d3c90bc739e..ebc43bf09c2 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/KeyStore.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/KeyStore.scala @@ -1,24 +1,22 @@ package com.gu.mediaservice.lib.auth import com.gu.mediaservice.lib.BaseStore +import com.gu.mediaservice.lib.aws.S3Bucket import com.gu.mediaservice.lib.config.CommonConfig +import com.gu.mediaservice.model.Instance -import scala.jdk.CollectionConverters._ import scala.concurrent.ExecutionContext -class KeyStore(bucket: String, config: CommonConfig)(implicit ec: ExecutionContext) +class KeyStore(bucket: S3Bucket, config: CommonConfig)(implicit ec: ExecutionContext) extends BaseStore[String, ApiAccessor](bucket, config)(ec) { - def lookupIdentity(key: String): Option[ApiAccessor] = store.get().get(key) - - def findKey(prefix: String): Option[String] = s3.syncFindKey(bucket, prefix) + def lookupIdentity(key: String)(implicit instance: Instance): Option[ApiAccessor] = store.get().get(instance.id + "/" + key) def update(): Unit = { store.set(fetchAll) } private def fetchAll: Map[String, ApiAccessor] = { - val keys = s3.client.listObjects(bucket).getObjectSummaries.asScala.map(_.getKey) - keys.flatMap(k => getS3Object(k).map(k -> ApiAccessor(_))).toMap + s3.listObjectKeys(bucket).flatMap(k => getS3Object(k).map(k -> ApiAccessor(_))).toMap } } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/AwsClientV1BuilderUtils.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/AwsClientV1BuilderUtils.scala index cdd4ec04d92..6545cf09c94 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/AwsClientV1BuilderUtils.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/AwsClientV1BuilderUtils.scala @@ -1,7 +1,6 @@ package com.gu.mediaservice.lib.aws -import com.amazonaws.auth.profile.ProfileCredentialsProvider -import com.amazonaws.auth.{AWSCredentialsProvider, AWSCredentialsProviderChain, InstanceProfileCredentialsProvider} +import com.amazonaws.auth.{AWSCredentialsProvider, AWSCredentialsProviderChain, EnvironmentVariableCredentialsProvider} import com.amazonaws.client.builder.AwsClientBuilder import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration import com.gu.mediaservice.lib.logging.GridLogging @@ -13,8 +12,7 @@ trait AwsClientV1BuilderUtils extends GridLogging { def awsRegion: String = "eu-west-1" def awsCredentials: AWSCredentialsProvider = new AWSCredentialsProviderChain( - new ProfileCredentialsProvider("media-service"), - InstanceProfileCredentialsProvider.getInstance() + new EnvironmentVariableCredentialsProvider(), ) final def awsEndpointConfiguration: Option[EndpointConfiguration] = awsLocalEndpoint match { diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/AwsClientV2BuilderUtils.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/AwsClientV2BuilderUtils.scala index e73d8d7408a..2aa2f166af4 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/AwsClientV2BuilderUtils.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/AwsClientV2BuilderUtils.scala @@ -1,7 +1,7 @@ package com.gu.mediaservice.lib.aws import com.gu.mediaservice.lib.logging.GridLogging -import software.amazon.awssdk.auth.credentials.{AwsCredentialsProvider, DefaultCredentialsProvider} +import software.amazon.awssdk.auth.credentials.{AwsCredentialsProvider, EnvironmentVariableCredentialsProvider} import software.amazon.awssdk.awscore.client.builder.AwsClientBuilder import software.amazon.awssdk.regions.Region @@ -13,7 +13,7 @@ trait AwsClientV2BuilderUtils extends GridLogging { def awsRegionV2: Region = Region.EU_WEST_1 - def awsCredentialsV2: AwsCredentialsProvider = DefaultCredentialsProvider.builder().profileName("media-service").build() + def awsCredentialsV2: AwsCredentialsProvider = EnvironmentVariableCredentialsProvider.create() final def withAWSCredentialsV2[T, S <: AwsClientBuilder[S, T]](builder: AwsClientBuilder[S, T], localstackAware: Boolean = true, maybeRegionOverride: Option[Region] = None): S = { val credentialedBuilder = builder.credentialsProvider(awsCredentialsV2).region(maybeRegionOverride.getOrElse(awsRegionV2)) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Bedrock.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Bedrock.scala new file mode 100644 index 00000000000..0a2bbfbe960 --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Bedrock.scala @@ -0,0 +1,122 @@ +package com.gu.mediaservice.lib.aws + +import software.amazon.awssdk.services.bedrockruntime.model._ +import software.amazon.awssdk.services.bedrockruntime._ +import com.gu.mediaservice.lib.config.CommonConfig +import play.api.libs.json.Json +import software.amazon.awssdk.core.SdkBytes + +import java.net.URI +import com.gu.mediaservice.lib.logging.LogMarker +import play.api.libs.json.OFormat.oFormatFromReadsAndOWrites +import play.api.libs.json._ + +import scala.concurrent.{ExecutionContext, Future} + +sealed trait InputType { + def value: String +} +object InputType { + case object Image extends InputType { + val value = "image" + } + case object SearchDocument extends InputType { + val value = "search_document" + } +} + +object Bedrock { + private case class BedrockImageRequest( + input_type: String, + embedding_types: List[String], + images: List[String] + ) + private case class BedrockTextRequest( + input_type: String, + embedding_types: List[String], + texts: List[String] + ) + + private implicit val bedrockImageRequestFormat: OFormat[BedrockImageRequest] = Json.format[BedrockImageRequest] + private implicit val bedrockTextRequestFormat: OFormat[BedrockTextRequest] = Json.format[BedrockTextRequest] +} + +class Bedrock(config: CommonConfig) + extends AwsClientV2BuilderUtils { + + // TODO: figure out what the more usual pattern for turning off localstack behaviour is + override def awsLocalEndpointUri: Option[URI] = None + + override def isDev: Boolean = config.isDev + + val client: BedrockRuntimeClient = { + withAWSCredentialsV2(BedrockRuntimeClient.builder()) + .build() + } + + private def createRequestBody(inputType: InputType, inputData: List[String]): InvokeModelRequest = { + + val jsonBody = inputType match { + case InputType.Image => + val body = Bedrock.BedrockImageRequest( + input_type = inputType.value, + embedding_types = List("float"), + images = inputData + ) + Json.toJson(body).toString() + case InputType.SearchDocument => + val body = Bedrock.BedrockTextRequest( + input_type = inputType.value, + embedding_types = List("float"), + texts = inputData + ) + Json.toJson(body).toString() + } + + val request: InvokeModelRequest = { + InvokeModelRequest + .builder() + .accept("*/*") + .body(SdkBytes.fromUtf8String(jsonBody)) + .contentType("application/json") + .modelId("cohere.embed-english-v3") + .build() + } + request + } + + private def sendBedrockEmbeddingRequest(requestBody: InvokeModelRequest)( + implicit logMarker: LogMarker + ): InvokeModelResponse = { + try { + val response = client.invokeModel(requestBody) + logger.info( + logMarker, + s"Bedrock API call to create image embedding completed with status: ${response.sdkHttpResponse().statusCode()}" + ) + response + } + catch { + case e: Exception => + logger.error(logMarker, "Exception during Bedrock API call to create image embedding", e) + throw e + } + } + + def createEmbedding(inputType: InputType, inputData: String)(implicit ec: ExecutionContext, logMarker: LogMarker): Future[List[Float]] = { + val requestBody = createRequestBody(inputType, List(inputData)) + val bedrockFuture = Future { sendBedrockEmbeddingRequest(requestBody) } + bedrockFuture.map { response => + val responseBody = response.body().asUtf8String() + val json = Json.parse(responseBody) + // Extract the embedding array (first element since it's an array of arrays) + val embedding = (json \ "embeddings" \ "float")(0).as[List[Float]] + logger.info( + logMarker, + s"Successfully extracted image embedding. Vector size: ${embedding.size}" + ) + embedding + } + } + +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/ContentDisposition.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/ContentDisposition.scala index da2a73ad96a..796a1fae85d 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/ContentDisposition.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/ContentDisposition.scala @@ -19,14 +19,16 @@ trait ContentDisposition extends GridLogging { getContentDisposition(filename, fallbackLatin1Filename(image, extension)) } - def getContentDisposition(image: Image, crop: Crop, asset: Asset, shortenDownloadFilename: Boolean): String = { - val cropId: String = crop.id.map(id => s"($id)").getOrElse("") + def getContentDisposition(image: Image, crop: Crop, asset: Asset): String = { val extension: String = getExtension(image, asset) - val dimensions: String = asset.dimensions.map(dims => s"(${dims.width} x ${dims.height})").getOrElse("") - val filenameSuffix: String = s"(${image.id})$cropId$dimensions$extension" - val filename = getBaseFilename(image, filenameSuffix, shortenDownloadFilename) + val filename = image.uploadInfo.filename match { + case Some(filename) => filename + case _ => image.id + } - getContentDisposition(filename, fallbackLatin1Filename(image, extension)) + // Drop original file's extension and replace with the crops actual extension + val withCropsExtension = removeExtension(filename) + extension + getContentDisposition(withCropsExtension, fallbackLatin1Filename(image, extension)) } private def getExtension(image: Image, asset: Asset): String = asset.mimeType match { diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/DynamoDB.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/DynamoDB.scala index c7f5066da15..6b1c8a06304 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/DynamoDB.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/DynamoDB.scala @@ -1,382 +1,247 @@ package com.gu.mediaservice.lib.aws -import java.util - -import com.amazonaws.AmazonServiceException -import com.amazonaws.services.dynamodbv2.document.spec.{DeleteItemSpec, GetItemSpec, PutItemSpec, QuerySpec, UpdateItemSpec} -import com.amazonaws.services.dynamodbv2.document.utils.ValueMap -import com.amazonaws.services.dynamodbv2.document.{DynamoDB => AwsDynamoDB, _} -import com.amazonaws.services.dynamodbv2.model.{AttributeValue, KeysAndAttributes, ReturnValue} -import com.amazonaws.services.dynamodbv2.{AmazonDynamoDBAsync, AmazonDynamoDBAsyncClientBuilder} -import com.gu.mediaservice.lib.config.CommonConfig import com.gu.mediaservice.lib.logging.GridLogging import org.joda.time.DateTime import play.api.libs.json._ +import software.amazon.awssdk.enhanced.dynamodb._ +import software.amazon.awssdk.enhanced.dynamodb.document.EnhancedDocument +import software.amazon.awssdk.services.dynamodb.DynamoDbClient +import software.amazon.awssdk.services.dynamodb.model.{BatchGetItemRequest, QueryRequest, UpdateItemRequest, AttributeValue => AttributeValueV2, KeysAndAttributes => KeysAndAttributesV2, ReturnValue => ReturnValueV2} import scala.annotation.tailrec -import scala.jdk.CollectionConverters._ import scala.concurrent.{ExecutionContext, Future} +import scala.jdk.CollectionConverters._ object NoItemFound extends Throwable("item not found") /** * A lightweight wrapper around AWS dynamo SDK for undertaking various operations - * @param config Common grid config including AWS credentials + * @param client2 DynamoDbClient client * @param tableName the table name for this instance of the dynamoDB wrapper * @param lastModifiedKey if set to a string the wrapper will maintain a last modified with that name on any update * @tparam T The type of this table */ -class DynamoDB[T](config: CommonConfig, tableName: String, lastModifiedKey: Option[String] = None) extends GridLogging { - lazy val client: AmazonDynamoDBAsync = config.withAWSCredentials(AmazonDynamoDBAsyncClientBuilder.standard()).build() - lazy val dynamo = new AwsDynamoDB(client) - lazy val table: Table = dynamo.getTable(tableName) - - val IdKey = "id" - - def exists(id: String)(implicit ex: ExecutionContext): Future[Boolean] = Future { - table.getItem(new GetItemSpec().withPrimaryKey(IdKey, id)) - } map(Option(_).isDefined) - - def get(id: String) - (implicit ex: ExecutionContext): Future[JsObject] = Future { - table.getItem( - new GetItemSpec(). - withPrimaryKey(IdKey, id) - ) - } flatMap itemOrNotFound map asJsObject - - private def get(id: String, key: String) - (implicit ex: ExecutionContext): Future[Item] = Future { - table.getItem( - new GetItemSpec() - .withPrimaryKey(IdKey, id) - .withAttributesToGet(key) - ) - } flatMap itemOrNotFound +class DynamoDB[T](client2: DynamoDbClient, tableName: String, lastModifiedKey: Option[String] = None) extends GridLogging { + lazy val dynamo2: DynamoDbEnhancedClient = DynamoDbEnhancedClient.builder().dynamoDbClient(client2).build() + lazy val tableSchema = TableSchema.documentSchemaBuilder() + .addIndexPartitionKey(TableMetadata.primaryIndexName(), IdKey, AttributeValueType.S) + .attributeConverterProviders(AttributeConverterProvider.defaultProvider()) + .build() + lazy val table2 = dynamo2.table(tableName, tableSchema) + + private val IdKey = "id" + + private def itemKey(key: String) = Key.builder().partitionValue(key).build() + + def getV2(id: String)(implicit ex: ExecutionContext): Future[JsObject] = Future { + table2.getItem(itemKey(id)) + } flatMap docOrNotFound map asJsObject + + private def getV2(id: String, attribute: String)(implicit ex: ExecutionContext): Future[EnhancedDocument] = Future { + Option(table2.getItem(itemKey(id))).flatMap(doc => Option.when(doc.isPresent(attribute))(doc)) + } flatMap { + case Some(doc) => Future.successful(doc) + case None => Future.failed(NoItemFound) + } - private def itemOrNotFound(itemOrNull: Item): Future[Item] = { - Option(itemOrNull) match { - case Some(item) => Future.successful(item) + private def docOrNotFound(docOrNull: EnhancedDocument): Future[EnhancedDocument] = { + Option(docOrNull) match { + case Some(doc) => Future.successful(doc) case None => Future.failed(NoItemFound) } } - def removeKey(id: String, key: String) - (implicit ex: ExecutionContext): Future[JsObject] = - update( - id, - s"REMOVE $key" - ) - - def deleteItem(id: String)(implicit ex: ExecutionContext): Future[Unit] = Future { - table.deleteItem(new DeleteItemSpec().withPrimaryKey(IdKey, id)) + def removeKeyV2(id: String, key: String)(implicit ex: ExecutionContext) = Future{ + updateV2(id, DynamoDB.removeExpr(key, lastModifiedKey)) } - def booleanGet(id: String, key: String) - (implicit ex: ExecutionContext): Future[Option[Boolean]] = - // TODO: add Option to item as it can be null - get(id, key).map{ item => item.get(key) match { - case b: java.lang.Boolean => Some(b.booleanValue) - case _ => None - }} - - def booleanSet(id: String, key: String, value: Boolean) - (implicit ex: ExecutionContext): Future[JsObject] = - update( - id, - s"SET $key = :value", - new ValueMap().withBoolean(":value", value) - ) - - def booleanSetOrRemove(id: String, key: String, value: Boolean) - (implicit ex: ExecutionContext): Future[JsObject] = - if (value) booleanSet(id, key, value) - else removeKey(id, key) - - def stringSet(id: String, key: String, value: JsValue) - (implicit ex: ExecutionContext): Future[JsObject] = - update( - id, - s"SET $key = :value", - valueMapWithNullForEmptyString(Map(":value" -> value)) + def deleteItemV2(id: String)(implicit ex: ExecutionContext): Future[Unit] = Future { + table2.deleteItem( + Key.builder().partitionValue(id).build() ) + } + def booleanGetV2(id: String, key: String) + (implicit ex: ExecutionContext): Future[Boolean] = { + getV2(id, key).map(_.getBoolean(key).booleanValue()) + } - def stringListSet(id: String, keyValues: (String, JsValue)*) - (implicit ex: ExecutionContext): Future[JsObject] = { - val keyValueMap = keyValues.toMap - val expressionParts = keyValueMap.keys.map(key => s"$key = :$key") - val valueMap = keyValueMap.map {case (key, value) => (s":$key", value)} - update( + def booleanSetV2(id: String, key: String, value: Boolean) + (implicit ex: ExecutionContext): Future[JsObject] = Future { + updateV2( id, - expression = s"SET ${expressionParts.mkString(",")}", - valueMapWithNullForEmptyString(valueMap) + DynamoDB.setExpr(key, lastModifiedKey), + AttributeValueV2.fromBool(value) ) } - def setGet(id: String, key: String) - (implicit ex: ExecutionContext): Future[Set[String]] = - get(id, key).map{ item => Option(item.getStringSet(key)) match { - case Some(set) => set.asScala.toSet - case None => Set() - } - } - - def setAdd(id: String, key: String, value: String) - (implicit ex: ExecutionContext): Future[JsObject] = - update( - id, - s"ADD $key :value", - new ValueMap().withStringSet(":value", value) - ) + def booleanSetOrRemoveV2(id: String, key: String, value: Boolean) + (implicit ex: ExecutionContext): Future[JsObject] = + if (value) booleanSetV2(id, key, value) + else removeKeyV2(id, key) - def setAdd(id: String, key: String, value: List[String]) - (implicit ex: ExecutionContext): Future[JsObject] = - update( - id, - s"ADD $key :value", - new ValueMap().withStringSet(":value", value:_*) - ) + def stringSetV2(id: String, key: String, value: String)(implicit ex: ExecutionContext): Future[JsObject] = Future { + updateV2(id, DynamoDB.setExpr(key, lastModifiedKey), AttributeValueV2.fromS(value)) + } + def setGetV2(id: String, key: String) + (implicit ex: ExecutionContext): Future[Set[String]] = { + getV2(id, key).map(_.getStringSet(key).asScala.toSet) + } - def jsonGet(id: String, key: String) - (implicit ex: ExecutionContext): Future[JsValue] = - get(id, key).map(item => asJsObject(item)) + def setAddV2(id: String, key: String, value: List[String])(implicit ex: ExecutionContext): Future[JsObject] = Future { + updateV2(id, DynamoDB.addExpr(key, lastModifiedKey), AttributeValueV2.fromSs(value.asJava)) + } - def batchGet(ids: List[String], attributeKey: String) - (implicit ex: ExecutionContext, rjs: Reads[T]): Future[Map[String, T]] = { + def batchGetV2(ids: List[String], attributeKey: String) + (implicit ex: ExecutionContext, rjs: Reads[T]): Future[Map[String, T]] = { val keyChunkList = ids - .map(k => Map(IdKey -> new AttributeValue(k)).asJava) + .map(k => Map(IdKey -> AttributeValueV2.fromS(k)).asJava) .grouped(100) Future.traverse(keyChunkList) { keyChunk => { - val keysAndAttributes: KeysAndAttributes = new KeysAndAttributes().withKeys(keyChunk.asJava) - - @tailrec - def nextPageOfBatch(request: java.util.Map[String, KeysAndAttributes], acc: List[(String, T)]) - (implicit ex: ExecutionContext, rjs: Reads[T]): List[(String, T)] = { - if (request.isEmpty) acc - else { - logger.info(s"Fetching records for $request") - val response = client.batchGetItem(request) - val responses = response.getResponses - logger.info(s"Got responses of $responses") - val results = responses.get(tableName).asScala.toList - .flatMap(att => { - val attributes: util.Map[String, AnyRef] = ItemUtils.toSimpleMapValue(att) - logger.info(s"Obtained attributes of $attributes from response $att") - val json = asJsObject(Item.fromMap(attributes)) - val maybeT = (json \ attributeKey).asOpt[T] - logger.info(s"Obtained a T of $maybeT from json $json") - maybeT.map( - attributes.get(IdKey).toString -> _ - ) - }) - logger.info(s"Got $results for request") - nextPageOfBatch(response.getUnprocessedKeys, acc ::: results) + val keysAndAttributes: KeysAndAttributesV2 = KeysAndAttributesV2.builder().keys(keyChunk.asJava).build() + + @tailrec + def nextPageOfBatch(request: java.util.Map[String, KeysAndAttributesV2], acc: List[(String, T)]) + (implicit ex: ExecutionContext, rjs: Reads[T]): List[(String, T)] = { + if (request.isEmpty) acc + else { + logger.info(s"Fetching records for $request") + val response = client2.batchGetItem(BatchGetItemRequest.builder().requestItems(request).build()) + val responses = response.responses() + logger.info(s"Got responses of $responses") + val results = responses.get(tableName).asScala.toList + .flatMap(att => { + logger.info(s"Obtained attributes of $att from response") + val json = asJsObject(EnhancedDocument.fromAttributeValueMap(att)) + val maybeT = (json \ attributeKey).asOpt[T] + logger.info(s"Obtained a T of $maybeT from json $json") + maybeT.map( + att.get(IdKey).s() -> _ + ) + }) + logger.info(s"Got $results for request") + nextPageOfBatch(response.unprocessedKeys(), acc ::: results) + } } - } - Future { - nextPageOfBatch(Map(tableName -> keysAndAttributes).asJava, Nil).toMap + Future { + nextPageOfBatch(Map(tableName -> keysAndAttributes).asJava, Nil).toMap + } + } } - }} .map(chunkIterator => chunkIterator.fold(Map.empty)((acc, result) => acc ++ result)) } - // We cannot update, so make sure you send over the WHOLE document - def jsonAdd(id: String, key: String, value: Map[String, JsValue]) - (implicit ex: ExecutionContext): Future[JsObject] = - update( + def jsonAddV2(id: String, key: String, value: Map[String, JsValue]) + (implicit ex: ExecutionContext): Future[JsObject] = Future { + updateV2( id, - s"SET $key = :value", - new ValueMap().withMap(":value", valueMapWithNullForEmptyString(value)) + DynamoDB.setExpr(key, lastModifiedKey), + AttributeValueV2.fromM(value.view.mapValues(DynamoDB.jsonToAttributeValue).toMap.asJava) ) - - def setDelete(id: String, key: String, value: String) - (implicit ex: ExecutionContext): Future[JsObject] = - update( - id, - s"DELETE $key :value", - new ValueMap().withStringSet(":value", value) - ) - - def listGet(id: String, key: String) - (implicit ex: ExecutionContext, reads: Reads[T]): Future[List[T]] = { - - get(id, key) map { item => - Option(item.toJSON) match { - case Some(json) => (Json.parse(json) \ key).as[List[T]] - case None => Nil - } - } } - def listAdd(id: String, key: String, value: T) - (implicit ex: ExecutionContext, tjs: Writes[T], rjs: Reads[T]): Future[List[T]] = { - - // TODO: Deal with the case that we don't have JSON serialisers, for now we just fail. - val json = Json.toJson(value).as[JsObject] - val valueMap = DynamoDB.jsonToValueMap(json) - def append = - update( - id, s"SET $key = list_append($key, :value)", - new ValueMap().withList(":value", valueMap) - ) - - def create = - update( - id, s"SET $key = :value", - new ValueMap().withList(":value", valueMap) - ) - - // DynamoDB doesn't seem to have a way of saying create the list if it doesn't exist then - // append to it. So what we're saying here is: - // Append to the list => if it doesn't exist => create it with the initial value. - append.map(j => (j \ key).as[List[T]]) recoverWith { - case err: AmazonServiceException => create.map(j => (j \ key).as[List[T]]) - case err => throw err - } + def setDeleteV2(id: String, key: String, value: String) + (implicit ex: ExecutionContext): Future[JsObject] = Future { + updateV2(id, DynamoDB.deleteExpr(key, lastModifiedKey), AttributeValueV2.fromSs(List(value).asJava)) } - def listRemoveIndexes(id: String, key: String, indexes: List[Int]) - (implicit ex: ExecutionContext, rjs: Reads[T]): Future[List[T]] = - update( - id, s"REMOVE ${indexes.map(i => s"$key[$i]").mkString(",")}" - ) map(j => (j \ key).as[List[T]]) - - def objPut(id: String, key: String, value: T) - (implicit ex: ExecutionContext, wjs: Writes[T], rjs: Reads[T]): Future[T] = Future { - - val item = new Item().withPrimaryKey(IdKey, id).withJSON(key, Json.toJson(value).toString) + def scanForIdV2(indexName: String, keyname: String, key: String)(implicit ex: ExecutionContext): Future[List[String]] = Future { + val request = QueryRequest.builder() + .tableName(tableName) + .indexName(indexName) + .keyConditionExpression(s"$keyname = :key") + .expressionAttributeValues(Map(":key" -> AttributeValueV2.fromS(key)).asJava) + .build() - val spec = new PutItemSpec().withItem(item) - table.putItem(spec) - // As PutItem only returns `null` if the item didn't exist, or the old item if it did, - // all we care about is whether it completed. - } map (_ => value) - - def scan()(implicit ex: ExecutionContext) = Future { - table.scan().iterator.asScala.toList - } map (_.map(asJsObject)) - - def scanForId(indexName: String, keyname: String, key: String)(implicit ex: ExecutionContext) = Future { - val index = table.getIndex(indexName) - - val spec = new QuerySpec() - .withKeyConditionExpression(s"$keyname = :key") - .withValueMap(new ValueMap() - .withString(":key", key)) - - val items: List[Item] = index.query(spec).iterator.asScala.toList - items map (a => a.getString("id")) + client2.query(request).items().asScala.toList + .flatMap(item => Option(item.get("id")).map(_.s())) } - def update(id: String, expression: String, valueMap: ValueMap) - (implicit ex: ExecutionContext): Future[JsObject] = - update(id, expression, Some(valueMap)) - - def update(id: String, expression: String, valueMap: Option[ValueMap] = None) - (implicit ex: ExecutionContext): Future[JsObject] = Future { - - val baseUpdateSpec = new UpdateItemSpec(). - withPrimaryKey(IdKey, id). - withUpdateExpression(expression). - withReturnValues(ReturnValue.ALL_NEW). - withValueMap(valueMap.orNull) - - val updateSpec = lastModifiedKey.map { key => - DynamoDB.addLastModifiedUpdate(baseUpdateSpec, key, DateTime.now) - }.getOrElse(baseUpdateSpec) + private def updateRequestBuilder(id: String, expression: String) = { + UpdateItemRequest.builder() + .key(Map(IdKey -> AttributeValueV2.fromS(id)).asJava) + .updateExpression(expression) + .returnValues(ReturnValueV2.ALL_NEW) + .tableName(tableName) + } - table.updateItem(updateSpec) - } map asJsObject + private def updateV2(id: String, expression: String, attribute: AttributeValueV2): JsObject = { + updateV2(id, expression, Map(":value" -> attribute)) + } + private def updateV2(id: String, expression: String): JsObject = { + updateV2(id, expression, Map.empty[String, AttributeValueV2]) + } - // FIXME: surely there must be a better way to convert? - def asJsObject(item: Item): JsObject = { - jsonWithNullAsEmptyString(Json.parse(item.toJSON)).as[JsObject] - IdKey + private def updateV2(id: String, expression: String, baseValuesMap: Map[String, AttributeValueV2]) = { + val valuesMap = lastModifiedKey.fold(baseValuesMap)(key => baseValuesMap ++ Map(s":${key}" -> AttributeValueV2.fromS(DateTime.now().toString))) + val updateRequest = updateRequestBuilder(id, expression) + .expressionAttributeValues(valuesMap.asJava) + .build() + val updateItemResponse = client2.updateItem(updateRequest) + val jsonString = EnhancedDocument.fromAttributeValueMap(updateItemResponse.attributes()).toJson + Json.parse(jsonString).as[JsObject] } - def asJsObject(outcome: UpdateItemOutcome): JsObject = - Option(outcome.getItem) map asJsObject getOrElse Json.obj() + def asJsObject(doc: EnhancedDocument): JsObject = + jsonWithNullAsEmptyString(Json.parse(doc.toJson)).as[JsObject] - IdKey // FIXME: Dynamo accepts `null`, but not `""`. This is a well documented issue // around the community. This guard keeps the introduction of `null` fairly // fenced in this Dynamo play area. `null` is continual and big annoyance with AWS libs. // see: https://forums.aws.amazon.com/message.jspa?messageID=389032 // see: http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DataModel.html - def mapJsValue(jsValue: JsValue)(f: JsValue => JsValue): JsValue = jsValue match { + private def mapJsValue(jsValue: JsValue)(f: JsValue => JsValue): JsValue = jsValue match { case JsObject(items) => JsObject(items.map{ case (k, v) => k -> mapJsValue(v)(f) }) case JsArray(items) => JsArray(items.map(f)) case value => f(value) } - def jsonWithNullAsEmptyString(jsValue: JsValue): JsValue = mapJsValue(jsValue) { + private def jsonWithNullAsEmptyString(jsValue: JsValue): JsValue = mapJsValue(jsValue) { case JsNull => JsString("") case value => value } - def valueMapWithNullForEmptyString(value: Map[String, JsValue]) = { - val valueMap = new ValueMap() - value.map { case(k, v) => (k, if (v == JsNull) null else v) } - .foreach { case(k, v) => valueMap.withJSON(k, Json.stringify(v)) } - - valueMap - } - } object DynamoDB { - def jsonToValueMap(json: JsObject): ValueMap = { - val valueMap = new ValueMap() - json.value map { case (key, value) => - value match { - case v: JsString => valueMap.withString(key, v.value) - case v: JsBoolean => valueMap.withBoolean(key, v.value) - case v: JsNumber => valueMap.withNumber(key, v.value) - case v: JsObject => valueMap.withMap(key, jsonToValueMap(v)) - - // TODO: Lists of different Types? JsArray is not type safe (because json lists aren't) - // so this leaves us in a bit of a pickle when converting them. So for now we only support - // List[String] - case v: JsArray => valueMap.withList(key, v.value.map { - case i: JsString => i.value - case i: JsValue => i.toString - }.asJava) - case _ => valueMap - } + def jsonToAttributeValue(json: JsValue): AttributeValueV2 = { + json match { + case JsString(v) => AttributeValueV2.fromS(v) + case JsBoolean(b) => AttributeValueV2.fromBool(b) + case JsTrue => AttributeValueV2.fromBool(true) + case JsFalse => AttributeValueV2.fromBool(false) + case JsNumber(n) => AttributeValueV2.fromN(n.toString()) + case JsNull => AttributeValueV2.fromNul(true) + case JsObject(obj) => AttributeValueV2.fromM(obj.view.mapValues(s => jsonToAttributeValue(s)).toMap.asJava) + case JsArray(arr) => AttributeValueV2.fromL(arr.toList.map(jsonToAttributeValue).asJava) } - valueMap } + def caseClassToMap[T](caseClass: T)(implicit tjs: Writes[T]): Map[String, JsValue] = Json.toJson[T](caseClass).as[JsObject].as[Map[String, JsValue]] - def addLastModifiedUpdate(update: UpdateItemSpec, lastModifiedKey: String, lastModifiedDate: DateTime): UpdateItemSpec = { - val expression = update.getUpdateExpression - val valueMap: ValueMap = { - val m = new ValueMap() - Option(update.getValueMap).foreach { vm => - m.putAll(vm) - } - m - } + def setExpr[T](key: String, lastModifiedKey: Option[String]) = { + val baseExpression = s"SET $key = :value" + lastModifiedKey.fold(baseExpression)(lastModifiedKey => s"$baseExpression, $lastModifiedKey = :$lastModifiedKey") + } - val newExpression = { - val keyUpdate: String = s"$lastModifiedKey = :$lastModifiedKey" - if (expression.contains("SET ")) { - // add to existing clause - expression.replace("SET ", s"SET ${keyUpdate}, ") - } else { - // add SET clause to existing expression - s"SET $keyUpdate ${expression}" - } - } + def removeExpr(key: String, lastModifiedKey: Option[String]) = { + generateExpression(s"REMOVE $key", lastModifiedKey) + } + + def addExpr(key: String, lastModifiedKey: Option[String]) = { + generateExpression(s"ADD $key :value", lastModifiedKey) + } - valueMap.put(s":$lastModifiedKey", lastModifiedDate.toString) + def deleteExpr(key: String, lastModifiedKey: Option[String]) = { + generateExpression(s"DELETE $key :value", lastModifiedKey) + } - update - .withUpdateExpression(newExpression) - .withValueMap(valueMap) + def generateExpression(baseExpression: String, lastModifiedKey: Option[String]) = { + lastModifiedKey.fold(baseExpression)(lastModifiedKey => s"$baseExpression SET $lastModifiedKey = :$lastModifiedKey") } } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Embedder.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Embedder.scala new file mode 100644 index 00000000000..2f0ea028a28 --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Embedder.scala @@ -0,0 +1,34 @@ +package com.gu.mediaservice.lib.aws +import com.amazonaws.services.sqs.model.SendMessageResult +import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker} +import play.api.libs.json.{Json, OFormat} +import software.amazon.awssdk.services.s3vectors.model.QueryVectorsResponse + +import scala.concurrent.{ExecutionContext, Future} + +sealed trait CohereCompatibleMimeType +case object CohereJpeg extends CohereCompatibleMimeType +case object CoherePng extends CohereCompatibleMimeType + +case class EmbedderMessage(imageId: String, fileType: String, s3Bucket: String, s3Key: String) + +object EmbedderMessage { + implicit val format: OFormat[EmbedderMessage] = Json.format[EmbedderMessage] +} + +class Embedder(s3vectors: S3Vectors, bedrock: Bedrock, sqs: SimpleSqsMessageConsumer)(implicit ec: ExecutionContext) extends GridLogging { + + def createEmbeddingAndSearch(query: String)(implicit logMarker: LogMarker): Future[QueryVectorsResponse] = { + logger.info(logMarker, s"Searching for image embedding for query: $query") + val embeddingFuture = bedrock.createEmbedding(InputType.SearchDocument, query) + embeddingFuture.flatMap { embedding => + s3vectors.searchVectorStore(embedding, query) + } + } + + def queueImageToEmbed(message: EmbedderMessage)(implicit logMarker: LogMarker) = { + val messageBody = Json.stringify(Json.toJson(message)) + val result: SendMessageResult = sqs.sendMessage(messageBody) + logger.info(logMarker, s"Queued image for embedding with message ID: ${result.getMessageId}") + } +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/InstanceAwareDynamoDB.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/InstanceAwareDynamoDB.scala new file mode 100644 index 00000000000..fded5dba41a --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/InstanceAwareDynamoDB.scala @@ -0,0 +1,228 @@ +package com.gu.mediaservice.lib.aws + +import com.gu.mediaservice.lib.aws.DynamoDB.deleteExpr +import com.gu.mediaservice.lib.logging.GridLogging +import com.gu.mediaservice.model.Instance +import org.joda.time.DateTime +import play.api.libs.json._ +import software.amazon.awssdk.enhanced.dynamodb._ +import software.amazon.awssdk.enhanced.dynamodb.document.EnhancedDocument +import software.amazon.awssdk.services.dynamodb.DynamoDbClient +import software.amazon.awssdk.services.dynamodb.model.{BatchGetItemRequest, QueryRequest, UpdateItemRequest, AttributeValue => AttributeValueV2, KeysAndAttributes => KeysAndAttributesV2, ReturnValue => ReturnValueV2} + +import scala.annotation.tailrec +import scala.concurrent.{ExecutionContext, Future} +import scala.jdk.CollectionConverters._ + +/** + * A lightweight wrapper around AWS dynamo SDK for undertaking various operations + * @param client2 DynamoDbClient client + * @param tableName the table name for this instance of the dynamoDB wrapper + * @param lastModifiedKey if set to a string the wrapper will maintain a last modified with that name on any update + * @tparam T The type of this table + */ +class InstanceAwareDynamoDB[T](client2: DynamoDbClient, tableName: String, lastModifiedKey: Option[String] = None) extends GridLogging { + lazy val dynamo2: DynamoDbEnhancedClient = DynamoDbEnhancedClient.builder().dynamoDbClient(client2).build() + lazy val tableSchema = TableSchema.documentSchemaBuilder() + .addIndexPartitionKey(TableMetadata.primaryIndexName(), InstanceKey, AttributeValueType.S) + .addIndexSortKey(TableMetadata.primaryIndexName(), IdKey, AttributeValueType.S) + .attributeConverterProviders(AttributeConverterProvider.defaultProvider()) + .build() + lazy val table2 = dynamo2.table(tableName, tableSchema) + + private val IdKey = "id" + private val InstanceKey = "instance" + + private def itemKey(id: String)(implicit instance: Instance) = { + Key.builder().partitionValue(instance.id).sortValue(id).build() + } + + def getV2(id: String)(implicit ex: ExecutionContext, instance: Instance): Future[JsObject] = Future { + table2.getItem(itemKey(id)) + } flatMap docOrNotFound map asJsObject + + private def getV2(id: String, attribute: String)(implicit ex: ExecutionContext, instance: Instance): Future[EnhancedDocument] = Future { + Option(table2.getItem(itemKey(id))).flatMap(doc => Option.when(doc.isPresent(attribute))(doc)) + } flatMap { + case Some(doc) => Future.successful(doc) + case None => Future.failed(NoItemFound) + } + + private def docOrNotFound(docOrNull: EnhancedDocument): Future[EnhancedDocument] = { + Option(docOrNull) match { + case Some(doc) => Future.successful(doc) + case None => Future.failed(NoItemFound) + } + } + + def removeKeyV2(id: String, key: String)(implicit ex: ExecutionContext, instance: Instance) = Future{ + updateV2(id, DynamoDB.removeExpr(key, lastModifiedKey)) + } + + def deleteItemV2(id: String)(implicit ex: ExecutionContext, instance: Instance): Future[Unit] = Future { + table2.deleteItem( + itemKey(id) + ) + } + def booleanGetV2(id: String, key: String) + (implicit ex: ExecutionContext, instance: Instance): Future[Boolean] = { + getV2(id, key).map(_.getBoolean(key).booleanValue()) + } + + def booleanSetV2(id: String, key: String, value: Boolean) + (implicit ex: ExecutionContext, instance: Instance): Future[JsObject] = Future { + updateV2( + id, + DynamoDB.setExpr(key, lastModifiedKey), + AttributeValueV2.fromBool(value) + ) + } + + def booleanSetOrRemoveV2(id: String, key: String, value: Boolean) + (implicit ex: ExecutionContext, instance: Instance): Future[JsObject] = + if (value) booleanSetV2(id, key, value) + else removeKeyV2(id, key) + + def stringSetV2(id: String, key: String, value: String)(implicit ex: ExecutionContext, instance: Instance): Future[JsObject] = Future { + updateV2(id, DynamoDB.setExpr(key, lastModifiedKey), AttributeValueV2.fromS(value)) + } + + def setGetV2(id: String, key: String) + (implicit ex: ExecutionContext, instance: Instance): Future[Set[String]] = { + getV2(id, key).map(_.getStringSet(key).asScala.toSet) + } + + def setAddV2(id: String, key: String, value: List[String])(implicit ex: ExecutionContext, instance: Instance): Future[JsObject] = Future { + updateV2(id, DynamoDB.addExpr(key, lastModifiedKey), AttributeValueV2.fromSs(value.asJava)) + } + + def batchGetV2(ids: List[String], attributeKey: String) + (implicit ex: ExecutionContext, rjs: Reads[T], instance: Instance): Future[Map[String, T]] = { + val keyChunkList = ids + .map(k => Map(IdKey -> AttributeValueV2.fromS(k), + InstanceKey -> AttributeValueV2.fromS(instance.id) + ).asJava) + .grouped(100) + + Future.traverse(keyChunkList) { keyChunk => { + val keysAndAttributes: KeysAndAttributesV2 = KeysAndAttributesV2.builder().keys(keyChunk.asJava).build() + + @tailrec + def nextPageOfBatch(request: java.util.Map[String, KeysAndAttributesV2], acc: List[(String, T)]) + (implicit ex: ExecutionContext, rjs: Reads[T]): List[(String, T)] = { + if (request.isEmpty) acc + else { + logger.info(s"Fetching records for $request") + val response = client2.batchGetItem(BatchGetItemRequest.builder().requestItems(request).build()) + val responses = response.responses() + logger.info(s"Got responses of $responses") + val results = responses.get(tableName).asScala.toList + .flatMap(att => { + logger.info(s"Obtained attributes of $att from response") + val json = asJsObject(EnhancedDocument.fromAttributeValueMap(att)) + val maybeT = (json \ attributeKey).asOpt[T] + logger.info(s"Obtained a T of $maybeT from json $json") + maybeT.map( + att.get(IdKey).s() -> _ + ) + }) + logger.info(s"Got $results for request") + nextPageOfBatch(response.unprocessedKeys(), acc ::: results) + } + } + + Future { + nextPageOfBatch(Map(tableName -> keysAndAttributes).asJava, Nil).toMap + } + } + } + .map(chunkIterator => chunkIterator.fold(Map.empty)((acc, result) => acc ++ result)) + } + + // We cannot update, so make sure you send over the WHOLE document + def jsonAddV2(id: String, key: String, value: Map[String, JsValue]) + (implicit ex: ExecutionContext, instance: Instance): Future[JsObject] = Future { + updateV2( + id, + setExpr(key, lastModifiedKey), + AttributeValueV2.fromM(value.view.mapValues(DynamoDB.jsonToAttributeValue).toMap.asJava) + ) + } + + def setDeleteV2(id: String, key: String, value: String) + (implicit ex: ExecutionContext, instance: Instance): Future[JsObject] = Future { + updateV2(id, deleteExpr(key, lastModifiedKey), AttributeValueV2.fromSs(List(value).asJava)) + } + + def scanForIdV2(indexName: String, keyname: String, key: String)(implicit ex: ExecutionContext, instance: Instance): Future[List[String]] = Future { + val request = QueryRequest.builder() + .tableName(tableName) + .indexName(indexName) + .keyConditionExpression(s"$keyname = :key AND instance = :instance") + .expressionAttributeValues(Map( + ":key" -> AttributeValueV2.fromS(key), + ":instance" -> AttributeValueV2.fromS(instance.id) + ).asJava) + .build() + + client2.query(request).items().asScala.toList + .flatMap(item => Option(item.get("id")).map(_.s())) + } + + private def updateRequestBuilder(id: String, expression: String)(implicit instance: Instance) = { + UpdateItemRequest.builder() + .key(Map( + InstanceKey -> AttributeValueV2.fromS(instance.id), + IdKey -> AttributeValueV2.fromS(id)).asJava + ) + .updateExpression(expression) + .returnValues(ReturnValueV2.ALL_NEW) + .tableName(tableName) + } + + def updateV2(id: String, expression: String, attribute: AttributeValueV2)(implicit instance: Instance) = { + val baseValuesMap = Map(":value" -> attribute) + val valuesMap = lastModifiedKey.fold(baseValuesMap)(key => baseValuesMap ++ Map(s":${key}" -> AttributeValueV2.fromS(DateTime.now().toString))) + val updateRequest = updateRequestBuilder(id, expression) + .expressionAttributeValues(valuesMap.asJava) + .build() + val updateItemResponse = client2.updateItem(updateRequest) + val jsonString = EnhancedDocument.fromAttributeValueMap(updateItemResponse.attributes()).toJson + Json.parse(jsonString).as[JsObject] + } + + def updateV2(id: String, expression: String)(implicit instance: Instance) = { + val valuesMap = lastModifiedKey.fold(Map.empty[String, AttributeValueV2])(key => Map(s":${key}" -> AttributeValueV2.fromS(DateTime.now().toString))) + val updateRequest = updateRequestBuilder(id, expression) + .expressionAttributeValues(valuesMap.asJava) + .build() + val updateItemResponse = client2.updateItem(updateRequest) + val jsonString = EnhancedDocument.fromAttributeValueMap(updateItemResponse.attributes()).toJson + Json.parse(jsonString).as[JsObject] + } + + def asJsObject(doc: EnhancedDocument): JsObject = + jsonWithNullAsEmptyString(Json.parse(doc.toJson)).as[JsObject] - IdKey - InstanceKey + + // FIXME: Dynamo accepts `null`, but not `""`. This is a well documented issue + // around the community. This guard keeps the introduction of `null` fairly + // fenced in this Dynamo play area. `null` is continual and big annoyance with AWS libs. + // see: https://forums.aws.amazon.com/message.jspa?messageID=389032 + // see: http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DataModel.html + def mapJsValue(jsValue: JsValue)(f: JsValue => JsValue): JsValue = jsValue match { + case JsObject(items) => JsObject(items.map{ case (k, v) => k -> mapJsValue(v)(f) }) + case JsArray(items) => JsArray(items.map(f)) + case value => f(value) + } + + def jsonWithNullAsEmptyString(jsValue: JsValue): JsValue = mapJsValue(jsValue) { + case JsNull => JsString("") + case value => value + } + + def setExpr[T](key: String, lastModifiedKey: Option[String]) = { + val baseExpression = s"SET $key = :value" + lastModifiedKey.fold(baseExpression)(lastModifiedKey => s"$baseExpression, $lastModifiedKey = :$lastModifiedKey") + } + +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Kinesis.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Kinesis.scala index 811bfe64007..e4ef2dbac79 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Kinesis.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/Kinesis.scala @@ -2,7 +2,7 @@ package com.gu.mediaservice.lib.aws import java.nio.ByteBuffer import java.util.UUID -import com.amazonaws.services.kinesis.model.PutRecordRequest +import com.amazonaws.services.kinesis.model.{PutRecordRequest, PutRecordsRequest, PutRecordsRequestEntry} import com.amazonaws.services.kinesis.{AmazonKinesis, AmazonKinesisClientBuilder} import com.gu.mediaservice.lib.json.JsonByteArrayUtil import com.gu.mediaservice.model.usage.UsageNotice @@ -10,8 +10,11 @@ import net.logstash.logback.marker.{LogstashMarker, Markers} import play.api.libs.json.{JodaWrites, Json, Writes} import com.amazonaws.auth.AWSCredentialsProvider import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker} +import com.gu.mediaservice.model.Instance import org.joda.time.DateTime +import scala.jdk.CollectionConverters.SeqHasAsJava + case class KinesisSenderConfig( override val awsRegion: String, override val awsCredentials: AWSCredentialsProvider, @@ -32,12 +35,13 @@ class Kinesis(config: KinesisSenderConfig) extends GridLogging{ val partitionKey = UUID.randomUUID().toString implicit val yourJodaDateWrites: Writes[DateTime] = JodaWrites.JodaDateTimeWrites + implicit val iw: Writes[Instance] = Json.writes[Instance] implicit val unw: Writes[UsageNotice] = Json.writes[UsageNotice] val payload = JsonByteArrayUtil.toByteArray(message) val markers: LogstashMarker = message.toLogMarker.and(Markers.append("compressed-size", payload.length)) - logger.info(markers, "Publishing message to kinesis") + logger.info(markers, s"Publishing message to kinesis: ${config.streamName}") val data = ByteBuffer.wrap(payload) val request = new PutRecordRequest() @@ -55,5 +59,38 @@ class Kinesis(config: KinesisSenderConfig) extends GridLogging{ throw e } } + + def publish[T <: LogMarker](messages: Seq[T])(implicit messageWrites: Writes[T]): Unit = { + val records: Seq[PutRecordsRequestEntry] = messages.map { message => + val partitionKey = UUID.randomUUID().toString + implicit val yourJodaDateWrites: Writes[DateTime] = JodaWrites.JodaDateTimeWrites + implicit val iw: Writes[Instance] = Json.writes[Instance] + implicit val unw: Writes[UsageNotice] = Json.writes[UsageNotice] + + val payload = JsonByteArrayUtil.toByteArray(message) + val data = ByteBuffer.wrap(payload) + + val entry = new PutRecordsRequestEntry + entry.setPartitionKey(partitionKey) + entry.setData(data) + entry + } + + logger.info(s"Publishing ${messages.size} messages to kinesis: ${config.streamName}") + + val request = new PutRecordsRequest() + .withStreamName(config.streamName).withRecords(records.asJava) + + try { + val result = kinesisClient.putRecords(request) + logger.info(s"Published kinesis message: $result") + } catch { + case e: Exception => + logger.error(s"kinesis putRecord failed", e) + // propagate error forward to the client + throw e + } + } + } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala index cd7cd9cee9e..88db6d3bc1b 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala @@ -1,5 +1,7 @@ package com.gu.mediaservice.lib.aws +import com.amazonaws.auth.{AWSStaticCredentialsProvider, BasicAWSCredentials} +import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration import com.amazonaws.services.s3.model._ import com.amazonaws.services.s3.{AmazonS3, AmazonS3ClientBuilder, model} import com.amazonaws.util.IOUtils @@ -7,25 +9,21 @@ import com.amazonaws.{AmazonServiceException, ClientConfiguration} import com.gu.mediaservice.lib.config.CommonConfig import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker, Stopwatch} import com.gu.mediaservice.model._ -import org.joda.time.{DateTime, Duration} +import org.joda.time.DateTime import java.io.File -import java.net.URI -import scala.jdk.CollectionConverters._ +import java.net.{URI, URL} import scala.concurrent.{ExecutionContext, Future} +import scala.jdk.CollectionConverters._ case class S3Object(uri: URI, size: Long, metadata: S3Metadata) object S3Object { - def objectUrl(bucket: String, key: String): URI = { - val bucketUrl = s"$bucket.${S3Ops.s3Endpoint}" - new URI("http", bucketUrl, s"/$key", null) - } - def apply(bucket: String, key: String, size: Long, metadata: S3Metadata): S3Object = - apply(objectUrl(bucket, key), size, metadata) + def apply(bucket: S3Bucket, key: String, size: Long, metadata: S3Metadata): S3Object = + apply(bucket.objectUrl(key), size, metadata) - def apply(bucket: String, key: String, file: File, mimeType: Option[MimeType], lastModified: Option[DateTime], + def apply(bucket: S3Bucket, key: String, file: File, mimeType: Option[MimeType], lastModified: Option[DateTime], meta: Map[String, String] = Map.empty, cacheControl: Option[String] = None): S3Object = { S3Object( bucket, @@ -61,34 +59,66 @@ object S3Metadata { case class S3ObjectMetadata(contentType: Option[MimeType], cacheControl: Option[String], lastModified: Option[DateTime]) class S3(config: CommonConfig) extends GridLogging with ContentDisposition with RoundedExpiration { - type Bucket = String type Key = String type UserMetadata = Map[String, String] - lazy val client: AmazonS3 = S3Ops.buildS3Client(config) - // also create a legacy client that uses v2 signatures for URL signing - private lazy val legacySigningClient: AmazonS3 = S3Ops.buildS3Client(config, forceV2Sigs = true) + val AmazonAwsS3Endpoint: String = S3.AmazonAwsS3Endpoint - def signUrl(bucket: Bucket, url: URI, image: Image, expiration: DateTime = cachableExpiration(), imageType: ImageFileType = Source): String = { - // get path and remove leading `/` - val key: Key = url.getPath.drop(1) + private val amazonS3: AmazonS3 = S3Ops.buildS3Client(config) + private val googleS3: Option[AmazonS3] = S3Ops.buildGoogleS3Client(config) + private val localS3: Option[AmazonS3] = S3Ops.buildLocalS3Client(config) + def signUrl(bucket: S3Bucket, key: String, image: Image, expiration: DateTime = cachableExpiration(), imageType: ImageFileType = Source): String = { val contentDisposition = getContentDisposition(image, imageType, config.shortenDownloadFilename) val headers = new ResponseHeaderOverrides().withContentDisposition(contentDisposition) - val request = new GeneratePresignedUrlRequest(bucket, key).withExpiration(expiration.toDate).withResponseHeaders(headers) - legacySigningClient.generatePresignedUrl(request).toExternalForm + val request = new GeneratePresignedUrlRequest(bucket.bucket, key).withExpiration(expiration.toDate).withResponseHeaders(headers) + bucket.client.generatePresignedUrl(request).toExternalForm + } + + def signUrlTony(bucket: S3Bucket, key: String, expiration: DateTime = cachableExpiration()): URL = { + val request = new GeneratePresignedUrlRequest(bucket.bucket, key).withExpiration(expiration.toDate) + bucket.client.generatePresignedUrl(request) + } + + def copyObject(sourceBucket: S3Bucket, destinationBucket: S3Bucket, key: String): CopyObjectResult = { + // TODO check that source and destination share the same client + sourceBucket.client.copyObject(sourceBucket.bucket, key, destinationBucket.bucket, key) + } + + def generatePresignedRequest(request: GeneratePresignedUrlRequest, bucket: S3Bucket): URL = { + bucket.client.generatePresignedUrl(request) + } + + def deleteObject(bucket: S3Bucket, key: String): Unit = { + bucket.client.deleteObject(bucket.bucket, key) } - def getObject(bucket: Bucket, url: URI): model.S3Object = { - // get path and remove leading `/` - val key: Key = url.getPath.drop(1) - client.getObject(new GetObjectRequest(bucket, key)) + def deleteObjects(bucket: S3Bucket, keys: Seq[String]): DeleteObjectsResult = { + bucket.client.deleteObjects( + new DeleteObjectsRequest(bucket.bucket).withKeys(keys: _*) + ) + } + + def deleteVersion(bucket: S3Bucket, id: String, objectVersion: String): Unit = { + bucket.client.deleteVersion(bucket.bucket, id, objectVersion) } - def getObjectAsString(bucket: Bucket, key: String): Option[String] = { - val content = client.getObject(new GetObjectRequest(bucket, key)) + def doesObjectExist(bucket: S3Bucket, key: String) = { + bucket.client.doesObjectExist(bucket.bucket, key) + } + + def getObject(bucket: S3Bucket, key: String): model.S3Object = { + bucket.client.getObject(new GetObjectRequest(bucket.bucket, key)) + } + + def getObject(bucket: S3Bucket, obj: S3ObjectSummary): model.S3Object = { + bucket.client.getObject(bucket.bucket, obj.getKey) + } + + def getObjectAsString(bucket: S3Bucket, key: String): Option[String] = { + val content = bucket.client.getObject(new GetObjectRequest(bucket.bucket, key)) val stream = content.getObjectContent try { Some(IOUtils.toString(stream).trim) @@ -102,7 +132,31 @@ class S3(config: CommonConfig) extends GridLogging with ContentDisposition with } } - def store(bucket: Bucket, id: Key, file: File, mimeType: Option[MimeType], meta: UserMetadata = Map.empty, cacheControl: Option[String] = None) + def getObjectMetadata(bucket: S3Bucket, id: String): ObjectMetadata = { + bucket.client.getObjectMetadata(bucket.bucket, id) + } + + def listObjects(bucket: S3Bucket): ObjectListing = { + bucket.client.listObjects(bucket.bucket) + } + + def listObjects(bucket: S3Bucket, prefix: String): ObjectListing = { + bucket.client.listObjects(bucket.bucket, prefix) + } + + def listObjects(bucket: S3Bucket, request: ListObjectsRequest): ObjectListing = { + bucket.client.listObjects(request) + } + + def listObjectKeys(bucket: S3Bucket): Seq[String] = { + bucket.client.listObjects(bucket.bucket).getObjectSummaries.asScala.map(_.getKey).toSeq + } + + def putObject(bucket: S3Bucket, key: String, content: String): Unit = { + bucket.client.putObject(bucket.bucket, key, content) + } + + def store(bucket: S3Bucket, id: Key, file: File, mimeType: Option[MimeType], meta: UserMetadata = Map.empty, cacheControl: Option[String] = None) (implicit ex: ExecutionContext, logMarker: LogMarker): Future[S3Object] = Future { val metadata = new ObjectMetadata @@ -111,25 +165,26 @@ class S3(config: CommonConfig) extends GridLogging with ContentDisposition with metadata.setUserMetadata(meta.asJava) val fileMarkers = Map( - "bucket" -> bucket, + "bucket" -> bucket.bucket, "fileName" -> id, "mimeType" -> mimeType.getOrElse("none"), ) val markers = logMarker ++ fileMarkers - val req = new PutObjectRequest(bucket, id, file).withMetadata(metadata) + val req = new PutObjectRequest(bucket.bucket, id, file).withMetadata(metadata) Stopwatch(s"S3 client.putObject ($req)"){ + val client = bucket.client client.putObject(req) // once we've completed the PUT read back to ensure that we are returning reality - val metadata = client.getObjectMetadata(bucket, id) + val metadata = client.getObjectMetadata(bucket.bucket, id) S3Object(bucket, id, metadata.getContentLength, S3Metadata(metadata)) }(markers) } - def storeIfNotPresent(bucket: Bucket, id: Key, file: File, mimeType: Option[MimeType], meta: UserMetadata = Map.empty, cacheControl: Option[String] = None) + def storeIfNotPresent(bucket: S3Bucket, id: Key, file: File, mimeType: Option[MimeType], meta: UserMetadata = Map.empty, cacheControl: Option[String] = None) (implicit ex: ExecutionContext, logMarker: LogMarker): Future[S3Object] = { Future{ - Some(client.getObjectMetadata(bucket, id)) + Some(bucket.client.getObjectMetadata(bucket.bucket, id)) }.recover { // translate this exception into the object not existing case as3e:AmazonS3Exception if as3e.getStatusCode == 404 => None @@ -142,11 +197,11 @@ class S3(config: CommonConfig) extends GridLogging with ContentDisposition with } } - def list(bucket: Bucket, prefixDir: String) + def list(bucket: S3Bucket, prefixDir: String) (implicit ex: ExecutionContext): Future[List[S3Object]] = Future { - val req = new ListObjectsRequest().withBucketName(bucket).withPrefix(s"$prefixDir/") - val listing = client.listObjects(req) + val req = new ListObjectsRequest().withBucketName(bucket.bucket).withPrefix(s"$prefixDir/") + val listing = bucket.client.listObjects(req) val summaries = listing.getObjectSummaries.asScala summaries.map(summary => (summary.getKey, summary)).foldLeft(List[S3Object]()) { case (memo: List[S3Object], (key: String, summary: S3ObjectSummary)) => @@ -154,17 +209,17 @@ class S3(config: CommonConfig) extends GridLogging with ContentDisposition with } } - def getMetadata(bucket: Bucket, key: Key): S3Metadata = { - val meta = client.getObjectMetadata(bucket, key) + def getMetadata(bucket: S3Bucket, key: Key): S3Metadata = { + val meta = bucket.client.getObjectMetadata(bucket.bucket, key) S3Metadata(meta) } - def getUserMetadata(bucket: Bucket, key: Key): Map[Bucket, Bucket] = - client.getObjectMetadata(bucket, key).getUserMetadata.asScala.toMap + def getUserMetadata(bucket: S3Bucket, key: Key): Map[String, String] = + bucket.client.getObjectMetadata(bucket.bucket, key).getUserMetadata.asScala.toMap - def syncFindKey(bucket: Bucket, prefixName: String): Option[Key] = { - val req = new ListObjectsRequest().withBucketName(bucket).withPrefix(s"$prefixName-") - val listing = client.listObjects(req) + def syncFindKey(bucket: S3Bucket, prefixName: String): Option[Key] = { + val req = new ListObjectsRequest().withBucketName(bucket.bucket).withPrefix(s"$prefixName-") + val listing = bucket.client.listObjects(req) val summaries = listing.getObjectSummaries.asScala summaries.headOption.map(_.getKey) } @@ -172,9 +227,44 @@ class S3(config: CommonConfig) extends GridLogging with ContentDisposition with } object S3Ops { - // TODO make this localstack friendly - // TODO: Make this region aware - i.e. RegionUtils.getRegion(region).getServiceEndpoint(AmazonS3.ENDPOINT_PREFIX) - val s3Endpoint = "s3.amazonaws.com" + def buildGoogleS3Client(config: CommonConfig): Option[AmazonS3] = { + config.googleS3AccessKey.flatMap { accessKey => + config.googleS3SecretKey.map { secretKey => + val endpointConfig = new EndpointConfiguration("https://storage.googleapis.com", null) + // create credentials provider + val credentials = new BasicAWSCredentials(accessKey, secretKey) + val credentialsProvider = new AWSStaticCredentialsProvider(credentials) + // create a client config + val clientConfig = new ClientConfiguration() + + val clientBuilder = AmazonS3ClientBuilder.standard() + clientBuilder.setEndpointConfiguration(endpointConfig) + clientBuilder.withCredentials(credentialsProvider) + clientBuilder.withClientConfiguration(clientConfig) + clientBuilder.build() + } + } + } + + def buildLocalS3Client(config: CommonConfig): Option[AmazonS3] = { + config.googleS3AccessKey.flatMap { accessKey => + config.googleS3SecretKey.map { secretKey => + val endpointConfig = new EndpointConfiguration("https://minio.griddev.eelpieconsulting.co.uk", null) + // create credentials provider + val credentials = new BasicAWSCredentials(accessKey, secretKey) + val credentialsProvider = new AWSStaticCredentialsProvider(credentials) + // create a client config + val clientConfig = new ClientConfiguration() + + val clientBuilder = AmazonS3ClientBuilder.standard() + clientBuilder.setEndpointConfiguration(endpointConfig) + clientBuilder.withCredentials(credentialsProvider) + clientBuilder.withClientConfiguration(clientConfig) + clientBuilder.withPathStyleAccessEnabled(true) + clientBuilder.build() + } + } + } def buildS3Client(config: CommonConfig, forceV2Sigs: Boolean = false, localstackAware: Boolean = true, maybeRegionOverride: Option[String] = None): AmazonS3 = { @@ -196,4 +286,9 @@ object S3Ops { config.withAWSCredentials(builder, localstackAware, maybeRegionOverride).build() } + +} + +object S3 { + val AmazonAwsS3Endpoint: String = "s3.amazonaws.com" } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3Bucket.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3Bucket.scala new file mode 100644 index 00000000000..9f217375589 --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3Bucket.scala @@ -0,0 +1,29 @@ +package com.gu.mediaservice.lib.aws + +import com.amazonaws.services.s3.AmazonS3 + +import java.net.URI + +case class S3Bucket(bucket: String, endpoint: String, usesPathStyleURLs: Boolean, client: AmazonS3) { + def objectUrl(key: String): URI = { + val bucketBaseURL = bucketURL() + new URI("http", bucketBaseURL.getHost, bucketBaseURL.getPath + key, null) + } + + def keyFromS3URL(url: URI): String = { + if (usesPathStyleURLs) { + url.getPath.drop(bucket.length + 2) + } else { + url.getPath.drop(1) + } + } + + def bucketURL(): URI = { + if (usesPathStyleURLs) { + new URI("https", endpoint, s"/$bucket/", null) + } else { + new URI("https", s"$bucket.$endpoint", "/", null) + } + } + +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3Vectors.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3Vectors.scala new file mode 100644 index 00000000000..c57d16d7dfd --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3Vectors.scala @@ -0,0 +1,194 @@ +package com.gu.mediaservice.lib.aws +import com.gu.mediaservice.lib.config.CommonConfig +import com.gu.mediaservice.lib.logging.LogMarker +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3vectors._ +import software.amazon.awssdk.services.s3vectors.model._ + +import java.net.URI +import scala.concurrent.{ExecutionContext, Future} +import scala.jdk.CollectionConverters._ + + +object S3Vectors { + object DeletionStatus extends Enumeration { + val deleted: Value = Value("deleted") + val notFound: Value = Value("not found") + val failed: Value = Value("failed to delete") + + def fromBeforeAndAfter(key: String, vectorsBefore: Set[String], vectorsAfter: Set[String]): DeletionStatus.Value = + if (!vectorsBefore.contains(key)) DeletionStatus.notFound + else if (vectorsAfter.contains(key)) DeletionStatus.failed + else DeletionStatus.deleted + } +} + +class S3Vectors(config: CommonConfig)(implicit ec: ExecutionContext) + extends AwsClientV2BuilderUtils { + import S3Vectors.DeletionStatus + + // TODO: figure out what the more usual pattern for turning off localstack behaviour is + override def awsLocalEndpointUri: Option[URI] = None + + override def isDev: Boolean = config.isDev + + // The S3 Vector Store is not yet available in eu-west-1, so we are using eu-central-1 because it's closest to us. + override def awsRegionV2: Region = Region.EU_CENTRAL_1 + + val client: S3VectorsClient = { + withAWSCredentialsV2(S3VectorsClient.builder()) + .build() + } + + private val vectorBucketName: String = s"image-embeddings-${config.stage.toLowerCase}" + private val indexName: String = "cohere-embed-english-v3" + + private def getVectors(keys: Set[String], returnData: Boolean, returnMetadata: Boolean): List[GetOutputVector] = + // GetVectors has a max of 100 + // https://docs.aws.amazon.com/AmazonS3/latest/API/API_S3VectorBuckets_GetVectors.html + keys.grouped(100).flatMap { batch => + val request = GetVectorsRequest.builder() + .indexName(indexName) + .returnData(returnData) + .returnMetadata(returnMetadata) + .vectorBucketName(vectorBucketName) + .keys(batch.asJavaCollection) + .build() + + val response = client.getVectors(request) + response.vectors().asScala.toList + }.toList + + private def deleteVectors(keys: Set[String]): DeleteVectorsResponse = { + val request = DeleteVectorsRequest.builder() + .indexName(indexName) + .vectorBucketName(vectorBucketName) + .keys(keys.asJavaCollection) + .build() + + client.deleteVectors(request) + } + + private def convertEmbeddingToVectorData(embedding: List[Float]): VectorData = { + VectorData + .builder() + .float32(embedding.map(float2Float).asJava) + .build() + } + + private def putVector(vector: List[Float], key: String): PutVectorsResponse = { + val vectorData: VectorData = convertEmbeddingToVectorData(vector) + + val inputVector: PutInputVector = PutInputVector + .builder() + .data(vectorData) + .key(key) + .build() + + val request: PutVectorsRequest = PutVectorsRequest + .builder() + .indexName(indexName) + .vectorBucketName(vectorBucketName) + .vectors(inputVector) + .build() + + client.putVectors(request) + } + + private def queryVectors(embedding: List[Float]): QueryVectorsResponse = { + val queryVector: VectorData = convertEmbeddingToVectorData(embedding: List[Float]) + + val request: QueryVectorsRequest = QueryVectorsRequest + .builder() + .indexName(indexName) + .vectorBucketName(vectorBucketName) + .topK(100) + .queryVector(queryVector) + .build() + + client.queryVectors(request) + } + + def searchVectorStore(queryEmbedding: List[Float], query: String)(implicit logMarker: LogMarker): Future[QueryVectorsResponse] = Future { + try { + val response = queryVectors(queryEmbedding) + logger.info( + logMarker, + s"S3 Vector Store API call to search image embeddings completed with status: ${response.sdkHttpResponse().statusCode()}" + ) + logger.info(logMarker, s"${response}") + response + } catch { + case e: Exception => + logger.error(logMarker, s"Exception during S3 Vector Store API call for query ${query}", e) + throw e + } + } + + private def getExistingVectorKeys(keys: Set[String]): Set[String] = + getVectors(keys, returnData = false, returnMetadata = false).map(_.key).toSet + + private def deleteBatch(batch: Set[String], batchCount: String)(implicit logMarker: LogMarker): Map[String, DeletionStatus.Value] = { + // Currently AWS don't provide any information on which deletes succeeded or failed. + // It returns 200 even if none of the provided keys currently exist. + // So in order to tell what was actually deleted, we need to make GetVectors requests before & after. + val vectorsBefore = getExistingVectorKeys(batch) + + logger.info(logMarker, s"${vectorsBefore.size} vectors to delete from batch of ${batch.size} ($batchCount)") + + if (vectorsBefore.isEmpty) { + batch.map(_ -> DeletionStatus.notFound).toMap + } else { + try { + deleteVectors(batch) + } catch { + // Swallow this error. Because there is a low write throughput across Puts and Deletes + // (5 per second), we may get failures here (though hopefully mitigated by the SDK retry logic). + // By recovering at this granularity, we can still try and report exactly + // what did and didn't get deleted through the subsequent GetVectors call. + case e: Exception => + logger.error(logMarker, s"Exception during S3 Vector Store API call to delete batch of ${batch.size} vectors ($batchCount)", e) + } + val vectorsAfter = getExistingVectorKeys(batch) + + if (vectorsAfter.nonEmpty) { + logger.warn(logMarker, s"${vectorsAfter.size} of ${vectorsBefore.size} failed to delete ($batchCount)") + } + logger.info(logMarker, s"${vectorsBefore.size - vectorsAfter.size} vectors deleted from batch of ${batch.size} ($batchCount)") + + batch.map(key => key -> DeletionStatus.fromBeforeAndAfter(key, vectorsBefore, vectorsAfter)).toMap + } + } + + def deleteEmbeddings(imageIds: Set[String])(implicit logMarker: LogMarker): Future[Map[String, DeletionStatus.Value]] = Future { + try { + val startTime = System.currentTimeMillis() + // We can only delete 500 keys at once + // https://docs.aws.amazon.com/AmazonS3/latest/API/API_S3VectorBuckets_DeleteVectors.html + val batches = imageIds.grouped(500).toList + + val result = batches.zipWithIndex.flatMap { case (batch, i) => + val batchCount = s"batch ${i + 1} of ${batches.length}" + deleteBatch(batch, batchCount) + }.toMap + + val duration = System.currentTimeMillis() - startTime + val stats = DeletionStatus.values.map { deletionStatus => + val count = result.values.count(_ == deletionStatus) + s"${deletionStatus}: ${count}" + } + logger.info( + logMarker, + s"deleteEmbeddings took ${duration}ms for ${imageIds.size} images, ${stats.mkString(", ")}" + ) + result + } catch { + case e: Exception => + // Swallow this error and assume all images failed to delete, + // so we don't affect the rest of the reaping process and can still report the results. + logger.error("Unexpected exception when deleting embeddings", e) + imageIds.map(key => key -> DeletionStatus.failed).toMap + } + + } +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/SimpleSqsMessageConsumer.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/SimpleSqsMessageConsumer.scala index 13c9804ba76..67a6ee77825 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/SimpleSqsMessageConsumer.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/SimpleSqsMessageConsumer.scala @@ -3,7 +3,7 @@ package com.gu.mediaservice.lib.aws import com.gu.mediaservice.lib.config.CommonConfig import com.amazonaws.services.sqs.AmazonSQS import com.amazonaws.services.sqs.AmazonSQSClientBuilder -import com.amazonaws.services.sqs.model.{DeleteMessageRequest, ReceiveMessageRequest, Message => SQSMessage} +import com.amazonaws.services.sqs.model.{ChangeMessageVisibilityRequest, DeleteMessageRequest, ReceiveMessageRequest, SendMessageRequest, SendMessageResult, Message => SQSMessage} import scala.jdk.CollectionConverters._ @@ -22,6 +22,9 @@ class SimpleSqsMessageConsumer (queueUrl: String, config: CommonConfig) { def deleteMessage(message: SQSMessage): Unit = client.deleteMessage(new DeleteMessageRequest(queueUrl, message.getReceiptHandle)) + def makeMessageVisible(message: SQSMessage): Unit = + client.changeMessageVisibility(new ChangeMessageVisibilityRequest(queueUrl, message.getReceiptHandle, 0)) + def getStatus: Map[String, String] = { client.getQueueAttributes(queueUrl, List( "ApproximateNumberOfMessagesDelayed", @@ -29,4 +32,11 @@ class SimpleSqsMessageConsumer (queueUrl: String, config: CommonConfig) { "ApproximateNumberOfMessagesNotVisible" ).asJava).getAttributes.asScala.toMap } + + def sendMessage(messageBody: String): SendMessageResult = { + val sendMessageRequest: SendMessageRequest = new SendMessageRequest() + .withQueueUrl(queueUrl) + .withMessageBody(messageBody) + client.sendMessage(sendMessageRequest) + } } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/ThrallMessageSender.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/ThrallMessageSender.scala index a7fa790454d..fe9d2958cdc 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/ThrallMessageSender.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/ThrallMessageSender.scala @@ -6,7 +6,7 @@ import com.gu.mediaservice.model.leases.MediaLease import com.gu.mediaservice.model.usage.UsageNotice import org.joda.time.{DateTime, DateTimeZone} import play.api.libs.functional.syntax.toFunctionalBuilderOps -import play.api.libs.json.{JodaReads, JodaWrites, Json, OWrites, Reads, Writes, __} +import play.api.libs.json.{JodaReads, JodaWrites, Json, OFormat, OWrites, Reads, Writes, __} // TODO MRB: replace this with the simple Kinesis class once we migrate off SNS class ThrallMessageSender(config: KinesisSenderConfig) { @@ -16,7 +16,11 @@ class ThrallMessageSender(config: KinesisSenderConfig) { kinesis.publish(updateMessage)(UpdateMessage.writes) } - def publish(externalThrallMessage: ExternalThrallMessage) = { + def publish(updateMessages: Seq[UpdateMessage]): Unit = { + kinesis.publish(updateMessages)(UpdateMessage.writes) + } + + def publish(externalThrallMessage: ExternalThrallMessage): Unit = { kinesis.publish(externalThrallMessage) } } @@ -34,6 +38,7 @@ object BulkIndexRequest { object UpdateMessage extends GridLogging { implicit val yourJodaDateReads: Reads[DateTime] = JodaReads.DefaultJodaDateTimeReads.map(d => d.withZone(DateTimeZone.UTC)) implicit val yourJodaDateWrites: Writes[DateTime] = JodaWrites.JodaDateTimeWrites + implicit val instanceFormats: OFormat[Instance] = Json.format[Instance] implicit val unw: OWrites[UsageNotice] = Json.writes[UsageNotice] implicit val unr: Reads[UsageNotice] = Json.reads[UsageNotice] implicit val writes: OWrites[UpdateMessage] = Json.writes[UpdateMessage] @@ -60,7 +65,8 @@ object UpdateMessage extends GridLogging { (__ \ "leases").readNullable[Seq[MediaLease]] ~ (__ \ "syndicationRights").readNullable[SyndicationRights] ~ (__ \ "bulkIndexRequest").readNullable[BulkIndexRequest] ~ - (__ \ "usageId").readNullable[String] + (__ \ "usageId").readNullable[String] ~ + (__ \ "instance").read[Instance] )(UpdateMessage.apply _) } @@ -80,7 +86,8 @@ case class UpdateMessage( leases: Option[Seq[MediaLease]] = None, syndicationRights: Option[SyndicationRights] = None, bulkIndexRequest: Option[BulkIndexRequest] = None, - usageId: Option[String] = None + usageId: Option[String] = None, + instance: Instance ) extends LogMarker { override def markerContents = { val message = Json.stringify(Json.toJson(this)) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/PhotographerRenamer.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/PhotographerRenamer.scala index 76244a4c345..c55b9cf1b1f 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/PhotographerRenamer.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/PhotographerRenamer.scala @@ -234,6 +234,7 @@ object PhotographerRenamer extends MetadataCleaner { "Huseyin Demirci" -> "Hüseyin Demirci", "Huseyin Yildiz" -> "Hüseyin Yıldız", "Ian Macnicol" -> "Ian MacNicol", + "Igor Pavicevic" -> "Igor Pavićević", "Inti Ocon" -> "Inti Ocón", "Ints Kalnins" -> "Ints Kalniņš", "Irek Dorozanski" -> "Irek Dorożański", @@ -274,6 +275,7 @@ object PhotographerRenamer extends MetadataCleaner { "Jerome Prebois" -> "Jérôme Prébois", "Jerome Prevost" -> "Jérôme Prévost", "Jerome Sessini" -> "Jérôme Sessini", + "Jerzy Muszynski" -> "Jerzy Muszyński", "Jesus Bustamante" -> "Jesús Bustamante", "Jesus Diges" -> "Jesús Diges", "Jesus Merida" -> "Jesús Mérida", @@ -338,6 +340,7 @@ object PhotographerRenamer extends MetadataCleaner { "Klebher Vasquez" -> "Klebher Vásquez", "Koca Sulejmanovic" -> "Koca Sulejmanović", "Krisztian Elek" -> "Krisztián Elek", + "Krzysztof Cwik" -> "Krzysztof Ćwik", "Krzysztof Swiderski" -> "Krzysztof Świderski", "Kuba Stezycki" -> "Kuba Stężycki", "Laszlo Balogh" -> "László Balogh", @@ -378,6 +381,7 @@ object PhotographerRenamer extends MetadataCleaner { "Manu Fernandez" -> "Manu Fernández", "Manuel Vazquez" -> "Manuel Vázquez", "Manuel Velazquez" -> "Manuel Velázquez", + "Marek Antoni Iwanczuk" -> "Marek Antoni Iwańczuk", "Marc Mccormack" -> "Marc McCormack", "Marcelo Del Pozo" -> "Marcelo del Pozo", "Marcelo Hernandez" -> "Marcelo Hernández", @@ -419,6 +423,7 @@ object PhotographerRenamer extends MetadataCleaner { "Milos Bicanski" -> "Miloš Bičanski", "Milos Vujovic" -> "Miloš Vujović", "Miro Kuzmanovic" -> "Miro Kuzmanović", + "Mitar Mitrovic" -> "Mitar Mitrović", "Moises Castillo" -> "Moisés Castillo", "Morne de Klerk" -> "Morné de Klerk", "Murat Ozgur Guvendik" -> "Murat Özgür Güvendik", @@ -482,6 +487,7 @@ object PhotographerRenamer extends MetadataCleaner { "Radoslaw Jozwiak" -> "Radosław Jóźwiak", "Rafal Gaglewski" -> "Rafał Gąglewski", "Rafal Guz" -> "Rafał Guz", + "Raimonda Kulikauskiene" -> "Raimonda Kulikauskienė", "Ramon Buxo Martinez" -> "Ramon Buxó Martínez", "Ramon Costa" -> "Ramón Costa", "Ramon de la Rocha" -> "Ramón de la Rocha", diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/RedundantTokenRemover.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/RedundantTokenRemover.scala index d61b2b5b008..e6a3afa064e 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/RedundantTokenRemover.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/RedundantTokenRemover.scala @@ -20,6 +20,8 @@ object RedundantTokenRemover extends MetadataCleaner { "stringer", "Stringer", "Stringer .", + "str", + "Str", "STR", "STR New", "-STR", diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/SupplierProcessors.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/SupplierProcessors.scala index 9af0ee47f12..a62276b1618 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/SupplierProcessors.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/cleanup/SupplierProcessors.scala @@ -1,9 +1,11 @@ package com.gu.mediaservice.lib.cleanup -import com.gu.mediaservice.lib.config.{RuntimeUsageRightsConfig, UsageRightsConfigProvider} +import com.gu.mediaservice.lib.config.UsageRightsConfigProvider import com.gu.mediaservice.lib.metadata.UsageRightsMetadataMapper import com.gu.mediaservice.model._ +import scala.util.matching.Regex + /** * This is largely generic or close to generic processing aside from the Guardian Photographer parser. */ @@ -426,19 +428,74 @@ object RexParser extends ImageProcessor { val SlashRex = ".+/ Rex Features".r def apply(image: Image): Image = { + (image.metadata.source, image.metadata.credit) match { + // TODO: cleanup byline/credit + case (Some("Rex Features"), _) + | (_, Some(SlashRex())) + | (Some("REX/Shutterstock"), _) + | (Some("Shutterstock"), _) + | (Some("Shutterstock Editorial"), _) => format(image) + case _ => image + } + } + + private def matchMandatoryCreditBylines(suppliersReference: String) = s"Mandatory Credit: Photo by (.*) \\(${Regex.quote(suppliersReference)}\\)\n" + + private def format(image: Image): Image = { val usageRights: UsageRights = if (image.metadata.specialInstructions exists(_.toLowerCase.startsWith("exclusive"))) NoRights else rexAgency - (image.metadata.source, image.metadata.credit) match { - // TODO: cleanup byline/credit - case (Some("Rex Features"), _) => image.copy(usageRights = usageRights) - case (_, Some(SlashRex())) => image.copy(usageRights = usageRights) - case (Some("REX/Shutterstock"), _) => image.copy(usageRights = usageRights) - case (Some("Shutterstock"), _) => image.copy(usageRights = usageRights) - case (Some("Shutterstock Editorial"), _) => image.copy(usageRights = usageRights) - case _ => image - } + def removeSpecialInstructions(description: String) = + image.metadata.specialInstructions + .map(specialInstructions => description.replaceAll(s"${Regex.quote(specialInstructions)}\n", "")) + .getOrElse(description) + + /** + * Does the image metadata include every byline in the image description's credit line? + * + * An example credit line might look like: + * + * `Mandatory Credit: Photo by Action Press/Shutterstock (16512200n)` + * + * If the image metadata contained + * + * "credit" -> "ITV/Shutterstock" + * "byline" -> "Action Press" + * + * then this function would return `true`. + */ + def imageMetadataAccountsForCreditLine(description: String, suppliersReference: String) = { + val bylinesInMetadata = image.metadata.byline.toList ++ image.metadata.credit.toList.flatMap(_.split("/").toList) + + val maybeBylinesInCreditLine = matchMandatoryCreditBylines(suppliersReference) + .r + .findFirstMatchIn(description) + .flatMap(_.subgroups.headOption) + + maybeBylinesInCreditLine.forall { bylinesInCreditLine => + val bylinesWithMetadataRemoved = bylinesInMetadata + // Remove all the bylines from the credit string + .foldLeft(bylinesInCreditLine.toLowerCase)((desc, toRemove) => desc.replaceAll(toRemove.toLowerCase, "")) + // Get rid of whitespace and delimiters + .replaceAll("[\\s/]", "") + + bylinesWithMetadataRemoved.isEmpty + } + } + + def removeCredit(description: String): String = + image.metadata.suppliersReference match { + case Some(suppliersReference) if imageMetadataAccountsForCreditLine(description, suppliersReference) => + description.replaceAll(matchMandatoryCreditBylines(suppliersReference), "") + case _ => description + } + + val description = image.metadata.description + .map(removeSpecialInstructions) + .map(removeCredit) + + image.copy(usageRights = usageRights, metadata = image.metadata.copy(description = description)) } } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/collections/CollectionPaths.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/collections/CollectionPaths.scala new file mode 100644 index 00000000000..c9bb0584b5d --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/collections/CollectionPaths.scala @@ -0,0 +1,18 @@ +package com.gu.mediaservice.lib.collections + +trait CollectionPaths { + + private val delimiter = "/" + private val doublequotes = "\"" + + def stringToPath(s: String): List[String] = s.split(delimiter).toList + + def pathToString(path: Seq[String]): String = path.mkString(delimiter) + + def pathToPathId(path: Seq[String]): String = pathToString(path).toLowerCase + + + // We could use `ValidationNel`s here, but that's overkill + def isValidPathBit(s: String): Boolean = if (s.contains(delimiter) || s.contains(doublequotes)) false else true + +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/collections/CollectionsManager.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/collections/CollectionsManager.scala index 28f53338611..d8100351c0d 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/collections/CollectionsManager.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/collections/CollectionsManager.scala @@ -4,14 +4,10 @@ import com.gu.mediaservice.lib.net.URI.{encode, decode} import com.gu.mediaservice.model.Collection -object CollectionsManager { - val delimiter = "/" - val doublequotes = "\"" +object CollectionsManager extends CollectionPaths with CssColours { - def stringToPath(s: String) = s.split(delimiter).toList - def pathToString(path: List[String]) = path.mkString(delimiter) - def pathToPathId(path: List[String]) = pathToString(path).toLowerCase def pathToUri(path: List[String]) = pathToString(path.map(encode)) + def uriToPath(uri: String) = stringToPath(decode(uri)) def sortBy(c: Collection) = c.pathId @@ -30,6 +26,9 @@ object CollectionsManager { case (collection, i) if collection.path == path => i } + def filterCollectionsByIndexes(indexes: List[Int], collections: List[Collection]) = { + collections.zipWithIndex.filterNot({case(_, i) => indexes.contains(i)}).map(_._1) + } def onlyLatest(collections: List[Collection]): List[Collection] = collections filter { collection => // if there isn't a collection with the same path created after itself. @@ -38,22 +37,4 @@ object CollectionsManager { }} } - // We could use `ValidationNel`s here, but that's overkill - def isValidPathBit(s: String) = if (s.contains(delimiter) || s.contains(doublequotes)) false else true - - // These use Source swatches - val collectionColours = Map( - "australia" -> "#185E36", - "culture" -> "#BB3B80", - "film & music" -> "#6B5840", - "g2" -> "#121212", - "guide" -> "#7D0068", - "observer" -> "#052962", - "sport" -> "#22874D", - "travel" -> "#041F4A" - ) - - def getCollectionColour(s: String) = collectionColours.get(s) - - def getCssColour(path: List[String]) = path.headOption.map(_.toLowerCase).flatMap(getCollectionColour) } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/collections/CssColours.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/collections/CssColours.scala new file mode 100644 index 00000000000..2c0216882b3 --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/collections/CssColours.scala @@ -0,0 +1,35 @@ +package com.gu.mediaservice.lib.collections + +trait CssColours extends CollectionPaths { + + // These use Source swatches + private val collectionColours = Map( + "home/biz & cash" -> "#c98a07", + "home/home news" -> "#022164", + "home/international" -> "#022164", + "home/ofm" -> "#f2327d", + "home/magazine" -> "#5b1e4a", + "home/sensemakers" -> "#3c9bf9", + "home/sport" -> "#00663b", + "home/supplements" -> "#008083", + "home" -> "#052962" + ) + + def getCssColour(path: List[String]): Option[String] = { + def forPath(depth: Int, default: Option[String]): Option[String] = { + if (depth > path.size) { + default + } else { + val pathId = pathToPathId(path.take(depth)) + collectionColours.get(pathId).map { colour => + forPath(depth + 1, Some(colour)) + }.getOrElse { + default + } + } + } + // recurse drop the path return the furthest leaf node + forPath(depth = 1, default = None) + } + +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfig.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfig.scala index ce25eec81eb..e9c45ead280 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfig.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfig.scala @@ -1,10 +1,12 @@ package com.gu.mediaservice.lib.config -import com.gu.mediaservice.lib.aws.{AwsClientV1BuilderUtils, AwsClientV2BuilderUtils, KinesisSenderConfig} +import com.amazonaws.services.s3.AmazonS3 +import com.gu.mediaservice.lib.aws.{AwsClientV1BuilderUtils, AwsClientV2BuilderUtils, KinesisSenderConfig, S3, S3Bucket, S3Ops} import com.gu.mediaservice.model.UsageRightsSpec import com.typesafe.config.Config import com.typesafe.scalalogging.StrictLogging import play.api.{ConfigLoader, Configuration} +import scalaz.NonEmptyList import java.net.URI import java.util.UUID @@ -32,6 +34,8 @@ abstract class CommonConfig(resources: GridConfigResources) extends AwsClientV1B val localLogShipping: Boolean = sys.env.getOrElse("LOCAL_LOG_SHIPPING", "false").toBoolean + val thrallAppName = stringOpt("thrall.kinesis.app.name").getOrElse("thrall") + val thrallLowPriorityAppName = stringOpt("thrall.kinesis.lowPriority.app.name").getOrElse("thrall-low-priority") val thrallKinesisStream = string("thrall.kinesis.stream.name") val thrallKinesisLowPriorityStream = string("thrall.kinesis.lowPriorityStream.name") @@ -50,34 +54,61 @@ abstract class CommonConfig(resources: GridConfigResources) extends AwsClientV1B lazy val softDeletedMetadataTable: String = string("dynamo.table.softDelete.metadata") + val googleS3AccessKey: Option[String] = stringOpt("s3.accessKey") + val googleS3SecretKey: Option[String] = stringOpt("s3.secretKey") + + private val amazonS3: AmazonS3 = S3Ops.buildS3Client(this) + private val googleS3: Option[AmazonS3] = S3Ops.buildGoogleS3Client(this) + private val localS3: Option[AmazonS3] = S3Ops.buildLocalS3Client(this) + + def clientFor(bucketEndpoint: String): AmazonS3 = { + (bucketEndpoint match { + case "storage.googleapis.com" => + googleS3 + case "minio.griddev.eelpieconsulting.co.uk" => + localS3 + case _ => + Some(amazonS3) + }).getOrElse { + amazonS3 + } + } + val maybeIngestSqsQueueUrl: Option[String] = stringOpt("sqs.ingest.queue.url") - val maybeIngestBucket: Option[String] = stringOpt("s3.ingest.bucket") - val maybeFailBucket: Option[String] = stringOpt("s3.fail.bucket") + val maybeIngestBucket: Option[S3Bucket] = for { + ingestBucket <- stringOpt("s3.ingest.bucket.name") + ingestBucketEndpoint <- stringOpt("s3.ingest.bucket.endpoint") + } yield { + S3Bucket(ingestBucket, ingestBucketEndpoint, usesPathStyleURLs = booleanOpt("s3.ingest.bucket.pathStyleURLs").getOrElse(false), clientFor(ingestBucketEndpoint)) + } + val maybeFailBucket: Option[S3Bucket] = for { + failBucket <- stringOpt("s3.fail.bucket.name") + failBucketEndpoint <- stringOpt("s3.fail.bucket.endpoint") + } yield { + S3Bucket(failBucket, failBucketEndpoint, usesPathStyleURLs = booleanOpt("s3.fail.bucket.pathStyleURLs").getOrElse(false), clientFor(failBucketEndpoint)) + } + + val maybeQuarantineBucket: Option[S3Bucket] = stringOpt("s3.quarantine.bucket.name").map(S3Bucket(_, S3.AmazonAwsS3Endpoint, booleanOpt("s3.quarantine.bucket.pathStyleURLs").getOrElse(false), clientFor(S3.AmazonAwsS3Endpoint))) + + val maybeBucketForUIUploads: Option[S3Bucket] = maybeQuarantineBucket orElse maybeIngestBucket - val maybeUploadLimitInBytes: Option[Int] = intOpt("upload.limit.mb").map(_ * 1_000_000) + val maybeUploadLimitInBytes: Option[Int] = intOpt("upload.limit.mb").map(_ * 1024 * 1024) + + val instancesEndpoint: String = string("instance.service.instances") // Note: had to make these lazy to avoid init order problems ;_; val domainRoot: String = string("domain.root") val domainRootOverride: Option[String] = stringOpt("domain.root-override") val rootAppName: String = stringDefault("app.name.root", "media") - val serviceHosts = ServiceHosts( - stringDefault("hosts.kahunaPrefix", s"$rootAppName."), - stringDefault("hosts.apiPrefix", s"api.$rootAppName."), - stringDefault("hosts.loaderPrefix", s"loader.$rootAppName."), - stringDefault("hosts.projectionPrefix", s"loader-projection.$rootAppName."), - stringDefault("hosts.cropperPrefix", s"cropper.$rootAppName."), - stringDefault("hosts.metadataPrefix", s"$rootAppName-metadata."), - stringDefault("hosts.imgopsPrefix", s"$rootAppName-imgops."), - stringDefault("hosts.usagePrefix", s"$rootAppName-usage."), - stringDefault("hosts.collectionsPrefix", s"$rootAppName-collections."), - stringDefault("hosts.leasesPrefix", s"$rootAppName-leases."), - stringDefault("hosts.authPrefix", s"$rootAppName-auth."), - stringDefault("hosts.thrallPrefix", s"thrall.$rootAppName.") - ) val corsAllowedOrigins: Set[String] = getStringSet("security.cors.allowedOrigins") - val services = new Services(domainRoot, serviceHosts, corsAllowedOrigins, domainRootOverride) + val services = new SingleHostServices(domainRoot) + + private val imageBucketEndpoint = string("s3.image.bucket.endpoint") + val imageBucket: S3Bucket = S3Bucket(string("s3.image.bucket.name"), imageBucketEndpoint, usesPathStyleURLs = booleanOpt("s3.image.bucket.pathStyleURLs").getOrElse(false), clientFor(imageBucketEndpoint)) + private val thumbBucketEndpoint = string("s3.thumb.bucket.endpoint") + val thumbnailBucket: S3Bucket = S3Bucket(string("s3.thumb.bucket.name"), thumbBucketEndpoint, usesPathStyleURLs = booleanOpt("s3.thumb.bucket.pathStyleURLs").getOrElse(false), clientFor(thumbBucketEndpoint)) /** * Load in a list of domain metadata specifications from configuration. For example: @@ -120,6 +151,9 @@ abstract class CommonConfig(resources: GridConfigResources) extends AwsClientV1B val recordDownloadAsUsage: Boolean = boolean("image.record.download") val shortenDownloadFilename: Boolean = boolean("image.download.shorten") + val myInstancesEndpoint: String = string("instance.service.my") + + val usageEventsQueueName: String = string("usageEvents.queue.name") /** * Load in a list of external staff photographers, internal staff photographers, contracted photographers, @@ -210,6 +244,10 @@ abstract class CommonConfig(resources: GridConfigResources) extends AwsClientV1B val applicableUsageRights: Seq[UsageRightsSpec] = configuration.get[Seq[UsageRightsSpec]]("usageRights.applicable") val stdUserExcludedUsageRights = getStringSet("usageRights.stdUserExcluded") + val agencyPicksIngredients: Option[Map[String, Seq[String]]] = + configuration.getOptional[Map[String, Seq[String]]]("agencyPicks.ingredients") + val agencyPicksColour: String = stringDefault("agencyPicks.colour", "#7d0068") + private def getKinesisConfigForStream(streamName: String) = KinesisSenderConfig(awsRegion, awsCredentials, awsLocalEndpoint, isDev, streamName) final def getOptionalStringSet(key: String): Option[Set[String]] = Try { @@ -248,4 +286,5 @@ abstract class CommonConfig(resources: GridConfigResources) extends AwsClientV1B private def missing(key: String, type_ : String): Nothing = sys.error(s"Required $type_ configuration property missing: $key") + } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfigWithElastic.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfigWithElastic.scala index dddb14c0d31..d80441453c1 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfigWithElastic.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfigWithElastic.scala @@ -14,9 +14,6 @@ class CommonConfigWithElastic(resources: GridConfigResources) extends CommonConf replicas = string("es6.replicas").toInt ) - val persistenceIdentifier = string("persistence.identifier") - val queriableIdentifiers = Seq(persistenceIdentifier) - // note this will match any part of the collection path, e.g. "bar" will match "bar", "foo/bar", "bar/baz" val maybePersistOnlyTheseCollections: Option[Set[String]] = getOptionalStringSet("persistence.onlyTheseCollections") } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/GridConfigLoader.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/GridConfigLoader.scala index 7484ccf8103..1e027209b95 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/GridConfigLoader.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/GridConfigLoader.scala @@ -57,7 +57,10 @@ object GridConfigLoader extends StrictLogging { if (file.getPath.endsWith(".properties")) { logger.warn(s"Configuring the Grid with Java properties files is deprecated as of #3011, please switch to .conf files. See #3037 for a conversion utility.") } - Configuration(ConfigFactory.parseFile(file)) + val parsed = ConfigFactory.parseFile(file) + logger.info(s"Resolving config parsed from file: $file") + val resolved = parsed.resolve() + Configuration(resolved) } else { logger.info(s"Skipping config file $file as it doesn't exist") Configuration.empty diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/InstanceForRequest.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/InstanceForRequest.scala new file mode 100644 index 00000000000..45da79a9c95 --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/InstanceForRequest.scala @@ -0,0 +1,13 @@ +package com.gu.mediaservice.lib.config + +import com.gu.mediaservice.model.Instance +import play.api.mvc.RequestHeader + +trait InstanceForRequest { + + def instanceOf(request: RequestHeader): Instance = { + // TODO some sort of filter supplied attribute + Instance(request.host.split("\\.").head) + } + +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/Services.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/Services.scala index 896acf8549f..57f7ffc96d9 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/Services.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/Services.scala @@ -1,91 +1,80 @@ package com.gu.mediaservice.lib.config -case class ServiceHosts( - kahunaPrefix: String, - apiPrefix: String, - loaderPrefix: String, - projectionPrefix: String, - cropperPrefix: String, - metadataPrefix: String, - imgopsPrefix: String, - usagePrefix: String, - collectionsPrefix: String, - leasesPrefix: String, - authPrefix: String, - thrallPrefix: String -) - -object ServiceHosts { - // this is tightly coupled to the Guardian's deployment. - // TODO make more generic but w/out relying on Play config - def guardianPrefixes: ServiceHosts = { - val rootAppName: String = "media" - - ServiceHosts( - kahunaPrefix = s"$rootAppName.", - apiPrefix = s"api.$rootAppName.", - loaderPrefix = s"loader.$rootAppName.", - projectionPrefix = s"loader-projection.$rootAppName", - cropperPrefix = s"cropper.$rootAppName.", - metadataPrefix = s"$rootAppName-metadata.", - imgopsPrefix = s"$rootAppName-imgops.", - usagePrefix = s"$rootAppName-usage.", - collectionsPrefix = s"$rootAppName-collections.", - leasesPrefix = s"$rootAppName-leases.", - authPrefix = s"$rootAppName-auth.", - thrallPrefix = s"thrall.$rootAppName." - ) - } +import com.gu.mediaservice.model.Instance + +trait Services { + + def kahunaBaseUri(instance: Instance): String + + def apiBaseUri(instance: Instance): String + + def loaderBaseUri(instance: Instance): String + + def projectionBaseUri(instance: Instance): String + + def cropperBaseUri(instance: Instance): String + + def metadataBaseUri(instance: Instance): String + + def imgopsBaseUri(instance: Instance): String + + def usageBaseUri(instance: Instance): String + + def collectionsBaseUri(instance: Instance): String + + def leasesBaseUri(instance: Instance): String + + def authBaseUri(instance: Instance): String + def authBaseInstanceUri(instance: Instance): String + + def guardianWitnessBaseUri: String + + def corsAllowedDomains(instance: Instance): Set[String] + + def redirectUriParam: String + + def redirectUriPlaceholder: String + + def loginUriTemplate(instance: Instance): String } -class Services(val domainRoot: String, hosts: ServiceHosts, corsAllowedOrigins: Set[String], domainRootOverride: Option[String] = None) { - val kahunaHost: String = s"${hosts.kahunaPrefix}$domainRoot" - val apiHost: String = s"${hosts.apiPrefix}$domainRoot" - val loaderHost: String = s"${hosts.loaderPrefix}${domainRootOverride.getOrElse(domainRoot)}" - val cropperHost: String = s"${hosts.cropperPrefix}${domainRootOverride.getOrElse(domainRoot)}" - val metadataHost: String = s"${hosts.metadataPrefix}${domainRootOverride.getOrElse(domainRoot)}" - val imgopsHost: String = s"${hosts.imgopsPrefix}${domainRootOverride.getOrElse(domainRoot)}" - val usageHost: String = s"${hosts.usagePrefix}${domainRootOverride.getOrElse(domainRoot)}" - val collectionsHost: String = s"${hosts.collectionsPrefix}${domainRootOverride.getOrElse(domainRoot)}" - val leasesHost: String = s"${hosts.leasesPrefix}${domainRootOverride.getOrElse(domainRoot)}" - val authHost: String = s"${hosts.authPrefix}$domainRoot" - val projectionHost: String = s"${hosts.projectionPrefix}${domainRootOverride.getOrElse(domainRoot)}" - val thrallHost: String = s"${hosts.thrallPrefix}${domainRootOverride.getOrElse(domainRoot)}" - - - val kahunaBaseUri = baseUri(kahunaHost) - val apiBaseUri = baseUri(apiHost) - val loaderBaseUri = baseUri(loaderHost) - val projectionBaseUri = baseUri(projectionHost) - val cropperBaseUri = baseUri(cropperHost) - val metadataBaseUri = baseUri(metadataHost) - val imgopsBaseUri = baseUri(imgopsHost) - val usageBaseUri = baseUri(usageHost) - val collectionsBaseUri = baseUri(collectionsHost) - val leasesBaseUri = baseUri(leasesHost) - val authBaseUri = baseUri(authHost) - val thrallBaseUri = baseUri(thrallHost) - - val allInternalUris = Seq( - kahunaBaseUri, - apiBaseUri, - loaderBaseUri, - cropperBaseUri, - metadataBaseUri, - usageBaseUri, - collectionsBaseUri, - leasesBaseUri, - authBaseUri, - thrallBaseUri - ) +protected class SingleHostServices(val domain: String) extends Services { + override def kahunaBaseUri(instance: Instance): String = vhostServiceName("", instance) + + override def apiBaseUri(instance: Instance): String = vhostServiceName("media-api", instance) + + override def loaderBaseUri(instance: Instance): String = vhostServiceName("image-loader", instance) + + override def projectionBaseUri(instance: Instance): String = vhostServiceName("image-loader", instance) + + override def cropperBaseUri(instance: Instance): String = vhostServiceName("cropper", instance) + + override def metadataBaseUri(instance: Instance): String = vhostServiceName("metadata-editor", instance) + + override def imgopsBaseUri(instance: Instance): String= vhostServiceName("imgproxy", instance) + + override def usageBaseUri(instance: Instance): String = vhostServiceName("usage", instance) + + override def collectionsBaseUri(instance: Instance): String = vhostServiceName("collections", instance) + + override def leasesBaseUri(instance: Instance): String = vhostServiceName("leases", instance) + + override def authBaseUri(instance: Instance): String = s"https://$domain/auth" + override def authBaseInstanceUri(instance: Instance): String = vhostServiceName("auth", instance) + + private def thrallBaseUri(instance: Instance): String = vhostServiceName("thrall", instance) val guardianWitnessBaseUri: String = "https://n0ticeapis.com" - val corsAllowedDomains: Set[String] = corsAllowedOrigins.map(baseUri) + kahunaBaseUri + apiBaseUri + thrallBaseUri + override def corsAllowedDomains(instance: Instance): Set[String] = Set(kahunaBaseUri(instance), apiBaseUri(instance), thrallBaseUri(instance)) val redirectUriParam = "redirectUri" val redirectUriPlaceholder = s"{?$redirectUriParam}" - val loginUriTemplate = s"$authBaseUri/login$redirectUriPlaceholder" + def loginUriTemplate(instance: Instance): String = s"${authBaseUri(instance)}/login$redirectUriPlaceholder" - def baseUri(host: String) = s"https://$host" + private def vhostServiceName(serviceName: String, instance: Instance): String = { + val vhost = instance.id + s"https://$vhost.$domain" + (if (serviceName.nonEmpty) "/" + serviceName else "") + } } + diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/ElasticSearchClient.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/ElasticSearchClient.scala index ee6ca13fbd4..887f2379813 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/ElasticSearchClient.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/ElasticSearchClient.scala @@ -26,15 +26,9 @@ trait ElasticSearchClient extends ElasticSearchExecutions with GridLogging { def url: String - def imagesCurrentAlias: String - def imagesMigrationAlias: String - lazy val imagesHistoricalAlias: String = "Images_Historical" - protected val imagesIndexPrefix = "images" protected val imageType = "image" - val initialImagesIndex = "images" - def shards: Int def replicas: Int @@ -45,12 +39,12 @@ trait ElasticSearchClient extends ElasticSearchExecutions with GridLogging { } //TODO: this function should fail and cause healthcheck fails - def ensureIndexExistsAndAliasAssigned(): Unit = { - logger.info(s"Checking alias $imagesCurrentAlias is assigned to index…") - val indexForCurrentAlias = Await.result(getIndexForAlias(imagesCurrentAlias), tenSeconds) + def ensureIndexExistsAndAliasAssigned(alias: String, index: String): Unit = { + logger.info(s"Checking alias $alias is assigned to index $index") + val indexForCurrentAlias = Await.result(getIndexForAlias(alias), tenSeconds) if (indexForCurrentAlias.isEmpty) { - createIndexIfMissing(initialImagesIndex) - assignAliasTo(initialImagesIndex, imagesCurrentAlias) + createIndexIfMissing(index) + assignAliasTo(index, alias) waitUntilHealthy() } } @@ -65,9 +59,7 @@ trait ElasticSearchClient extends ElasticSearchExecutions with GridLogging { } def healthCheck(): Future[Boolean] = { - implicit val logMarker: MarkerMap = MarkerMap() - val request = search(imagesCurrentAlias) limit 0 - executeAndLog(request, "Healthcheck").map { _ => true}.recover { case _ => false} + Future.successful(true) // TODO reimplement } case class IndexWithAliases(name: String, aliases: Seq[String]) @@ -80,7 +72,7 @@ trait ElasticSearchClient extends ElasticSearchExecutions with GridLogging { }) } - def countImages(indexName: String = imagesCurrentAlias): Future[ElasticSearchImageCounts] = { + def countImages(indexName: String): Future[ElasticSearchImageCounts] = { implicit val logMarker: MarkerMap = MarkerMap() val queryCatCount = catCount(indexName) // document count only of index including live documents, not deleted documents which have not yet been removed by the merge process val queryImageSearch = search(indexName) trackTotalHits true limit 0 // hits that match the query defined in the request @@ -181,7 +173,7 @@ trait ElasticSearchClient extends ElasticSearchExecutions with GridLogging { } } - def changeAliasTo(newIndex: String, oldIndex: String, alias: String = imagesCurrentAlias): Unit = { + def changeAliasTo(newIndex: String, oldIndex: String, alias: String): Unit = { logger.info(s"Assigning alias $alias to $newIndex") val aliasActionResponse = Await.result(client.execute { aliases( diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/MappingTest.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/MappingTest.scala index 7bee7a94524..440b327cfde 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/MappingTest.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/MappingTest.scala @@ -72,7 +72,8 @@ object MappingTest { lastModified = Some(imageModified), identifiers = Map("id1" -> "value1"), uploadInfo = UploadInfo( - filename = Some("filename.jpg") + filename = Some("filename.jpg"), + isFeedUpload = Some(true) ), source = testAsset, thumbnail = Some(Asset( @@ -182,8 +183,8 @@ object MappingTest { ), digitalUsageMetadata = Some(DigitalUsageMetadata( webUrl = new URI("https://gu.com/12345"), - webTitle = "Article title", - sectionId = "uk/news", + webTitle = Some("Article title"), + sectionId = Some("uk/news"), composerUrl = Some(new URI("https://composer/api/2345678987654321345678")) )), syndicationUsageMetadata = Some(SyndicationUsageMetadata( diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/Mappings.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/Mappings.scala index 72a60c45315..ac9021bea73 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/Mappings.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/Mappings.scala @@ -242,7 +242,8 @@ object Mappings { )) def uploadInfoMapping(name: String): ObjectField = nonDynamicObjectField(name).copy(properties = Seq( - keywordField("filename") + keywordField("filename"), + booleanField("isFeedUpload"), )) def usageReference(name: String): ObjectField = { diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/MigrationStatusProvider.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/MigrationStatusProvider.scala index 0622115303e..3080df636f4 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/MigrationStatusProvider.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/MigrationStatusProvider.scala @@ -1,12 +1,14 @@ package com.gu.mediaservice.lib.elasticsearch +import com.gu.mediaservice.lib.instances.InstancesClient +import com.gu.mediaservice.model.Instance import org.apache.pekko.actor.Scheduler -import com.sksamuel.elastic4s.Index +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicReference import scala.concurrent.Await -import scala.concurrent.duration.DurationInt import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration.{Duration, DurationInt, SECONDS} sealed trait MigrationStatus @@ -36,12 +38,24 @@ object MigrationStatusProvider { trait MigrationStatusProvider { self: ElasticSearchClient => + def elasticSearchConfig: ElasticSearchConfig + + def imagesCurrentAlias(instance: Instance): String = instance.id + "_" + elasticSearchConfig.aliases.current + def imagesMigrationAlias(instance: Instance): String = instance.id + "_" + elasticSearchConfig.aliases.migration + def imagesHistoricalAlias(instance: Instance): String = instance.id + "_" + "Images_Historical" + def scheduler: Scheduler - private val migrationStatusRef = new AtomicReference[MigrationStatus](fetchMigrationStatus(bubbleErrors = true)) + def instancesClient: InstancesClient + + private val migrationStatues: ConcurrentHashMap[String, AtomicReference[MigrationStatus]] = new ConcurrentHashMap() + + private def migrationStatusRef(instance: Instance): AtomicReference[MigrationStatus] = { + migrationStatues.getOrDefault(instance.id, new AtomicReference(fetchMigrationStatus(bubbleErrors = true, instance = instance))) + } - private def fetchMigrationStatus(bubbleErrors: Boolean): MigrationStatus = { - val statusFuture = getIndexForAlias(imagesMigrationAlias) + private def fetchMigrationStatus(bubbleErrors: Boolean, instance: Instance): MigrationStatus = { + val statusFuture = getIndexForAlias(imagesMigrationAlias(instance)) .map { case Some(index) if index.aliases.contains(MigrationStatusProvider.COMPLETION_PREVIEW_ALIAS) => CompletionPreview(index.name) case Some(index) if index.aliases.contains(MigrationStatusProvider.PAUSED_ALIAS) => Paused(index.name) @@ -54,30 +68,32 @@ trait MigrationStatusProvider { } catch { case e if !bubbleErrors => logger.error("Failed to get name of index for ongoing migration", e) - StatusRefreshError(cause = e, preErrorStatus = migrationStatusRef.get()) + StatusRefreshError(cause = e, preErrorStatus = migrationStatusRef(instance).get) } } - private def refreshMigrationStatus(): Unit = { - migrationStatusRef.set( - fetchMigrationStatus(bubbleErrors = false) - ) + private def refreshMigrationStatus(instance: Instance): Unit = { + migrationStatues.put(instance.id, new AtomicReference(fetchMigrationStatus(bubbleErrors = false, instance = instance))) } private val migrationStatusRefresher = scheduler.scheduleAtFixedRate( initialDelay = 0.seconds, - interval = 5.seconds - ) { () => refreshMigrationStatus() } - - def migrationStatus: MigrationStatus = migrationStatusRef.get() - def migrationIsInProgress: Boolean = migrationStatus.isInstanceOf[InProgress] - def refreshAndRetrieveMigrationStatus(): MigrationStatus = { - refreshMigrationStatus() - migrationStatus + interval = 1.minutes + ) { () => { + val instances = Await.result(instancesClient.getInstances(), Duration(10, SECONDS)) + instances.foreach(refreshMigrationStatus) + } + } + + def migrationStatus(implicit instance: Instance): MigrationStatus = migrationStatusRef(instance).get() + def migrationIsInProgress(implicit instance: Instance): Boolean = migrationStatus.isInstanceOf[InProgress] + def refreshAndRetrieveMigrationStatus(instance: Instance): MigrationStatus = { + refreshMigrationStatus(instance) + migrationStatus(instance) } - def migrationStatusRefresherHealth: Option[String] = { - migrationStatusRef.get() match { + def migrationStatusRefresherHealth(implicit instance: Instance): Option[String] = { + migrationStatusRef(instance).get match { case StatusRefreshError(_, _) => Some("Could not determine status of migration") case _ => None } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/PersistedQueries.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/PersistedQueries.scala index 424983baa34..261318cb870 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/PersistedQueries.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/PersistedQueries.scala @@ -25,8 +25,6 @@ object PersistedQueries extends ImageFields { val hasCrops = filters.bool().must(filters.existsOrMissing("exports", exists = true)) val usedInContent = filters.nested("usages", filters.exists(NonEmptyList("usages"))) - def existedPreGrid(persistenceIdentifier: String) = filters.exists(NonEmptyList(identifierField(persistenceIdentifier))) - val addedToLibrary = filters.bool().must(filters.boolTerm(editsField("archived"), value = true)) val hasUserEditsToImageMetadata = filters.exists(NonEmptyList(editsField("metadata"))) val hasPhotographerUsageRights = filters.bool().must(filters.terms(usageRightsField("category"), photographerCategories)) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/ReapableEligibility.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/ReapableEligibility.scala index 1e52d4d8888..1b5301ddcd6 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/ReapableEligibility.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/ReapableEligibility.scala @@ -13,10 +13,9 @@ trait ReapableEligibility extends Provider{ val maybePersistOnlyTheseCollections: Option[Set[String]] // typically from config - val persistenceIdentifier: String // typically from config private def moreThanTwentyDaysOld = - filters.date("uploadTime", None, Some(DateTime.now().minusDays(20))).getOrElse(matchAllQuery()) + filters.date("uploadTime", None, Some(DateTime.now().minusDays(ReapableEligibility.ReapableAfterMoreThanDaysOld))).getOrElse(matchAllQuery()) private lazy val persistedQueries = filters.or( PersistedQueries.hasCrops, @@ -29,12 +28,25 @@ trait ReapableEligibility extends Provider{ PersistedQueries.addedToPhotoshoot, PersistedQueries.hasLabels, PersistedQueries.hasLeases, - PersistedQueries.existedPreGrid(persistenceIdentifier), PersistedQueries.isInPersistedCollection(maybePersistOnlyTheseCollections) ) + private def isFeedUpload = + filters.boolTerm("uploadInfo.isFeedUpload", value = true) + def query: Query = filters.and( moreThanTwentyDaysOld, + isFeedUpload, + filters.not(persistedQueries) + ) + + def preview: Query = filters.and( + isFeedUpload, filters.not(persistedQueries) ) + +} + +object ReapableEligibility { + val ReapableAfterMoreThanDaysOld: Int = 20 } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/filters.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/filters.scala index 47f30078c41..98ed3a4d4ba 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/filters.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/elasticsearch/filters.scala @@ -56,6 +56,8 @@ object filters { def ids(idList: List[String]): Query = idsQuery(idList) + def pinnedIds(idList: List[String]): Query = pinnedQuery(ids = idList, organic = matchNoneQuery()) + def bool() = BoolQuery() def mustNot(queries: Query*): Query = ElasticDsl.not(queries) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/events/UsageEvents.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/events/UsageEvents.scala new file mode 100644 index 00000000000..6b4265ee654 --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/events/UsageEvents.scala @@ -0,0 +1,97 @@ +package com.gu.mediaservice.lib.events + +import org.apache.pekko.actor.{Actor, ActorSystem, Props} +import org.apache.pekko.pattern.ask +import org.apache.pekko.util.Timeout +import com.gu.mediaservice.lib.logging.GridLogging +import com.gu.mediaservice.model.Instance +import org.joda.time.DateTime +import play.api.inject.ApplicationLifecycle +import play.api.libs.json.{JodaWrites, Json, OWrites} +import software.amazon.awssdk.services.sqs.SqsClient +import software.amazon.awssdk.services.sqs.model.SendMessageRequest + +import scala.concurrent.duration.DurationInt +import scala.util.Random + +class UsageEvents(actorSystem: ActorSystem, applicationLifecycle: ApplicationLifecycle, sqsClient: SqsClient, queueUrl: String) { + + private val random = new Random() + private val usageEventsActor = actorSystem.actorOf(UsageEventsActor.props(sqsClient, queueUrl), s"usageeventsactor-${random.alphanumeric.take(8).mkString}") + + applicationLifecycle.addStopHook(() => (usageEventsActor ? UsageEventsActor.Shutdown)(Timeout(5.seconds))) + + def successfulIngestFromQueue(instance: Instance, image: String, filesize: Long): Unit = { + usageEventsActor ! UsageEvent(`type` = "imageIngest", instance = instance.id, image = Some(image), filesize = Some(filesize)) + } + + def prepareUpload(instance: Instance, image: String, apiKey: Option[String], user: Option[String]): Unit = { + usageEventsActor ! UsageEvent(`type` = "prepareUpload", instance = instance.id, image = Some(image), apiKey = apiKey, user = user) + } + + def uploadImage(instance: Instance, image: String, filesize: Long, apiKey: Option[String], user: Option[String]): Unit = { + usageEventsActor ! UsageEvent(`type` = "imageUpload", instance = instance.id, image = Some(image), filesize = Some(filesize), apiKey = apiKey, user = user) + } + + def downloadOriginal(instance: Instance, image: String, filesize: Option[Long], apiKey: Option[String], user: Option[String]): Unit = { + usageEventsActor ! UsageEvent(`type` = "downloadOriginal", instance = instance.id, image = Some(image), filesize = filesize, apiKey = apiKey, user = user) + } + + def softDelete(instance: Instance, image: String): Unit = { + usageEventsActor ! UsageEvent(`type` = "softDelete", instance = instance.id, image = Some(image), filesize = None, apiKey = None, user = None) + } + + def unsoftDelete(instance: Instance, image: String): Unit = { + usageEventsActor ! UsageEvent(`type` = "unsoftDelete", instance = instance.id, image = Some(image), filesize = None, apiKey = None, user = None) + } + + def deleteImage(instance: Instance, image: String): Unit = { + usageEventsActor ! UsageEvent(`type` = "deleteImage", instance = instance.id, image = Some(image), filesize = None, apiKey = None, user = None) + } + + def hardDeleteImage(instance: Instance, image: String) = { + usageEventsActor ! UsageEvent(`type` = "hardDeleteImage", instance = instance.id, image = Some(image), filesize = None, apiKey = None, user = None) + } + + def apiKeyUsed(instance: Instance, apiKey: String) = { + usageEventsActor ! UsageEvent(`type` = "apiKeyUsed", instance = instance.id, apiKey = Some(apiKey)) + } + + def userAuthed(instance: Instance, user: String) = { + usageEventsActor ! UsageEvent(`type` = "userAuthed", instance = instance.id, user = Some(user)) + } +} + +case class UsageEvent(`type`: String, instance: String, + image: Option[String] = None, + filesize: Option[Long] = None, + date: DateTime = DateTime.now, + apiKey: Option[String] = None, + user: Option[String] = None) + +object UsageEvent extends JodaWrites { + implicit val uew: OWrites[UsageEvent] = Json.writes[UsageEvent] +} + + +object UsageEventsActor { + def props(sqsClient: SqsClient, queueUrl: String): Props = + Props(new UsageEventsActor(sqsClient, queueUrl)) + + final case object Shutdown +} + + +private class UsageEventsActor(sqsClient: SqsClient, queueUrl: String) extends Actor with GridLogging { + override def receive: Receive = { + case usageEvent: UsageEvent => + logger.info("Got usageEvent: " + usageEvent) + send(usageEvent) + } + + private def send(usageEvent: UsageEvent): Unit = { + import play.api.libs.json._ + sqsClient.sendMessage(SendMessageRequest.builder.queueUrl(queueUrl).messageBody(Json.stringify(Json.toJson(usageEvent))).build) + } + +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/guardian/GuardianUsageRightsConfig.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/guardian/GuardianUsageRightsConfig.scala index 3bdeb73b3dd..d96cca5d610 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/guardian/GuardianUsageRightsConfig.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/guardian/GuardianUsageRightsConfig.scala @@ -130,7 +130,6 @@ object GuardianUsageRightsConfig extends UsageRightsConfigProvider { PublicationPhotographer("Steve Bell"), )), PublicationPhotographers(ObserverPublication, List( - PublicationPhotographer("Chris Riddell"), PublicationPhotographer("David Foldvari"), PublicationPhotographer("David Simonds"), )) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/ImageOperations.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/ImageOperations.scala index d07fe814fa4..938e5a3b859 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/ImageOperations.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/ImageOperations.scala @@ -1,24 +1,28 @@ package com.gu.mediaservice.lib.imaging -import java.io._ -import org.im4java.core.IMOperation -import com.gu.mediaservice.lib.Files._ -import com.gu.mediaservice.lib.{BrowserViewableImage, StorableThumbImage} -import com.gu.mediaservice.lib.imaging.ImageOperations.{optimisedMimeType, thumbMimeType} -import com.gu.mediaservice.lib.imaging.im4jwrapper.ImageMagick.{addDestImage, addImage, format, runIdentifyCmd} -import com.gu.mediaservice.lib.imaging.im4jwrapper.{ExifTool, ImageMagick} +import app.photofox.vipsffm.enums.{VipsIntent, VipsInterpretation} +import app.photofox.vipsffm.jextract.VipsRaw +import app.photofox.vipsffm.{VBlob, VImage, VipsHelper, VipsOption} +import com.adobe.internal.xmp.options.SerializeOptions +import com.adobe.internal.xmp.{XMPConst, XMPMetaFactory} +import com.gu.mediaservice.lib.BrowserViewableImage +import com.gu.mediaservice.lib.imaging.ImageOperations.thumbMimeType +import com.gu.mediaservice.lib.imaging.im4jwrapper.ImageMagick import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker, Stopwatch, addLogMarkers} import com.gu.mediaservice.model._ +import org.im4java.core.IMOperation +import java.io._ +import java.lang.foreign.Arena +import java.nio.charset.StandardCharsets +import java.util import scala.concurrent.{ExecutionContext, Future} -import scala.sys.process._ case class ExportResult(id: String, masterCrop: Asset, othersizings: List[Asset]) class UnsupportedCropOutputTypeException extends Exception class ImageOperations(playPath: String) extends GridLogging { - import ExifTool._ import ImageMagick._ private def profilePath(fileName: String): String = s"$playPath/$fileName" @@ -36,97 +40,151 @@ class ImageOperations(playPath: String) extends GridLogging { "Greyscale" -> profilePath("grayscale.icc") ) - private def tagFilter(metadata: ImageMetadata) = { - Map[String, Option[String]]( - "Copyright" -> metadata.copyright, - "Credit" -> metadata.credit, - "OriginalTransmissionReference" -> metadata.suppliersReference - ).collect { case (key, Some(value)) => (key, value) } - } + def cropImageVips( + sourceFile: File, + bounds: Bounds, + metadata: ImageMetadata, + orientationMetadata: Option[OrientationMetadata] + )(implicit logMarker: LogMarker, arena: Arena): VImage = { + // Read source image + val image = VImage.newFromFile(arena, sourceFile.getAbsolutePath) + + // Orient + val rotated = orientationMetadata.map(_.orientationCorrection()).map { angle => + image.rotate(angle) + }.getOrElse { + image + } - private def applyOutputProfile(base: IMOperation, optimised: Boolean = false) = profile(base)(rgbProfileLocation(optimised)) - - // Optionally apply transforms to the base operation if the colour space - // in the ICC profile doesn't match the colour model of the image data - private def correctColour(base: IMOperation)(iccColourSpace: Option[String], colourModel: Option[String], isTransformedFromSource: Boolean)(implicit logMarker: LogMarker): IMOperation = { - (iccColourSpace, colourModel, isTransformedFromSource) match { - // If matching, all is well, just pass through - case (icc, model, _) if icc == model => base - // If no colour model detected, we can't do anything anyway so just hope all is well - case (_, None, _) => base - // Do not correct colour if file has already been transformed (ie. source file was TIFF) as correctColour has already been run - case (_, _, true) => base - // If mismatching, strip any (incorrect) ICC profile and inject a profile matching the model - // Note: Strip both ICC and ICM (Windows variant?) to be safe - case (icc, Some(model), _) => - profileLocations.get(model) match { - // If this is a supported model, strip profile from base and add profile for model - case Some(location) => profile(stripProfile(base)("icm,icc"))(location) - // Do not attempt to correct colour if we don't support that colour model - case None => - logger.warn( - logMarker, - s"Wanted to update colour model where iccColourSpace=$icc and colourModel=$model but we don't have a profile file for that model" - ) - base - } + val cropped = rotated.extractArea(bounds.x, bounds.y, bounds.width, bounds.height) + + // If we saw and ICC profile than we will need to transform + val needsICCTransform = VipsHelper.image_get_typeof(arena, image.getUnsafeStructAddress, "icc-profile-data") != 0 + val correctedForICCProfile = if (needsICCTransform) { + cropped.iccTransform("srgb", + VipsOption.Enum("intent", VipsIntent.INTENT_PERCEPTUAL), // Helps with CMYK; see https://github.com/libvips/libvips/issues/1110 + ) + } else { + // LAB gets corrupted by a needless icc_transform + cropped + } + + // Apply crop metadata + // https://developers.google.com/search/docs/appearance/structured-data/image-license-metadata#iptc-photo-metadata + makeXmpBlog(metadata).foreach { xmpBlob => + logger.info("Tagging master crop with XMP metadata: " + new String(xmpBlob)) + VipsHelper.image_set_blob_copy(arena, correctedForICCProfile.getUnsafeStructAddress, "xmp-data", VBlob.newFromBytes(arena, xmpBlob).getUnsafeDataAddress, xmpBlob.length) } + + correctedForICCProfile } - def cropImage( - sourceFile: File, - sourceMimeType: Option[MimeType], - bounds: Bounds, - qual: Double = 100d, - tempDir: File, - iccColourSpace: Option[String], - colourModel: Option[String], - fileType: MimeType, - isTransformedFromSource: Boolean, - orientationMetadata: Option[OrientationMetadata] - )(implicit logMarker: LogMarker): Future[File] = Stopwatch.async("magick crop image") { - for { - outputFile <- createTempFile(s"crop-", s"${fileType.fileExtension}", tempDir) - cropSource = addImage(sourceFile) - oriented = orient(cropSource, orientationMetadata) - qualified = quality(oriented)(qual) - corrected = correctColour(qualified)(iccColourSpace, colourModel, isTransformedFromSource) - converted = applyOutputProfile(corrected) - stripped = stripMeta(converted) - profiled = applyOutputProfile(stripped) - cropped = crop(profiled)(bounds) - depthAdjusted = depth(cropped)(8) - addOutput = addDestImage(depthAdjusted)(outputFile) - _ <- runConvertCmd(addOutput, useImageMagick = sourceMimeType.contains(Tiff)) - _ <- checkForOutputFileChange(outputFile) + private def makeXmpBlog(metadata: ImageMetadata): Option[Array[Byte]] = { + val mappings: Seq[(String, String, String)] = Seq(metadata.byline.map { creator => + (XMPConst.NS_DC, "creator", creator) + }, + metadata.credit.map { credit => + (XMPConst.NS_PHOTOSHOP, "Credit", credit) + }, + metadata.copyright.map { copyright => + (XMPConst.NS_DC, "rights", copyright) + }, + metadata.suppliersReference.map { suppliersReference => + (XMPConst.NS_PHOTOSHOP, "TransmissionReference", suppliersReference) + }).flatten + + mappings.headOption.map { _ => + val xmpMeta = XMPMetaFactory.create() + mappings.foreach { mapping => + xmpMeta.setProperty(mapping._1, mapping._2, mapping._3) + } + + val serializeOptions = new SerializeOptions() + serializeOptions.setUseCompactFormat(true) + serializeOptions.setUseCanonicalFormat(false) + val xmpXml = XMPMetaFactory.serializeToString(xmpMeta, serializeOptions) + xmpXml.getBytes(StandardCharsets.UTF_8) } - yield outputFile } - // Updates metadata on existing file - def appendMetadata(sourceFile: File, metadata: ImageMetadata): Future[File] = { - runExiftoolCmd( - setTags(tagSource(sourceFile))(tagFilter(metadata)) - ).map(_ => sourceFile) + def createCrops(sourceImage: VImage, dimensionList: List[Dimensions], imageId: String, bounds: Bounds, cropType: MimeType, tempDir: File, cropQuality: Int + )(implicit logMarker: LogMarker, instance: Instance, arena: Arena): Future[Seq[(File, String, Dimensions)]] = { + Stopwatch(s"Resizing crops for $imageId") { + logger.info("Starting resizes") + val resizes = dimensionList.map { dimensions => + val outputFile = File.createTempFile(s"resize-", s"${cropType.fileExtension}", tempDir) // TODO function for this + + logger.info("Starting resize for: " + dimensions) + resizeImageVips(sourceImage, dimensions, cropQuality, outputFile, cropType).map { f => + logger.info("Done resize for: " + dimensions) + + def outputFilename(imageId: String, bounds: Bounds, outputWidth: Int, fileType: MimeType, isMaster: Boolean = false, instance: Instance): String = { // TODO push back to Crops + val masterString: String = if (isMaster) "master/" else "" + instance.id + "/" + s"$imageId/${Crop.getCropId(bounds)}/$masterString$outputWidth${fileType.fileExtension}" + } + + val filename = outputFilename(imageId, bounds, dimensions.width, cropType, instance = instance) + (f, filename, dimensions) + } + } + logger.info("Done resizes") + Future.sequence(resizes) + } } - def resizeImage( - sourceFile: File, - sourceMimeType: Option[MimeType], - dimensions: Dimensions, - qual: Double = 100d, - tempDir: File, - fileType: MimeType - )(implicit logMarker: LogMarker): Future[File] = Stopwatch.async("magick resize image") { - for { - outputFile <- createTempFile(s"resize-", s".${fileType.fileExtension}", tempDir) - resizeSource = addImage(sourceFile) - qualified = quality(resizeSource)(qual) - resized = scale(qualified)(dimensions) - addOutput = addDestImage(resized)(outputFile) - _ <- runConvertCmd(addOutput, useImageMagick = sourceMimeType.contains(Tiff)) + def resizeImageVips( + sourceImage: VImage, + dimensions: Dimensions, + quality: Int = 100, + outputFile: File, + fileType: MimeType + )(implicit logMarker: LogMarker, arena: Arena): Future[File] = { + + def hasLabsColourspaceWithPotentiallyUnreliableAlpha(image: VImage): Boolean = { + VipsHelper.image_get_interpretation(image.getUnsafeStructAddress) == VipsInterpretation.INTERPRETATION_LABQ.getRawValue || + VipsHelper.image_get_interpretation(image.getUnsafeStructAddress) == VipsInterpretation.INTERPRETATION_LAB.getRawValue || + VipsHelper.image_get_interpretation(image.getUnsafeStructAddress) == VipsInterpretation.INTERPRETATION_LABS.getRawValue + } + + Future { + val inputWasLabs = hasLabsColourspaceWithPotentiallyUnreliableAlpha(sourceImage) + // Various LAB TIF files present in a colour space with 4 bands even though they have no alpha channel + // Shifting them makes the 4th band implies alpha check stable + val withColorspaceWithTrustedAlphaBands = if (inputWasLabs) { + val shifted = sourceImage.colourspace(VipsInterpretation.INTERPRETATION_sRGB) + logger.info("Shifted LAB image_get_interpretation " + VipsHelper.image_get_interpretation(sourceImage.getUnsafeStructAddress) + " to " + VipsHelper.image_get_interpretation(shifted.getUnsafeStructAddress)) + shifted + } else { + sourceImage + } + + val bandsCount = VipsRaw.vips_image_get_bands(withColorspaceWithTrustedAlphaBands.getUnsafeStructAddress) + val withBrokenLabAlphaDropped = if (inputWasLabs && bandsCount > 3) { + // We have not been able to extract a clean alpha mask from a LAB TIF. + // signed values and different scales may be at play. + // The best incremental improvement available is to drop the suspect alpha band. + logger.warn("Dropping suspected corrupt alpha band from LAB image") + val band1 = sourceImage.extractBand(0, VipsOption.Int("n", 1)) + val band2 = sourceImage.extractBand(1, VipsOption.Int("n", 1)) + val band3 = sourceImage.extractBand(2, VipsOption.Int("n", 1)) + val alphaBand: VImage = sourceImage.extractBand(3) //.cast(VipsBandFormat.FORMAT_UCHAR) + + val bandsToRejoin: util.ArrayList[VImage] = new java.util.ArrayList() + bandsToRejoin.add(band1) + bandsToRejoin.add(band2) + bandsToRejoin.add(band3) + VImage.bandjoin(arena, bandsToRejoin) + + } else { + withColorspaceWithTrustedAlphaBands + } + + // Resize the image and save + val scale = dimensions.width.toDouble / sourceImage.getWidth.toDouble + val resized = withBrokenLabAlphaDropped.resize(scale) + + saveImageToFile(resized, fileType, quality, outputFile, quantise = true, keep = Some(VipsRaw.VIPS_FOREIGN_KEEP_XMP)) } - yield outputFile } private def orient(op: IMOperation, orientationMetadata: Option[OrientationMetadata]): IMOperation = { @@ -137,192 +195,187 @@ class ImageOperations(playPath: String) extends GridLogging { } } - def optimiseImage(resizedFile: File, mediaType: MimeType)(implicit logMarker: LogMarker): File = mediaType match { - case Png => - val fileName: String = resizedFile.getAbsolutePath - - val optimisedImageName: String = fileName.split('.')(0) + "optimised.png" - Stopwatch("pngquant") { - Seq("pngquant", "-s10", "--quality", "1-85", fileName, "--output", optimisedImageName).! - } - - new File(optimisedImageName) - case Jpeg => resizedFile - - // This should never happen as we only ever crop as PNG or JPEG. See `Crops.cropType` and `CropsTest` - // TODO We should create a `CroppingMimeType` to enforce this at the type level. - // However we'd need to change the `Asset` model as source image and crop use this model - // and a source can legally be a `Tiff`. It's not a small change... - case Tiff => - logger.error("Attempting to optimize a Tiff crop. Cropping as Tiff is not supported.") - throw new UnsupportedCropOutputTypeException - } - - val thumbUnsharpRadius = 0.5d - val thumbUnsharpSigma = 0.5d - val thumbUnsharpAmount = 0.8d val interlacedHow = "Line" val backgroundColour = "#333333" /** - * Given a source file containing an image (the 'browser viewable' file), - * construct a thumbnail file in the provided temp directory, and return - * the file with metadata about it. - * @param browserViewableImage - * @param width Desired with of thumbnail - * @param qual Desired quality of thumbnail - * @param outputFile Location to create thumbnail file - * @param iccColourSpace (Approximately) number of colours to use - * @param colourModel Colour model - eg RGB or CMYK - * @return The file created and the mimetype of the content of that file, in a future. - */ - def createThumbnail(browserViewableImage: BrowserViewableImage, - width: Int, - qual: Double = 100d, - outputFile: File, - iccColourSpace: Option[String], - colourModel: Option[String], - orientationMetadata: Option[OrientationMetadata] - )(implicit logMarker: LogMarker): Future[(File, MimeType)] = { - val stopwatch = Stopwatch.start + * Given a source file containing an image (the 'browser viewable' file), + * construct a thumbnail file in the provided temp directory, and return + * the file with metadata about it. + * + * @param browserViewableImage + * @param width Desired with of thumbnail + * @param qual Desired quality of thumbnail + * @param outputFile Location to create thumbnail file + * @param orientationMetadata OrientationMetadata for rotation correction + * @return The file created and the mimetype of the content of that file and it's dimensions, in a future. + */ + def createThumbnailVips(browserViewableImage: BrowserViewableImage, + width: Int, + qual: Double = 100d, + outputFile: File, + orientationMetadata: Option[OrientationMetadata] + )(implicit logMarker: LogMarker): Future[(File, MimeType, Option[Dimensions])] = { + Future { + val stopwatch = Stopwatch.start + val arena = Arena.ofConfined + + try { + val thumbnail = VImage.thumbnail(arena, browserViewableImage.file.getAbsolutePath, width, + VipsOption.Boolean("auto-rotate", false), + VipsOption.Enum("intent", VipsIntent.INTENT_PERCEPTUAL), + VipsOption.String("export-profile", "srgb") + ) + val rotated = orientationMetadata.map(_.orientationCorrection()).map { angle => + logger.info("Rotating thumbnail: " + angle) + thumbnail.rotate(angle) + }.getOrElse { + thumbnail + } + logger.info("Created thumbnail: " + rotated.getWidth + "x" + rotated.getHeight) + saveImageToFile(rotated, Jpeg, qual.toInt, outputFile) - val cropSource = addImage(browserViewableImage.file) - val orientated = orient(cropSource, orientationMetadata) - val thumbnailed = thumbnail(orientated)(width) - val corrected = correctColour(thumbnailed)(iccColourSpace, colourModel, browserViewableImage.isTransformedFromSource) - val converted = applyOutputProfile(corrected, optimised = true) - val stripped = stripMeta(converted) - val profiled = applyOutputProfile(stripped, optimised = true) - val withBackground = setBackgroundColour(profiled)(backgroundColour) - val flattened = flatten(withBackground) - val unsharpened = unsharp(flattened)(thumbUnsharpRadius, thumbUnsharpSigma, thumbUnsharpAmount) - val qualified = quality(unsharpened)(qual) - val interlaced = interlace(qualified)(interlacedHow) - val addOutput = {file:File => addDestImage(interlaced)(file)} - for { - _ <- runConvertCmd(addOutput(outputFile), useImageMagick = browserViewableImage.mimeType == Tiff) - _ = logger.info(addLogMarkers(stopwatch.elapsed), "Finished creating thumbnail") - } yield (outputFile, thumbMimeType) - } + val thumbDimensions = Some(Dimensions(rotated.getWidth, rotated.getHeight)) + arena.close() - /** - * Given a source file containing a file which requires optimising to make it suitable for viewing in - * a browser, construct a new image file in the provided temp directory, and return - * * the file with metadata about it. - * @param sourceFile File containing browser viewable (ie not too big or colourful) image - * @param sourceMimeType Mime time of browser viewable file - * @param tempDir Location to create optimised file - * @return The file created and the mimetype of the content of that file, in a future. - */ - def transformImage(sourceFile: File, sourceMimeType: Option[MimeType], tempDir: File)(implicit logMarker: LogMarker): Future[(File, MimeType)] = { - val stopwatch = Stopwatch.start - for { - // png suffix is used by imagemagick to infer the required type - outputFile <- createTempFile(s"transformed-", optimisedMimeType.fileExtension, tempDir) - transformSource = addImage(sourceFile) - converted = applyOutputProfile(transformSource, optimised = true) - stripped = stripMeta(converted) - profiled = applyOutputProfile(stripped, optimised = true) - depthAdjusted = depth(profiled)(8) - addOutput = addDestImage(depthAdjusted)(outputFile) - _ <- runConvertCmd(addOutput, useImageMagick = sourceMimeType.contains(Tiff)) - _ <- checkForOutputFileChange(outputFile) - _ = logger.info(addLogMarkers(stopwatch.elapsed), "Finished creating browser-viewable image") - } yield (outputFile, optimisedMimeType) - } + logger.info(addLogMarkers(stopwatch.elapsed), "Finished creating thumbnail") + (outputFile, thumbMimeType, thumbDimensions) + + } catch { + case e: Throwable => + arena.close() + throw e + } - // When a layered tiff is unpacked, the temp file (blah.something) is moved - // to blah-0.something and contains the composite layer (which is what we want). - // Other layers are then saved as blah-1.something etc. - // As the file has been renamed, the file object still exists, but has the wrong name - // We will need to put it back where it is expected to be found, and clean up the other - // files. - private def checkForOutputFileChange(f: File): Future[Unit] = Future { - val fileBits = f.getAbsolutePath.split("\\.").toList - val mainPart = fileBits.dropRight(1).mkString(".") - val extension = fileBits.last - - // f2 is the blah-0 name that gets created from a layered tiff. - val f2 = new File(List(s"$mainPart-0", extension).mkString(".")) - if (f2.exists()) { - // f HAS been renamed to blah-0. Rename it right back! - f2.renameTo(f) - // Tidy up any other files (blah-1,2,3 etc will be created for each subsequent layer) - cleanUpLayerFiles(mainPart, extension, 1) + }.recoverWith { + case e: Throwable => + logger.error("Error creating thumbnail", e) + Future.failed(e) } } - @scala.annotation.tailrec - private def cleanUpLayerFiles(mainPart: String, extension: String, index: Int):Unit = { - val newFile = List(s"$mainPart-$index", extension).mkString(".") - val f3 = new File(newFile) - if (f3.exists()) { - f3.delete() - cleanUpLayerFiles(mainPart, extension, index+1) - } - } + def saveImageToFile(image: VImage, mimeType: MimeType, quality: Int, outputFile: File, quantise: Boolean = false, keep: Option[Int] = None): File = { + val k = keep.getOrElse(VipsRaw.VIPS_FOREIGN_KEEP_NONE) + mimeType match { + case Jpeg => + image.jpegsave(outputFile.getAbsolutePath, + VipsOption.Int("Q", quality), + //VipsOption.Boolean("optimize-scans", true), + VipsOption.Boolean("optimize-coding", true), + //VipsOption.Boolean("interlace", true), + //VipsOption.Boolean("trellis-quant", true), + // VipsOption.Int("quant-table", 3), + VipsOption.Boolean("strip", true), + VipsOption.Int("keep", k) + ) + outputFile + case Png => + // We are allowed to quantise PNG crops but not the master + if (quantise) { + image.pngsave(outputFile.getAbsolutePath, + VipsOption.Boolean("palette", true), + VipsOption.Int("Q", quality), + VipsOption.Int("effort", 1), + //VipsOption.Int("compression", 6), + VipsOption.Boolean("strip", true), + VipsOption.Int("keep", k) + ) + } else { + image.pngsave(outputFile.getAbsolutePath, + //VipsOption.Int("compression", 6), + VipsOption.Boolean("strip", true), + VipsOption.Int("keep", k) + ) + } + outputFile + + case _ => + logger.error(s"Save to $mimeType is not supported.") + throw new UnsupportedCropOutputTypeException + } + } } -object ImageOperations { +object ImageOperations extends GridLogging { val thumbMimeType = Jpeg val optimisedMimeType = Png - def identifyColourModel(sourceFile: File, mimeType: MimeType)(implicit ec: ExecutionContext, logMarker: LogMarker): Future[Option[String]] = { - // TODO: use mimeType to lookup other properties once we support other formats - mimeType match { - case Jpeg => - val source = addImage(sourceFile) - val formatter = format(source)("%[JPEG-Colorspace-Name]") - - for { - output <- runIdentifyCmd(formatter, false) - colourModel = output.headOption - } yield colourModel match { - case Some("GRAYSCALE") => Some("Greyscale") - case Some("CMYK") => Some("CMYK") - case _ => Some("RGB") - } - case Tiff => - val op = new IMOperation() - val formatter = format(op)("%[colorspace]") - val withSource = addDestImage(formatter)(sourceFile) - - for { - output <- runIdentifyCmd(withSource, true) - colourModel = output.headOption - } yield colourModel match { - case Some("sRGB") => Some("RGB") - case Some("Gray") => Some("Greyscale") - case Some("CIELab") => Some("LAB") - // IM returns doubles for TIFFs with transparency… - case Some("sRGBsRGB") => Some("RGB") - case Some("GrayGray") => Some("Greyscale") - case Some("CIELabCIELab") => Some("LAB") - case Some("CMYKCMYK") => Some("CMYK") - // …and triples for TIFFs with transparency and alpha channel(s). I think. - case Some("sRGBsRGBsRGB") => Some("RGB") - case Some("GrayGrayGray") => Some("Greyscale") - case Some("CIELabCIELabCIELab") => Some("LAB") - case Some("CMYKCMYKCMYK") => Some("CMYK") - case _ => colourModel + def getImageInformation(sourceFile: File)(implicit ec: ExecutionContext, logMarker: LogMarker): Future[(Option[Dimensions], Option[OrientationMetadata], Option[String], Map[String, String])] = { + val stopwatch = Stopwatch.start + Future { + var dimensions: Option[Dimensions] = None + var maybeExifOrientationWhichTransformsImage: Option[OrientationMetadata] = None + var colourModel: Option[String] = None + var colourModelInformation: Map[String, String] = Map.empty + + implicit val arena: Arena = Arena.ofConfined + try { + val image = VImage.newFromFile(arena, sourceFile.getAbsolutePath) + + dimensions = Some(Dimensions(width = image.getWidth, height = image.getHeight)) + + val exifOrientation = VipsHelper.image_get_orientation(image.getUnsafeStructAddress) + val orientation = Some(OrientationMetadata( + exifOrientation = Some(exifOrientation) + )) + maybeExifOrientationWhichTransformsImage = Seq(orientation).flatten.find(_.transformsImage()) + + val interpretationRawValue = VipsHelper.image_get_interpretation(image.getUnsafeStructAddress) + // TODO better way to go straight from int to enum? + val maybeInterpretation = VipsInterpretation.values().toSeq.find(_.getRawValue == interpretationRawValue) + colourModel = maybeInterpretation match { + case Some(VipsInterpretation.INTERPRETATION_B_W) => Some("Greyscale") + case Some(VipsInterpretation.INTERPRETATION_CMYK) => Some("CMYK") + case Some(VipsInterpretation.INTERPRETATION_LAB) => Some("LAB") + case Some(VipsInterpretation.INTERPRETATION_LABS) => Some("LAB") + case Some(VipsInterpretation.INTERPRETATION_RGB16) => Some("RGB") + case Some(VipsInterpretation.INTERPRETATION_sRGB) => Some("RGB") + case _ => None } - case Png => - val op = new IMOperation() - val formatter = format(op)("%[colorspace]") - val withSource = addDestImage(formatter)(sourceFile) - - for { - output <- runIdentifyCmd(withSource, true) - colourModel = output.headOption - } yield colourModel match { - case Some("sRGB") => Some("RGB") - case Some("Gray") => Some("Greyscale") - case _ => Some("RGB") + + colourModelInformation = Map { + "hasAlpha" -> hasAlpha(image).toString } - case _ => - // assume that the colour model is RGB for other image types - Future.successful(Some("RGB")) + } catch { + case e: Exception => + logger.error("Error during getImageInformation", e) + arena.close() + throw e + } + arena.close() + + (dimensions, maybeExifOrientationWhichTransformsImage, colourModel, colourModelInformation) + }.map { result => + logger.info(addLogMarkers(stopwatch.elapsed), "Finished getImageInformation") + result } } + + def hasAlpha(image: VImage)(implicit arena: Arena): Boolean = { + val labInterpretationsKnownToHaveFourBandsWhenNoAlpha = Set( + VipsInterpretation.INTERPRETATION_LAB.getRawValue + ) + val interpretation = VipsHelper.image_get_interpretation(image.getUnsafeStructAddress) + if (labInterpretationsKnownToHaveFourBandsWhenNoAlpha.contains(interpretation)) { + logger.warn("Forcing colour space to sRGB to try and get a more accurate hasAlpha result") + image.colourspace(VipsInterpretation.INTERPRETATION_sRGB).hasAlpha + } else { + image.hasAlpha + } + } + + def isGraphicVips(image: VImage)(implicit arena: Arena): Boolean = { + val numberOfBands = VipsHelper.image_get_bands(image.getUnsafeStructAddress) + logger.info("Number of bands: " + numberOfBands) + // Indexed plus alpha would be 2 bands + + val format = VipsHelper.image_get_format(image.getUnsafeStructAddress) + logger.info("Format: " + format) + + val paletteType = VipsHelper.image_get_typeof(arena, image.getUnsafeStructAddress, "palette") + logger.info("Palette type: " + paletteType) + + paletteType > 0 || numberOfBands < 3 + } } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/im4jwrapper/ExifTool.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/im4jwrapper/ExifTool.scala deleted file mode 100644 index 88ea75dbdd5..00000000000 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/im4jwrapper/ExifTool.scala +++ /dev/null @@ -1,36 +0,0 @@ -package com.gu.mediaservice.lib.imaging.im4jwrapper - -import java.util.concurrent.Executors - -import java.io.File -import scala.concurrent.{Future, ExecutionContext} -import org.im4java.core.{ETOperation, ExiftoolCmd} - - -object ExifTool { - private implicit val ctx: ExecutionContext = - ExecutionContext.fromExecutor(Executors.newFixedThreadPool(Config.imagingThreadPoolSize)) - - def tagSource(source: File): ETOperation = { - val op = new ETOperation() - op.addImage(source.getAbsolutePath) - op - } - - def setTags(ops: ETOperation)(tags: Map[String, String]): ETOperation = { - tags.foldLeft(ops) { case (ops, (key, value)) => - ops.setTags(s"$key=$value") - } - } - - def overwriteOriginal(ops: ETOperation): ETOperation = { - ops.overwrite_original() - ops - } - - def runExiftoolCmd(ops: ETOperation): Future[Unit] = { - // Set overwrite original to ensure temporary file deletion - overwriteOriginal(ops) - Future((new ExiftoolCmd).run(ops)) - } -} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/im4jwrapper/ImageMagick.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/im4jwrapper/ImageMagick.scala index 302eb189f00..72a164730dc 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/im4jwrapper/ImageMagick.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/im4jwrapper/ImageMagick.scala @@ -87,14 +87,6 @@ object ImageMagick extends GridLogging { op } - def runConvertCmd(op: IMOperation, useImageMagick: Boolean)(implicit logMarker: LogMarker): Future[Unit] = { - Stopwatch.async(s"Using ${if(useImageMagick) "imagemagick" else "graphicsmagick"} for imaging conversion operation '$op'") { - Future { - new ConvertCmd(!useImageMagick).run(op) - } - } - } - def runIdentifyCmd(op: IMOperation, useImageMagick: Boolean)(implicit logMarker: LogMarker): Future[List[String]] = { Stopwatch.async(s"Using ${if (useImageMagick) "imagemagick" else "graphicsmagick"} for imaging identification operation '$op'") { Future { diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/instances/Instances.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/instances/Instances.scala new file mode 100644 index 00000000000..8f37d67710a --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/instances/Instances.scala @@ -0,0 +1,42 @@ +package com.gu.mediaservice.lib.instances + +import com.gu.mediaservice.lib.config.CommonConfig +import com.gu.mediaservice.model.Instance +import com.typesafe.scalalogging.StrictLogging +import play.api.libs.json.{Json, Reads} +import play.api.libs.ws.{WSClient, WSResponse} + +import scala.concurrent.{ExecutionContext, Future} + +trait Instances extends StrictLogging { + def config: CommonConfig + + def wsClient: WSClient + + def getInstances()(implicit ec: ExecutionContext): Future[Seq[Instance]] = { + wsClient.url(config.instancesEndpoint).get().map { r => + handleInstancesResponse(r) + } + } + + def getMyInstances(owner: String)(implicit ec: ExecutionContext): Future[Seq[Instance]] = { + wsClient.url(config.myInstancesEndpoint).withQueryStringParameters("owner" -> owner).get().map { r => + handleInstancesResponse(r) + } + } + + private def handleInstancesResponse(r: WSResponse): Seq[Instance] = { + r.status match { + case 200 => + implicit val ir: Reads[Instance] = Json.reads[Instance] + Json.parse(r.body).as[Seq[Instance]] + case 404 => + logger.warn("Got 404 status for instances call; returning no permissions") + Seq.empty + case _ => + logger.error("Got non 200 status for instances call: " + r.status) + throw new RuntimeException("Could not load instances") + } + } + +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/instances/InstancesClient.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/instances/InstancesClient.scala new file mode 100644 index 00000000000..ada7fc7df04 --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/instances/InstancesClient.scala @@ -0,0 +1,6 @@ +package com.gu.mediaservice.lib.instances + +import com.gu.mediaservice.lib.config.CommonConfig +import play.api.libs.ws.WSClient + +class InstancesClient(val config: CommonConfig, val wsClient: WSClient) extends Instances diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/metadata/ImageMetadataConverter.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/metadata/ImageMetadataConverter.scala index 0e73246752c..a8b13ec6b99 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/metadata/ImageMetadataConverter.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/metadata/ImageMetadataConverter.scala @@ -144,13 +144,13 @@ object ImageMetadataConverter extends GridLogging { DateTimeFormat.forPattern("E MMM dd HH:mm:ss.SSS 'BST' yyyy").withZone(DateTimeZone.forOffsetHours(1)), DateTimeFormat.forPattern("E MMM dd HH:mm:ss 'BST' yyyy").withZone(DateTimeZone.forOffsetHours(1)), + // TODO these group of formatters are locale dependent. Is this intentional? DateTimeFormat.forPattern("yyyyMMdd"), DateTimeFormat.forPattern("yyyyMM"), DateTimeFormat.forPattern("yyyyddMM"), DateTimeFormat.forPattern("yyyy"), DateTimeFormat.forPattern("yyyy-MM"), DateTimeFormat.forPattern("yyyy:MM:dd"), - DateTimeFormat.forPattern("yyyy-MM-dd"), // 2014-12-16 - Maybe it's just a date // no timezone provided so force UTC rather than use the machine's timezone diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/metadata/SoftDeletedMetadataTable.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/metadata/SoftDeletedMetadataTable.scala index e20edf694a5..5315a5844f3 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/metadata/SoftDeletedMetadataTable.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/metadata/SoftDeletedMetadataTable.scala @@ -1,11 +1,10 @@ package com.gu.mediaservice.lib.metadata -import com.amazonaws.services.dynamodbv2.model.BatchWriteItemResult import com.gu.mediaservice.lib.config.CommonConfig -import com.gu.mediaservice.model.ImageStatusRecord +import com.gu.mediaservice.model.{ImageStatusRecord, Instance} import org.scanamo._ -import org.scanamo.syntax._ import org.scanamo.generic.auto._ +import org.scanamo.syntax._ import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient import scala.concurrent.{ExecutionContext, Future} @@ -15,8 +14,8 @@ class SoftDeletedMetadataTable(config: CommonConfig) { private val softDeletedMetadataTable = Table[ImageStatusRecord](config.softDeletedMetadataTable) - def getStatus(imageId: String)(implicit ex: ExecutionContext) = { - ScanamoAsync(client).exec(softDeletedMetadataTable.get("id" === imageId)) + def getStatus(imageId: String)(implicit ex: ExecutionContext, instance: Instance) = { + ScanamoAsync(client).exec(softDeletedMetadataTable.get("id" === imageId and "instance" === instance.id)) } def setStatus(imageStatus: ImageStatusRecord)(implicit ex: ExecutionContext) = { @@ -28,18 +27,22 @@ class SoftDeletedMetadataTable(config: CommonConfig) { else ScanamoAsync(client).exec(softDeletedMetadataTable.putAll(imageStatuses)) } - def clearStatuses(imageIds: Set[String])(implicit ex: ExecutionContext) = { + def clearStatuses(imageIds: Set[String])(implicit ex: ExecutionContext, instance: Instance) = { if (imageIds.isEmpty) Future.successful(List.empty) - else ScanamoAsync(client).exec(softDeletedMetadataTable.deleteAll("id" in imageIds)) - } - - def updateStatus(imageId: String, isDeleted: Boolean)(implicit ex: ExecutionContext) = { + else { + Future.sequence(imageIds.map { id => + // Scanomo batch can't do composite keys? DSL is too confusing + ScanamoAsync(client).exec(softDeletedMetadataTable.delete("id" === id and "instance" === instance.id)) + }).map(_ => List.empty) + } } + + def updateStatus(imageId: String, isDeleted: Boolean)(implicit ex: ExecutionContext, instance: Instance) = { val updateExpression = set("isDeleted", isDeleted) ScanamoAsync(client).exec( softDeletedMetadataTable .when(attributeExists("id")) .update( - key = "id" === imageId, + "id" === imageId and "instance" === instance.id, update = updateExpression ) ) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/metrics/CloudWatchMetrics.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/metrics/CloudWatchMetrics.scala index 572ae8ea492..d1acae2bab2 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/metrics/CloudWatchMetrics.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/metrics/CloudWatchMetrics.scala @@ -104,9 +104,11 @@ private class MetricsActor(namespace: String, client: AmazonCloudWatch) extends .toSeq aggregatedMetrics.grouped(maxGroupSize).foreach(chunkedMetrics => { //can only send max 20 metrics to CW at a time + /* Yeah nah client.putMetricData(new PutMetricDataRequest() .withNamespace(namespace) .withMetricData(chunkedMetrics.asJava)) + */ }) logger.info(s"Put ${data.size} metric data points (aggregated to ${aggregatedMetrics.size} points) to namespace $namespace") diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/tortoise/TortoiseUsageRightsConfig.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/tortoise/TortoiseUsageRightsConfig.scala new file mode 100644 index 00000000000..e3d17043ba0 --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/tortoise/TortoiseUsageRightsConfig.scala @@ -0,0 +1,555 @@ +package com.gu.mediaservice.lib.tortoise + +import com.gu.mediaservice.lib.config.{PublicationPhotographer, PublicationPhotographers, UsageRightsConfigProvider} +import org.joda.time.LocalDate + +object TortoiseUsageRightsConfig extends UsageRightsConfigProvider { + private val ObserverPublication = "The Observer" + + val externalStaffPhotographers: List[PublicationPhotographers] = List( + PublicationPhotographers(ObserverPublication, List( + )) + ) + + // these are people who aren't photographers by trade, but have taken photographs for us. + // This is mainly used so when we ingest photos from Picdar, we make sure we categorise + // them correctly. + // TODO: Think about removin these once Picdar is dead. + val internalStaffPhotographers = List( + PublicationPhotographers(ObserverPublication, List( + )) + ) + + val contractedPhotographers = List( + PublicationPhotographers(ObserverPublication, List( + PublicationPhotographer("Andy Hall", from = Some(LocalDate.parse("2025-04-22"))), + PublicationPhotographer("Gary Calton", from = Some(LocalDate.parse("2025-04-22"))), + PublicationPhotographer("Suki Dhanda", from = Some(LocalDate.parse("2025-04-22"))), + PublicationPhotographer("Richard Saker", from = Some(LocalDate.parse("2025-04-22"))), + PublicationPhotographer("Karen Robinson", from = Some(LocalDate.parse("2025-04-22"))), + PublicationPhotographer("Sophia Evans", from = Some(LocalDate.parse("2025-04-22"))), + PublicationPhotographer("Katherine Anne Rose", from = Some(LocalDate.parse("2025-04-22"))), + PublicationPhotographer("Antonio Olmos", from = Some(LocalDate.parse("2025-04-22"))), + PublicationPhotographer("Jonathan Lovekin", from = Some(LocalDate.parse("2025-04-22"))) + )) + ) + + val staffIllustrators = List( + ) + + val contractIllustrators = List( + PublicationPhotographers(ObserverPublication, List( + PublicationPhotographer("Chris Riddell", from = Some(LocalDate.parse("2025-04-22"))) + )) + ) + + val creativeCommonsLicense = List( + "CC BY-4.0", "CC BY-SA-4.0", "CC BY-ND-4.0" + ) + + /* These are currently hardcoded */ + val payGettySourceList = List( + "ABC News", + "AFPTV", + "Alinari", + "Arnold Newman Collection", + "Baim Collection", + "Barrett-Jackson", + "Bob Thomas Sports Photography", + "Catwalking", + "CBS Television Stations Group RR", + "Contour", + "Contour RA", + "Corbis Premium Historical", + "Editorial Specials", + "Ercole Colombo", + "Extreme E", + "First Freedom", + "Formula E", + "Gamma-Legends", + "Getty Images Sport Classic", + "Icon Sport", + "J.LEAGUE", + "KBC - Japan", + "Klaud9", + "Kyodo News", + "Kyodo News Stills", + "LAT Image", + "Lichfield Studios Limited", + "Lonely Planet RF", + "Maggi & Maggi", + "Major League Baseball Platinum", + "Manchester City FC", + "Mondadori Portfolio Premium", + "NBA Classic", + "NBC News Archives Clips", + "Neil Leifer Collection", + "Newspix", + "NHK Video Bank Creative", + "NHK Video Bank Editorial", + "NHK Video Bank Premium", + "PA Images", + "Papixs", + "Paris Match Archive", + "Pele 10", + "Popperfoto", + "Premium Archive", + "Premium Archive Films Editorial", + "Rainer Schlegelmilch", + "Reportage Archive", + "SAMURAI JAPAN", + "SNS Group", + "Sports Illustrated", + "Sports Illustrated Classic", + "Storyful", + "Sutton Images", + "Sygma Premium", + "The Asahi Shimbun Premium", + "The Asahi Shimbun Video", + "Tottenham Hotspur FC", + "UEFA Exclusive", + "ullstein bild Premium", + "Ulrich Baumgarten", + "Vision Media", + // Here goes the list of inactive collections too + "#girlgaze", + "2VISTA", + "360cities.net Editorial", + "360cities.net RM", + "3D4Medical.com", + "3DClinic", + "40260 RF", + "ABC News", + "ABSODELS RM", + "ACP", + "Action Plus", + "Addictive Stock", + "Aflo Foto Agency RM", + "AFP Creative", + "age fotostock RM", + "Alaska Stock Images RF", + "Alaska Stock Images RR", + "Alaskan Express RF", + "All Canada Photos RM", + "Allsport Concepts", + "Altrendo", + "Altrendo RR", + "amana images RM", + "America 24-7", + "arabianEye RM", + "Arcaid Images", + "Arcaid RR", + "Arcangel Images RR", + "Archive Photos Creative", + "Arena Football League", + "Aridi", + "Art Images", + "Artville", + "ASAblanca", + "Asia Images RF", + "Asia Images RM", + "Astrakan", + "Aurora", + "Aurora Plus", + "Author's Image RF", + "AWL Images RM", + "Axiom Photographic Agency", + "Barcroft", + "Barcroft Media", + "Bettmann Creative", + "bilderlounge RR", + "Biosphoto RM", + "Black Box", + "Blend Images RM", + "Blend Images RR", + "Bloomberg Creative Photos RM", + "Boost", + "Botanica", + "Broadway.com RM", + "Built.Images RF", + "BuzzFoto", + "Caiaimage", + "Canopy RM", + "Car Culture", + "Cavan Images RM", + "CBS Watch Magazine", + "CCN Images RR", + "CGIBackgrounds", + "Check Six", + "Chic Sketch Editorial", + "Chic Sketch RF", + "China Span RM", + "Christian Science Monitor", + "CI BuzzFoto", + "CI Europa Press", + "CI FilmMagic", + "CI FilmMagic, Inc", + "CI FM Europa Press", + "CI Getty Images Entertainment", + "CI Getty Images Sport", + "CI News Feature", + "CI WI Europa Press", + "CI WireImage", + "Citizen Stock RM", + "Clerkenwell", + "clipart.com", + "Code Red", + "Codex", + "Collection Mix Subjects RM", + "Collection Vogue Paris", + "Collegiate Images", + "Colorsport", + "Comstock Images", + "Conde Nast Collection Editorial", + "Conde Nast Collection RM", + "Conde Nast Lifestyle Collection", + "Construction Photography RF", + "Contour Style", + "Contour Style Creative", + "Corbis Documentary", + "Corbis Historical Creative", + "Corbis NX", + "Corbis RF", + "Corbis RM Stills", + "Cote", + "Country Music Hall of Fame and Museum", + "CSA Images RM", + "Cuboimages RM", + "Cultura Exclusive", + "Cultura RF", + "Cultura RM", + "Cusp RM", + "Custom Medical Stock Photo RF", + "Custom Medical Stock Photo RM", + "Cut and Deal RF", + "Da Vinci Codex Atlanticus", + "Daily Express", + "DAJ", + "DAJ RM", + "De Agostini RM", + "Denkou RF", + "DigitalGlobe", + "Discovery Channel Images RM", + "DK Stock", + "Dorling Kindersley", + "Eastphoto RF", + "Eastphoto RM", + "Ecoscene RR", + "El Universal", + "Emotive Images RF", + "ESTADÃO CONTEÚDO", + "Everyday Projects", + "Eye Ubiquitous RR", + "EyeEm", + "EyeEm Premium", + "EyeEm RM", + "Eyewire", + "EyeWire Other", + "F1online RM", + "Fame Flynet Stills", + "Fancy RF", + "Federugby", + "Fever Images RF", + "Feyenoord", + "Finanzen Verlag", + "First Light", + "Flickr Flash", + "Flickr Prime", + "Flickr State", + "Flirt RF", + "FM Europa Press", + "FogStock", + "Folio Images RF", + "Folio Images RM", + "FoodPix", + "FoodShapes RF", + "Fototrove", + "Fox Entertainment Group", + "Gallo Images", + "Gamma-Features", + "GAP Photos RM", + "Garden Picture Library RM", + "Genuine Japan Creative Stills", + "Genuine Japan Editorial Stills", + "George Steinmetz", + "Getty Images - NASCAR Partners", + "Getty Images Multimedia Footage", + "Getty Images Special Access", + "Global Cricket Ventures - BCCI", + "Globe Photos", + "Globo", + "Glow RM", + "Glowimages RM", + "GoGo Images RF", + "Golden Boy Promotions", + "GoodSalt RR", + "GraphEast RF", + "GraphEast RM", + "Gulf Images RM", + "Hemera", + "Hemis.fr RM", + "Her Og Nu", + "Hero Images", + "Hero Images Corbis", + "HillCreek Pictures RF", + "Historic Map Works", + "Hoberman Collection UK RR", + "Hola Images RM", + "Hoxton", + "I Love Images RF", + "Iconic Images", + "Iconica", + "Iconotec RF", + "Ikon Images", + "Illustration Works", + "Image Farm RF", + "Image Ideas RF", + "Image Partner Media", + "Image100", + "imageBROKER RM", + "ImageDJ RF", + "Imagemore", + "ImageRite RF", + "Images Bazaar", + "Images.com RF", + "imageshop RF", + "imagesouk", + "ImageState RF", + "ImageState RM", + "Imagezoo RM", + "ImaZinS RM", + "Index Stock Images RR", + "Indian Premier League", + "Ingram Publishing RF", + "Inmagineasia", + "InsideOutPix RF", + "Inspirestock RF", + "Interact Images", + "International Speedway Corp.", + "Iromaya RF", + "IS Stock RF", + "iStock Exclusive RF", + "iStock Main", + "iStock Signature", + "iStock Signature Plus", + "iStock Vectors Plus", + "ItaliaStock", + "JLPGA", + "John Warburton-Lee RR", + "Johner Images", + "Jon Arnold Images RF", + "JTB Photo RM", + "Juice Images RF", + "Juniors Bildarchiv RM", + "Kablonk RF", + "Kallista Images", + "Keith Levit Photography RF", + "Keystone RF", + "Kobal Collection", + "Las Vegas Stock RR", + "LAT", + "LatinContent RM", + "Laughing Stock RM", + "Lifesize", + "Link Image RM", + "London Stills RR", + "Lonely Planet Images", + "LOOK", + "LuckyPix RR", + "Luxy", + "Map Resources", + "Mary Evans Picture Library RM", + "Masterfile", + "Masters", + "mauritius images RM", + "Mayo Clinic Collection", + "MedioImages", + "Melba Photo Agency RF", + "Mike King", + "Minden Pictures II", + "Minden Pictures RM", + "Mint Images RM", + "Mise En Beaute RR", + "MLBPA - The Players Choice", + "Moment RM", + "Moment Select", + "Moment Unreleased", + "Mondadori Portfolio", + "National Geographic", + "National Geographic Magazines", + "National Geographic RF", + "Nativestock", + "Nature Picture Library", + "Neovision RM", + "Nettavisen", + "New York Cosmos", + "newstockimages RF", + "NFL", + "Nordic Life", + "Nordic Photos", + "NucleusMedicalArt.com RM", + "NYonAir", + "Oceans-Image RR", + "Offside Live", + "OJO Images RM", + "OJO Plus RF", + "Old Visuals RF", + "Olive Images RF", + "Open Door Images RF", + "Open Mike Productions", + "Oxford Scientific RM", + "Pacific Stock RM", + "PANAPRESS", + "PanoramaStock RF", + "Panoramic Images RM", + "Panoramic Images RR", + "Panther Media RF", + "Paris Match Collection", + "Passage RM", + "Perspectives", + "Peter Arnold", + "Photo Exchange Bank Germany", + "PhotoAlto Agency RM", + "Photodisc", + "Photographer's Choice", + "Photographer's Choice RR", + "Photolibrary RF", + "Photolibrary RM", + "Photonica", + "Photonica World", + "Photononstop RM", + "Phototake RM", + "Phovoir RF", + "Picture Press RM", + "Pixmann RF", + "Pixta", + "Popperfoto Creative", + "Popstar Pictures", + "Popular Science", + "Portsmouth FC", + "Premium Ent", + "Private Label", + "Publisher Mix RM", + "Queerstock", + "QuickImage RF", + "QuickImage RR", + "Radius Images RF", + "Rainer Schlegelmilch", + "Real Latino RF", + "Realistic Reflections", + "Red Cover RM", + "Redlink RM", + "Refinery29 RM", + "relaximages", + "Reportage by Getty Images", + "Retrofile", + "Reunion Images", + "Riser", + "Robert Harding World Imagery", + "RooM RM", + "SAKIstyle RM", + "SambaPhoto", + "Science Faction", + "Science Faction Jewels", + "Science Photo Library RM", + "Science Source", + "ScienceFoto RM", + "Scoopt", + "Sebun", + "simple stock shots RF", + "Sites & Photos", + "Smart.MAGNA RF", + "Snapwi.re", + "SodaStyle", + "SoFood Collection RF", + "Solus", + "Somos RF", + "Sony BMG Music Entertainment", + "SPL Creative RM", + "Sport Plus", + "Starface Image Collection", + "Stock Illustration RF", + "Stock Illustration Source", + "Stock4B", + "stockbrokerXtra RF", + "Stockbyte", + "Stockbyte Global", + "stockbyway RF", + "Stockdisc", + "StockFood Creative RM", + "StockFood Creative RR", + "StockImage", + "Stone", + "Studio Harcourt", + "SuperStock RF", + "SuperStock RM", + "Swimwear by Popstar", + "Tango Stock RM", + "TAO Images RM", + "TASS", + "Taxi", + "Taxi Japan RM", + "Televisa", + "Terry O'Neill", + "TF-Images", + "the Agency Collection", + "The Axel Springer Collection", + "The England Collection", + "The Gruner & Jahr Collection", + "The Image Bank", + "The LIFE Images Collection", + "The LIFE Picture Collection", + "The LIFE Premium Collection", + "The New York Post", + "The Stock Connection RR", + "The StockPile Collection RF", + "Thinkstock", + "Tim de Waele", + "Tohoku Colour Agency RM", + "TongRo Images RF", + "Topic Images", + "Triangle", + "Trond Tandberg", + "Twenty20 RF", + "Universal Images Group", + "Untitled X-Ray", + "UpperCut RF", + "Uppercut RM", + "Urban CGI RF", + "View Stock RM", + "VII", + "VII Premium", + "VisitBritain RF", + "VisitBritain RM", + "Visual China Group Video", + "Visual Language RF", + "Visuals Unlimited", + "Warner Bros. Entertainment", + "WaterFrame RM", + "Wembley National Stadium Ltd", + "West Ham United FC", + "Westend61 RM", + "WI Europa Press", + "WIN-Initiative RM", + "WNET Collection RF", + "Workbook Stock", + "World Kabbadi League", + "World Sport Group", + "Yann Arthus-Bertrand", + "Zefa RF", + "Zen Shui RF" + ) + + val freeSuppliers = List( + "Alamy", + "AP", + "Getty Images", + "GNM", + "PA" + ) + + val suppliersCollectionExcl = Map( + "Getty Images" -> payGettySourceList + ) + +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/usage/ItemToMediaUsage.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/usage/ItemToMediaUsage.scala index 17ee72580a2..c27ee21bcd7 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/usage/ItemToMediaUsage.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/usage/ItemToMediaUsage.scala @@ -1,44 +1,47 @@ package com.gu.mediaservice.lib.usage import java.net.URI -import com.amazonaws.services.dynamodbv2.document.Item import com.gu.mediaservice.model.usage._ import org.joda.time.DateTime import org.joda.time.format.ISODateTimeFormat +import software.amazon.awssdk.services.dynamodb.model.{AttributeValue => AttributeValueV2} import scala.jdk.CollectionConverters._ import scala.util.Try object ItemToMediaUsage { - def transform(item: Item): MediaUsage = { + def transformV2(attrs: java.util.Map[String, AttributeValueV2]): MediaUsage = { + val m = attrs.asScala MediaUsage( - UsageId(item.getString("usage_id")), - item.getString("grouping"), - item.getString("media_id"), - UsageType(item.getString("usage_type")), - item.getString("media_type"), - UsageStatus(item.getString("usage_status")), - Option(item.getMap[Any]("print_metadata")) - .map(_.asScala.toMap).flatMap(buildPrint), - Option(item.getMap[Any]("digital_metadata")) - .map(_.asScala.toMap).flatMap(buildDigital), - Option(item.getMap[Any]("syndication_metadata")) - .map(_.asScala.toMap).flatMap(buildSyndication), - Option(item.getMap[Any]("front_metadata")) - .map(_.asScala.toMap).flatMap(buildFront), - Option(item.getMap[Any]("download_metadata")) - .map(_.asScala.toMap).flatMap(buildDownload), - new DateTime(item.getLong("last_modified")), - Try { - item.getLong("date_added") - }.toOption.map(new DateTime(_)), - Try { - item.getLong("date_removed") - }.toOption.map(new DateTime(_)) + UsageId(m("usage_id").s()), + m("grouping").s(), + m("media_id").s(), + UsageType(m("usage_type").s()), + m("media_type").s(), + UsageStatus(m("usage_status").s()), + m.get("print_metadata").map(_.m().asScala.view.mapValues(attrToAny).toMap).flatMap(buildPrint), + m.get("digital_metadata").map(_.m().asScala.view.mapValues(attrToAny).toMap).flatMap(buildDigital), + m.get("syndication_metadata").map(_.m().asScala.view.mapValues(attrToAny).toMap).flatMap(buildSyndication), + m.get("front_metadata").map(_.m().asScala.view.mapValues(attrToAny).toMap).flatMap(buildFront), + m.get("download_metadata").map(_.m().asScala.view.mapValues(attrToAny).toMap).flatMap(buildDownload), + new DateTime(m("last_modified").n().toLong), + Try(m("date_added").n().toLong).toOption.map(new DateTime(_)), + Try(m("date_removed").n().toLong).toOption.map(new DateTime(_)) ) } + private def attrToAny(av: AttributeValueV2): Any = { + if (av.s() != null) av.s() + else if (av.n() != null) new java.math.BigDecimal(av.n()) + else if (av.hasM) { + val linkedMap = new java.util.LinkedHashMap[String, Any]() + av.m().forEach((k, v) => linkedMap.put(k, attrToAny(v))) + linkedMap + } + else null + } + private def buildFront(metadataMap: Map[String, Any]): Option[FrontUsageMetadata] = { Try { FrontUsageMetadata( @@ -61,8 +64,8 @@ object ItemToMediaUsage { Try { DigitalUsageMetadata( URI.create(metadataMap("webUrl").asInstanceOf[String]), - metadataMap("webTitle").asInstanceOf[String], - metadataMap("sectionId").asInstanceOf[String], + metadataMap.get("webTitle").map(x => x.asInstanceOf[String]), + metadataMap.get("sectionId").map(x => x.asInstanceOf[String]), metadataMap.get("composerUrl").map(x => URI.create(x.asInstanceOf[String])) ) }.toOption diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/usage/UsageBuilder.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/usage/UsageBuilder.scala index 4d422309f82..007f3b2e226 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/usage/UsageBuilder.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/usage/UsageBuilder.scala @@ -52,7 +52,7 @@ object UsageBuilder { private def buildDigitalUsageReference(usage: MediaUsage): List[UsageReference] = { (usage.digitalUsageMetadata, usage.frontUsageMetadata) match { case (Some(metadata), None) => List( - UsageReference(FrontendUsageReference, Some(metadata.webUrl), Some(metadata.webTitle)) + UsageReference(FrontendUsageReference, Some(metadata.webUrl), metadata.webTitle) ) ++ metadata.composerUrl.map(url => UsageReference(ComposerUsageReference, Some(url))) case (None, Some(metadata)) => List( UsageReference(FrontUsageReference, None, name = Some(metadata.front)) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/model/Collection.scala b/common-lib/src/main/scala/com/gu/mediaservice/model/Collection.scala index bf7e450a84a..ecce63495dc 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/model/Collection.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/model/Collection.scala @@ -8,7 +8,7 @@ import play.api.libs.functional.syntax._ import com.gu.mediaservice.lib.collections.CollectionsManager -case class Collection private (path: List[String], actionData: ActionData, description: String) { +case class Collection (path: List[String], actionData: ActionData, description: String) { // We lowercase on pathId so that we can search case-insensitively val pathId = CollectionsManager.pathToPathId(path) } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/model/Edits.scala b/common-lib/src/main/scala/com/gu/mediaservice/model/Edits.scala index eff2b51188a..85a8ef5fe1b 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/model/Edits.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/model/Edits.scala @@ -2,11 +2,13 @@ package com.gu.mediaservice.model import java.net.{URI, URLEncoder} import com.gu.mediaservice.lib.argo.model.{Action, EmbeddedEntity} +import com.gu.mediaservice.lib.config.InstanceForRequest import org.joda.time.DateTime import play.api.libs.json._ import play.api.libs.json.JodaReads._ import play.api.libs.json.JodaWrites._ import play.api.libs.functional.syntax._ +import play.api.mvc.Request case class Edits( @@ -55,7 +57,7 @@ object Edits { } trait EditsResponse { - val metadataBaseUri: String + val metadataBaseUri: Instance => String type ArchivedEntity = EmbeddedEntity[Boolean] type SetEntity = EmbeddedEntity[Seq[EmbeddedEntity[String]]] @@ -63,11 +65,11 @@ trait EditsResponse { type UsageRightsEntity = EmbeddedEntity[UsageRights] type PhotoshootEntity = EmbeddedEntity[Photoshoot] - def editsEmbeddedEntity(id: String, edits: Edits) = + def editsEmbeddedEntity(id: String, edits: Edits)(implicit instance: Instance) = EmbeddedEntity(entityUri(id), Some(Json.toJson(edits)(editsEntity(id)))) // the types are in the arguments because of a whining scala compiler - def editsEntity(id: String): Writes[Edits] = ( + def editsEntity(id: String)(implicit instance: Instance): Writes[Edits] = ( (__ \ Edits.Archived).write[ArchivedEntity].contramap(archivedEntity(id, _: Boolean)) ~ (__ \ Edits.Labels).write[SetEntity].contramap(setEntity(id, "labels", _: List[String])) ~ (__ \ Edits.Metadata).write[MetadataEntity].contramap(metadataEntity(id, _: ImageMetadata)) ~ @@ -76,31 +78,31 @@ trait EditsResponse { (__ \ Edits.LastModified).writeNullable[DateTime] )(unlift(Edits.unapply)) - def photoshootEntity(id: String, photoshoot: Option[Photoshoot]): PhotoshootEntity = + def photoshootEntity(id: String, photoshoot: Option[Photoshoot])(implicit instance: Instance): PhotoshootEntity = EmbeddedEntity(entityUri(id, "/photoshoot"), photoshoot) - def archivedEntity(id: String, a: Boolean): ArchivedEntity = + def archivedEntity(id: String, a: Boolean)(implicit instance: Instance): ArchivedEntity = EmbeddedEntity(entityUri(id, "/archived"), Some(a)) - def metadataEntity(id: String, m: ImageMetadata): MetadataEntity = + def metadataEntity(id: String, m: ImageMetadata)(implicit instance: Instance): MetadataEntity = EmbeddedEntity(entityUri(id, "/metadata"), Some(m), actions = List( Action("set-from-usage-rights", entityUri(id, "/metadata/set-from-usage-rights"), "POST") )) - def usageRightsEntity(id: String, u: Option[UsageRights]): UsageRightsEntity = + def usageRightsEntity(id: String, u: Option[UsageRights])(implicit instance: Instance): UsageRightsEntity = u.map(i => EmbeddedEntity(entityUri(id, "/usage-rights"), Some(i))) .getOrElse(EmbeddedEntity(entityUri(id, "/usage-rights"), None)) - def setEntity(id: String, setName: String, labels: List[String]): SetEntity = + def setEntity(id: String, setName: String, labels: List[String])(implicit instance: Instance): SetEntity = EmbeddedEntity(entityUri(id, s"/$setName"), Some(labels.map(setUnitEntity(id, setName, _)))) - def setUnitEntity(id: String, setName: String, name: String): EmbeddedEntity[String] = + def setUnitEntity(id: String, setName: String, name: String)(implicit instance: Instance): EmbeddedEntity[String] = EmbeddedEntity(entityUri(id, s"/$setName/${URLEncoder.encode(name, "UTF-8")}"), Some(name)) - private def entityUri(id: String, endpoint: String = ""): URI = - URI.create(s"$metadataBaseUri/metadata/$id$endpoint") + private def entityUri(id: String, endpoint: String = "")(implicit instance: Instance): URI = + URI.create(s"${metadataBaseUri(instance)}/metadata/$id$endpoint") - def labelsUri(id: String) = entityUri(id, "/labels") + def labelsUri(id: String)(implicit instance: Instance) = entityUri(id, "/labels") - def metadataUri(id: String) = entityUri(id, "/metadata") + def metadataUri(id: String)(implicit instance: Instance) = entityUri(id, "/metadata") } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/model/Image.scala b/common-lib/src/main/scala/com/gu/mediaservice/model/Image.scala index 40439048f15..db9952602b7 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/model/Image.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/model/Image.scala @@ -44,9 +44,13 @@ case class Image( def hasNonInferredRights: Boolean = !hasInferredSyndicationRightsOrNoRights def syndicationStatus: SyndicationStatus = { - val isRightsAcquired: Boolean = syndicationRights.exists(_.isRightsAcquired) + // TODO Deduplicate this with syndicationFilter + val isOwned: Boolean = usageRights match { + case _: Photographer => true + case _ => false + } - if (!isRightsAcquired) { + if (!isOwned) { UnsuitableForSyndication } else { val hasSyndicationUsage = usages.exists(_.platform == SyndicationUsage) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/model/ImageStatusRecord.scala b/common-lib/src/main/scala/com/gu/mediaservice/model/ImageStatusRecord.scala index b58ebddb9f0..c942e813211 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/model/ImageStatusRecord.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/model/ImageStatusRecord.scala @@ -6,7 +6,8 @@ case class ImageStatusRecord( id: String, deletedBy: String, deleteTime: String, - isDeleted: Boolean + isDeleted: Boolean, + instance: String ) object ImageStatusRecord { diff --git a/common-lib/src/main/scala/com/gu/mediaservice/model/Instance.scala b/common-lib/src/main/scala/com/gu/mediaservice/model/Instance.scala new file mode 100644 index 00000000000..ba631d5bf0c --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/model/Instance.scala @@ -0,0 +1,5 @@ +package com.gu.mediaservice.model + +case class Instance(id: String) { + override def toString: String = id // TODO need to visit all the urls builders an make them use .id +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/model/MimeType.scala b/common-lib/src/main/scala/com/gu/mediaservice/model/MimeType.scala index 13e527625f8..14df82982d7 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/model/MimeType.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/model/MimeType.scala @@ -10,6 +10,7 @@ sealed trait MimeType { case Jpeg => "image/jpeg" case Png => "image/png" case Tiff => "image/tiff" + case Heif => "image/heif" } def fileExtension: String = s".${name.split('/').reverse.head}" @@ -22,6 +23,8 @@ object MimeType extends GridLogging { case "image/jpeg" => Jpeg case "image/png" => Png case "image/tiff" => Tiff + case "image/heif" => Heif + case "image/heic" => Heif // Support crops created in the early years of Grid (~2016) which state mime type w/out an 'image/' prefix // TODO correct these values in a reindex @@ -51,3 +54,4 @@ object Jpeg extends MimeType { object Png extends MimeType object Tiff extends MimeType +object Heif extends MimeType diff --git a/common-lib/src/main/scala/com/gu/mediaservice/model/ThrallMessage.scala b/common-lib/src/main/scala/com/gu/mediaservice/model/ThrallMessage.scala index ee7ba270df3..f7fc7d26dc9 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/model/ThrallMessage.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/model/ThrallMessage.scala @@ -25,13 +25,13 @@ sealed trait InternalThrallMessage extends ThrallMessage {} sealed trait MigrationMessage extends InternalThrallMessage {} -case class MigrateImageMessage(id: String, maybeImageWithVersion: Either[String, (Image, Long)]) extends MigrationMessage +case class MigrateImageMessage(id: String, maybeImageWithVersion: Either[String, (Image, Long)], instance: Instance) extends MigrationMessage object MigrateImageMessage { - def apply(imageId: String, maybeProjection: Option[Image], maybeVersion: Option[Long]): MigrateImageMessage = (maybeProjection, maybeVersion) match { - case (Some(projection), Some(version)) => MigrateImageMessage(imageId, scala.Right((projection, version))) - case (None, _) => MigrateImageMessage(imageId, Left("There was no projection returned")) - case _ => MigrateImageMessage(imageId, Left("There was no version returned")) + def apply(imageId: String, maybeProjection: Option[Image], maybeVersion: Option[Long], instance: Instance): MigrateImageMessage = (maybeProjection, maybeVersion) match { + case (Some(projection), Some(version)) => MigrateImageMessage(imageId, scala.Right((projection, version)), instance) + case (None, _) => MigrateImageMessage(imageId, Left("There was no projection returned"), instance) + case _ => MigrateImageMessage(imageId, Left("There was no version returned"), instance) } } @@ -45,6 +45,7 @@ sealed trait ExternalThrallMessage extends ThrallMessage { implicit val yourJodaDateWrites: Writes[DateTime] = JodaWrites.JodaDateTimeWrites val id: String val lastModified: DateTime + val instance: Instance def toJson: JsValue = Json.toJson(this)(ExternalThrallMessage.writes) override def markerContents: Map[String, Any] = { @@ -61,6 +62,8 @@ object ExternalThrallMessage{ implicit val yourJodaDateReads: Reads[DateTime] = JodaReads.DefaultJodaDateTimeReads.map(d => d.withZone(DateTimeZone.UTC)) implicit val yourJodaDateWrites: Writes[DateTime] = JodaWrites.JodaDateTimeWrites + implicit val instanceMessageFormat: OFormat[Instance] = Json.format[Instance] + implicit val usageNoticeFormat: OFormat[UsageNotice] = Json.format[UsageNotice] implicit val replaceImageLeasesMessageFormat: OFormat[ReplaceImageLeasesMessage] = Json.format[ReplaceImageLeasesMessage] @@ -85,56 +88,62 @@ object ExternalThrallMessage{ implicit val completeMigrationMessage: OFormat[CompleteMigrationMessage] = Json.format[CompleteMigrationMessage] implicit val upsertFromProjectionMessage: OFormat[UpsertFromProjectionMessage] = Json.format[UpsertFromProjectionMessage] + implicit val createInstanceMessage: OFormat[CreateInstanceMessage] = Json.format[CreateInstanceMessage] + implicit val reindexImageMessage: OFormat[ReindexImageMessage] = Json.format[ReindexImageMessage] + implicit val writes: OWrites[ExternalThrallMessage] = Json.writes[ExternalThrallMessage] implicit val reads: Reads[ExternalThrallMessage] = Json.reads[ExternalThrallMessage] } -case class ImageMessage(lastModified: DateTime, image: Image) extends ExternalThrallMessage { +case class ImageMessage(lastModified: DateTime, image: Image, instance: Instance) extends ExternalThrallMessage { override def additionalMarkers: () => Map[String, Any] = ()=> Map("fileName" -> image.source.file.toString) override val id: String = image.id } -case class DeleteImageMessage(id: String, lastModified: DateTime) extends ExternalThrallMessage +case class DeleteImageMessage(id: String, lastModified: DateTime, instance: Instance) extends ExternalThrallMessage -case class SoftDeleteImageMessage(id: String, lastModified: DateTime, softDeletedMetadata: SoftDeletedMetadata) extends ExternalThrallMessage +case class SoftDeleteImageMessage(id: String, lastModified: DateTime, softDeletedMetadata: SoftDeletedMetadata, instance: Instance) extends ExternalThrallMessage -case class UnSoftDeleteImageMessage(id: String, lastModified: DateTime) extends ExternalThrallMessage +case class UnSoftDeleteImageMessage(id: String, lastModified: DateTime, instance: Instance) extends ExternalThrallMessage -case class DeleteImageExportsMessage(id: String, lastModified: DateTime) extends ExternalThrallMessage +case class DeleteImageExportsMessage(id: String, lastModified: DateTime, instance: Instance) extends ExternalThrallMessage -case class UpdateImageExportsMessage(id: String, lastModified: DateTime, crops: Seq[Crop]) extends ExternalThrallMessage +case class UpdateImageExportsMessage(id: String, lastModified: DateTime, crops: Seq[Crop], instance: Instance) extends ExternalThrallMessage -case class UpdateImageUserMetadataMessage(id: String, lastModified: DateTime, edits: Edits) extends ExternalThrallMessage +case class UpdateImageUserMetadataMessage(id: String, lastModified: DateTime, edits: Edits, instance: Instance) extends ExternalThrallMessage -case class UpdateImageUsagesMessage(id: String, lastModified: DateTime, usageNotice: UsageNotice) extends ExternalThrallMessage +case class UpdateImageUsagesMessage(id: String, lastModified: DateTime, usageNotice: UsageNotice, instance: Instance) extends ExternalThrallMessage -case class ReplaceImageLeasesMessage(id: String, lastModified: DateTime, leases: Seq[MediaLease]) extends ExternalThrallMessage +case class ReplaceImageLeasesMessage(id: String, lastModified: DateTime, leases: Seq[MediaLease], instance: Instance) extends ExternalThrallMessage -case class AddImageLeaseMessage(id: String, lastModified: DateTime, lease: MediaLease) extends ExternalThrallMessage +case class AddImageLeaseMessage(id: String, lastModified: DateTime, lease: MediaLease, instance: Instance) extends ExternalThrallMessage -case class RemoveImageLeaseMessage(id: String, lastModified: DateTime, leaseId: String) extends ExternalThrallMessage +case class RemoveImageLeaseMessage(id: String, lastModified: DateTime, leaseId: String, instance: Instance) extends ExternalThrallMessage -case class SetImageCollectionsMessage(id: String, lastModified: DateTime, collections: Seq[Collection]) extends ExternalThrallMessage +case class SetImageCollectionsMessage(id: String, lastModified: DateTime, collections: Seq[Collection], instance: Instance) extends ExternalThrallMessage -case class DeleteSingleUsageMessage(id: String, lastModified: DateTime, usageId: String) extends ExternalThrallMessage +case class DeleteSingleUsageMessage(id: String, lastModified: DateTime, usageId: String, instance: Instance) extends ExternalThrallMessage -case class DeleteUsagesMessage(id: String, lastModified: DateTime) extends ExternalThrallMessage +case class DeleteUsagesMessage(id: String, lastModified: DateTime, instance: Instance) extends ExternalThrallMessage -case class UpdateUsageStatusMessage(id: String, usageNotice: UsageNotice, lastModified: DateTime) extends ExternalThrallMessage +case class UpdateUsageStatusMessage(id: String, usageNotice: UsageNotice, lastModified: DateTime, instance: Instance) extends ExternalThrallMessage object DeleteUsagesMessage { implicit val yourJodaDateReads: Reads[DateTime] = JodaReads.DefaultJodaDateTimeReads.map(d => d.withZone(DateTimeZone.UTC)) implicit val yourJodaDateWrites: Writes[DateTime] = JodaWrites.JodaDateTimeWrites + + implicit val instanceMessageFormat: OFormat[Instance] = Json.format[Instance] + implicit val what: OFormat[DeleteUsagesMessage] = Json.format[DeleteUsagesMessage] } -case class UpdateImageSyndicationMetadataMessage(id: String, lastModified: DateTime, maybeSyndicationRights: Option[SyndicationRights]) extends ExternalThrallMessage +case class UpdateImageSyndicationMetadataMessage(id: String, lastModified: DateTime, maybeSyndicationRights: Option[SyndicationRights], instance: Instance) extends ExternalThrallMessage -case class UpdateImagePhotoshootMetadataMessage(id: String, lastModified: DateTime, edits: Edits) extends ExternalThrallMessage +case class UpdateImagePhotoshootMetadataMessage(id: String, lastModified: DateTime, edits: Edits, instance: Instance) extends ExternalThrallMessage /** * Message to start a new 'migration' (for re-index, re-ingestion etc.) @@ -143,7 +152,8 @@ case class UpdateImagePhotoshootMetadataMessage(id: String, lastModified: DateTi */ case class CreateMigrationIndexMessage( migrationStart: DateTime, - gitHash: String + gitHash: String, + instance: Instance ) extends ExternalThrallMessage { val id: String = "N/A" val lastModified: DateTime = migrationStart @@ -152,8 +162,12 @@ case class CreateMigrationIndexMessage( s"images_${migrationStart.toString(DateTimeFormat.forPattern("yyyy-MM-dd_HH-mm-ss").withZoneUTC())}_${gitHash.take(7)}" } -case class UpsertFromProjectionMessage(id: String, image: Image, lastModified: DateTime) extends ExternalThrallMessage +case class UpsertFromProjectionMessage(id: String, image: Image, lastModified: DateTime, instance: Instance) extends ExternalThrallMessage -case class CompleteMigrationMessage(lastModified: DateTime) extends ExternalThrallMessage { +case class CompleteMigrationMessage(lastModified: DateTime, instance: Instance) extends ExternalThrallMessage { val id: String = "N/A" } + +case class CreateInstanceMessage(id: String, lastModified: DateTime, instance: Instance) extends ExternalThrallMessage + +case class ReindexImageMessage(id: String, lastModified: DateTime, instance: Instance) extends ExternalThrallMessage diff --git a/common-lib/src/main/scala/com/gu/mediaservice/model/UploadInfo.scala b/common-lib/src/main/scala/com/gu/mediaservice/model/UploadInfo.scala index 5b8beb65455..b4c10e0c51c 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/model/UploadInfo.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/model/UploadInfo.scala @@ -2,7 +2,7 @@ package com.gu.mediaservice.model import play.api.libs.json.{Json, OWrites, Reads} -case class UploadInfo(filename: Option[String] = None) +case class UploadInfo(filename: Option[String] = None, isFeedUpload: Option[Boolean] = None) object UploadInfo { implicit val jsonWrites: OWrites[UploadInfo] = Json.writes[UploadInfo] diff --git a/common-lib/src/main/scala/com/gu/mediaservice/model/UsageRights.scala b/common-lib/src/main/scala/com/gu/mediaservice/model/UsageRights.scala index 2db517e51d9..3921d948d45 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/model/UsageRights.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/model/UsageRights.scala @@ -182,9 +182,9 @@ object Chargeable extends UsageRightsSpec { val defaultCost = Some(Pay) def name(commonConfig: CommonConfig) = "Chargeable supplied / on spec" def description(commonConfig: CommonConfig) = - s"Images acquired by or supplied to ${commonConfig.staffPhotographerOrganisation} that do not fit other categories in ${commonConfig.systemName} and " + + s"Images acquired or supplied that do not fit other categories in ${commonConfig.systemName} and " + "therefore fees will be payable per use. Unless negotiated otherwise, fees should be based on " + - s"standard published ${commonConfig.staffPhotographerOrganisation} rates for stock and speculative images." + s"standard published rates for stock and speculative images." implicit val formats: Format[Chargeable] = UsageRights.subtypeFormat(Chargeable.category)(Json.format[Chargeable]) @@ -289,7 +289,7 @@ object Screengrab extends UsageRightsSpec { val defaultCost = Some(Free) def name(commonConfig: CommonConfig) = "Screengrab" def description(commonConfig: CommonConfig) = - s"Stills created by ${commonConfig.staffPhotographerOrganisation} from moving footage in television broadcasts usually in relation to " + + s"Stills created by us from moving footage in television broadcasts usually in relation to " + "breaking news stories." implicit val formats: Format[Screengrab] = diff --git a/common-lib/src/main/scala/com/gu/mediaservice/model/usage/DigitalUsageMetadata.scala b/common-lib/src/main/scala/com/gu/mediaservice/model/usage/DigitalUsageMetadata.scala index df6337a5f89..117a6e36959 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/model/usage/DigitalUsageMetadata.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/model/usage/DigitalUsageMetadata.scala @@ -2,22 +2,21 @@ package com.gu.mediaservice.model.usage import java.net.URI import play.api.libs.json._ -import com.gu.mediaservice.syntax._ +import org.joda.time.DateTime case class DigitalUsageMetadata ( webUrl: URI, - webTitle: String, - sectionId: String, + webTitle: Option[String], + sectionId: Option[String], composerUrl: Option[URI] = None ) extends UsageMetadata { private val placeholderWebTitle = "No title given" - private val dynamoSafeWebTitle = if(webTitle.isEmpty) placeholderWebTitle else webTitle + private val dynamoSafeWebTitle = webTitle.find(_.nonEmpty).getOrElse(placeholderWebTitle) override def toMap: Map[String, String] = Map( "webUrl" -> webUrl.toString, - "webTitle" -> dynamoSafeWebTitle, - "sectionId" -> sectionId - ) ++ composerUrl.map("composerUrl" -> _.toString) + "webTitle" -> dynamoSafeWebTitle + ) ++ sectionId.filter(_.nonEmpty).map("sectionId" -> _) ++ composerUrl.map("composerUrl" -> _.toString) } object DigitalUsageMetadata { diff --git a/common-lib/src/main/scala/com/gu/mediaservice/model/usage/UsageNotice.scala b/common-lib/src/main/scala/com/gu/mediaservice/model/usage/UsageNotice.scala index 27abc9ec487..a1e57b0cd38 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/model/usage/UsageNotice.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/model/usage/UsageNotice.scala @@ -1,10 +1,11 @@ package com.gu.mediaservice.model.usage import com.gu.mediaservice.lib.formatting.printDateTime +import com.gu.mediaservice.model.Instance import org.joda.time.DateTime import play.api.libs.json.{JodaWrites, JsArray, JsObject, Json} -case class UsageNotice(mediaId: String, usageJson: JsArray) { +case class UsageNotice(mediaId: String, usageJson: JsArray, instance: Instance) { def toJson = Json.obj( "id" -> mediaId, "data" -> usageJson, diff --git a/common-lib/src/main/scala/com/gu/mediaservice/syntax/MessageSubjects.scala b/common-lib/src/main/scala/com/gu/mediaservice/syntax/MessageSubjects.scala index a490f427f5c..b0a2f32c6ee 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/syntax/MessageSubjects.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/syntax/MessageSubjects.scala @@ -21,7 +21,8 @@ trait MessageSubjects { val DeleteSingleUsage = "delete-single-usage" val UpdateImageSyndicationMetadata = "update-image-syndication-metadata" val UpdateImagePhotoshootMetadata = "update-image-photoshoot-metadata" - + val CreateInstance = "create-instance" + val ReindexImage = "reindex-image" } object MessageSubjects extends MessageSubjects diff --git a/common-lib/src/test/resources/CMYK-with-profile.jpg b/common-lib/src/test/resources/CMYK-with-profile.jpg new file mode 100644 index 00000000000..6c78dff467a Binary files /dev/null and b/common-lib/src/test/resources/CMYK-with-profile.jpg differ diff --git a/common-lib/src/test/resources/IMG_0128.HEIC b/common-lib/src/test/resources/IMG_0128.HEIC new file mode 100644 index 00000000000..a5fa30b48a5 Binary files /dev/null and b/common-lib/src/test/resources/IMG_0128.HEIC differ diff --git a/common-lib/src/test/resources/IMG_4403.JPG b/common-lib/src/test/resources/IMG_4403.JPG new file mode 100755 index 00000000000..da8ed3230d9 Binary files /dev/null and b/common-lib/src/test/resources/IMG_4403.JPG differ diff --git a/common-lib/src/test/resources/TIFF 8bit noAlpha.tif b/common-lib/src/test/resources/TIFF 8bit noAlpha.tif new file mode 100644 index 00000000000..08e57087e94 Binary files /dev/null and b/common-lib/src/test/resources/TIFF 8bit noAlpha.tif differ diff --git a/common-lib/src/test/resources/TIFF 8bit unassociatedAlpha.tif b/common-lib/src/test/resources/TIFF 8bit unassociatedAlpha.tif new file mode 100644 index 00000000000..fb8a2af5d9b Binary files /dev/null and b/common-lib/src/test/resources/TIFF 8bit unassociatedAlpha.tif differ diff --git a/common-lib/src/test/resources/application.conf b/common-lib/src/test/resources/application.conf index 962a7678896..b5c3e1aee23 100644 --- a/common-lib/src/test/resources/application.conf +++ b/common-lib/src/test/resources/application.conf @@ -56,3 +56,15 @@ usageRightsConfigProvider = { suppliersCollectionExcl {} } } + +instance.service.my="" +instance.service.instances="" + +usageEvents.queue.name="" + +s3.image.bucket.name="images" +s3.image.bucket.endpoint="some-providers-s3-endpoint" + +s3.thumb.bucket.name="thumbs" +s3.thumb.bucket.endpoint="some-providers-s3-endpoint" + diff --git a/rest-lib/src/test/resources/cmyk.jpg b/common-lib/src/test/resources/cmyk.jpg similarity index 100% rename from rest-lib/src/test/resources/cmyk.jpg rename to common-lib/src/test/resources/cmyk.jpg diff --git a/common-lib/src/test/resources/cs-black-000.png b/common-lib/src/test/resources/cs-black-000.png new file mode 100644 index 00000000000..01f409774ad Binary files /dev/null and b/common-lib/src/test/resources/cs-black-000.png differ diff --git a/common-lib/src/test/resources/exif-orientated-no-rotation.jpg b/common-lib/src/test/resources/exif-orientated-no-rotation.jpg new file mode 100755 index 00000000000..0f37a7c3dbd Binary files /dev/null and b/common-lib/src/test/resources/exif-orientated-no-rotation.jpg differ diff --git a/common-lib/src/test/resources/exif-orientated.jpg b/common-lib/src/test/resources/exif-orientated.jpg new file mode 100755 index 00000000000..d7e45067170 Binary files /dev/null and b/common-lib/src/test/resources/exif-orientated.jpg differ diff --git a/common-lib/src/test/resources/flower.tif b/common-lib/src/test/resources/flower.tif new file mode 100644 index 00000000000..2278aff1822 Binary files /dev/null and b/common-lib/src/test/resources/flower.tif differ diff --git a/rest-lib/src/test/resources/grayscale-with-profile.jpg b/common-lib/src/test/resources/grayscale-with-profile.jpg similarity index 100% rename from rest-lib/src/test/resources/grayscale-with-profile.jpg rename to common-lib/src/test/resources/grayscale-with-profile.jpg diff --git a/rest-lib/src/test/resources/grayscale-wo-profile.jpg b/common-lib/src/test/resources/grayscale-wo-profile.jpg similarity index 100% rename from rest-lib/src/test/resources/grayscale-wo-profile.jpg rename to common-lib/src/test/resources/grayscale-wo-profile.jpg diff --git a/common-lib/src/test/resources/halfdome_LAB.tif b/common-lib/src/test/resources/halfdome_LAB.tif new file mode 100644 index 00000000000..564562e5ea6 Binary files /dev/null and b/common-lib/src/test/resources/halfdome_LAB.tif differ diff --git a/common-lib/src/test/resources/halfdome_LAB16.tif b/common-lib/src/test/resources/halfdome_LAB16.tif new file mode 100644 index 00000000000..0b54c644b30 Binary files /dev/null and b/common-lib/src/test/resources/halfdome_LAB16.tif differ diff --git a/common-lib/src/test/resources/lab16-with-alpha-unsigned-unassociated.tif b/common-lib/src/test/resources/lab16-with-alpha-unsigned-unassociated.tif new file mode 100644 index 00000000000..662f0514770 Binary files /dev/null and b/common-lib/src/test/resources/lab16-with-alpha-unsigned-unassociated.tif differ diff --git a/common-lib/src/test/resources/lab8-with-alpha-unsigned.tif b/common-lib/src/test/resources/lab8-with-alpha-unsigned.tif new file mode 100644 index 00000000000..4d55c4da679 Binary files /dev/null and b/common-lib/src/test/resources/lab8-with-alpha-unsigned.tif differ diff --git a/common-lib/src/test/resources/lab8-with-alpha.tif b/common-lib/src/test/resources/lab8-with-alpha.tif new file mode 100644 index 00000000000..f63cc44b309 Binary files /dev/null and b/common-lib/src/test/resources/lab8-with-alpha.tif differ diff --git a/rest-lib/src/test/resources/rgb-with-cmyk-profile.jpg b/common-lib/src/test/resources/rgb-with-cmyk-profile.jpg similarity index 100% rename from rest-lib/src/test/resources/rgb-with-cmyk-profile.jpg rename to common-lib/src/test/resources/rgb-with-cmyk-profile.jpg diff --git a/rest-lib/src/test/resources/rgb-with-rgb-profile.jpg b/common-lib/src/test/resources/rgb-with-rgb-profile.jpg similarity index 100% rename from rest-lib/src/test/resources/rgb-with-rgb-profile.jpg rename to common-lib/src/test/resources/rgb-with-rgb-profile.jpg diff --git a/rest-lib/src/test/resources/rgb-wo-profile.jpg b/common-lib/src/test/resources/rgb-wo-profile.jpg similarity index 100% rename from rest-lib/src/test/resources/rgb-wo-profile.jpg rename to common-lib/src/test/resources/rgb-wo-profile.jpg diff --git a/common-lib/src/test/resources/schaik.com_pngsuite/README.md b/common-lib/src/test/resources/schaik.com_pngsuite/README.md new file mode 100644 index 00000000000..3854ba52a73 --- /dev/null +++ b/common-lib/src/test/resources/schaik.com_pngsuite/README.md @@ -0,0 +1,25 @@ +Test PNG images +=============== + +These images are taken from http://www.schaik.com/pngsuite/pngsuite_bas_png.html + + basn0g08 - 8 bit (256 level) grayscale + basn2c08 - 3x8 bits rgb color + basn3p08 - 8 bit (256 color) paletted + basn6a08 - 3x8 bits rgb color + 8 bit alpha-channel + +LICENCE +------- + +At the time of downloading these images the licence file at http://www.schaik.com/pngsuite/PngSuite.LICENSE contained the following text: + +``` +PngSuite +-------- + +Permission to use, copy, modify and distribute these images for any +purpose and without fee is hereby granted. + + +(c) Willem van Schaik, 1996, 2011 +``` diff --git a/common-lib/src/test/resources/schaik.com_pngsuite/basi2c16.png b/common-lib/src/test/resources/schaik.com_pngsuite/basi2c16.png new file mode 100644 index 00000000000..cd7e50f9140 Binary files /dev/null and b/common-lib/src/test/resources/schaik.com_pngsuite/basi2c16.png differ diff --git a/common-lib/src/test/resources/schaik.com_pngsuite/basn0g08.png b/common-lib/src/test/resources/schaik.com_pngsuite/basn0g08.png new file mode 100644 index 00000000000..23c82379a29 Binary files /dev/null and b/common-lib/src/test/resources/schaik.com_pngsuite/basn0g08.png differ diff --git a/common-lib/src/test/resources/schaik.com_pngsuite/basn3p08.png b/common-lib/src/test/resources/schaik.com_pngsuite/basn3p08.png new file mode 100644 index 00000000000..0ddad07e5f5 Binary files /dev/null and b/common-lib/src/test/resources/schaik.com_pngsuite/basn3p08.png differ diff --git a/common-lib/src/test/resources/schaik.com_pngsuite/tbbn0g04.png b/common-lib/src/test/resources/schaik.com_pngsuite/tbbn0g04.png new file mode 100644 index 00000000000..39a7050d27a Binary files /dev/null and b/common-lib/src/test/resources/schaik.com_pngsuite/tbbn0g04.png differ diff --git a/common-lib/src/test/resources/with-alpha.png b/common-lib/src/test/resources/with-alpha.png new file mode 100644 index 00000000000..2e765a1ba2c Binary files /dev/null and b/common-lib/src/test/resources/with-alpha.png differ diff --git a/common-lib/src/test/resources/with-alpha.tif b/common-lib/src/test/resources/with-alpha.tif new file mode 100644 index 00000000000..a4efd9cc37b Binary files /dev/null and b/common-lib/src/test/resources/with-alpha.tif differ diff --git a/common-lib/src/test/scala/com/gu/mediaservice/lib/aws/ContentDispositionTest.scala b/common-lib/src/test/scala/com/gu/mediaservice/lib/aws/ContentDispositionTest.scala index 90b5142e8af..efccf8d7b59 100644 --- a/common-lib/src/test/scala/com/gu/mediaservice/lib/aws/ContentDispositionTest.scala +++ b/common-lib/src/test/scala/com/gu/mediaservice/lib/aws/ContentDispositionTest.scala @@ -25,17 +25,17 @@ class ContentDispositionTest extends AnyFunSuiteLike with ContentDisposition { header shouldBe """attachment; filename="abcdef1234567890.jpg"; filename*=UTF-8''%C2%A9House%20of%20Commons_240508_MU_PMQs-09_42668%20%28abcdef1234567890%29.jpg""" } - test("include crop id and dimensions in main crop asset filename") { + test("crop asset filename is the image uploaded filename with the correct file extension for the crop mime type") { val image = withFilename(MappingTest.testImage, "imagefilename.jpg") val crop = image.exports.head val cropAsset = crop.assets.head - val header = getContentDisposition(image, crop, cropAsset, shortenDownloadFilename = false) + val header = getContentDisposition(image, crop, cropAsset) // Latin1 fallback wants to remain simple header.contains("""filename="abcdef1234567890.jpg";""") shouldBe true val decoded = URLDecoder.decode(header, "UTF-8") - decoded shouldBe """attachment; filename="abcdef1234567890.jpg"; filename*=UTF-8''imagefilename (abcdef1234567890)(1234567890987654321)(1000 x 2000).jpg""" + decoded shouldBe """attachment; filename="abcdef1234567890.jpg"; filename*=UTF-8''imagefilename.jpg""" } test("use just the generated filename suffix as filename if short filenames are requested") { diff --git a/common-lib/src/test/scala/com/gu/mediaservice/lib/aws/DynamoDBTest.scala b/common-lib/src/test/scala/com/gu/mediaservice/lib/aws/DynamoDBTest.scala index 895d9cb4abb..755491e9a1d 100644 --- a/common-lib/src/test/scala/com/gu/mediaservice/lib/aws/DynamoDBTest.scala +++ b/common-lib/src/test/scala/com/gu/mediaservice/lib/aws/DynamoDBTest.scala @@ -1,121 +1,47 @@ package com.gu.mediaservice.lib.aws -import com.amazonaws.services.dynamodbv2.document.spec.UpdateItemSpec -import com.amazonaws.services.dynamodbv2.document.utils.ValueMap -import com.amazonaws.services.dynamodbv2.model.ReturnValue -import com.gu.mediaservice.model.{ActionData, Collection} -import org.joda.time.DateTime import org.scalatest.funspec.AnyFunSpec import org.scalatest.matchers.should.Matchers -import play.api.libs.json.{Format, JsObject, Json} - -import scala.jdk.CollectionConverters._ +import play.api.libs.json.{Format, Json} class DynamoDBTest extends AnyFunSpec with Matchers { - describe("jsonToValueMap") { - it ("should convert a simple JsObject to a valueMap") { - val json = Json.toJson(SimpleDynamoDBObj("this is a string", 100, true, List("list"))).as[JsObject] - val valueMap = DynamoDB.jsonToValueMap(json) - - - // This is the only way to get stuff type safely out of the valueMap - // It's not a problem as we shoulnd't be doing this anywhere else - val s: String = valueMap.get("s").asInstanceOf[String] - val d: BigDecimal = valueMap.get("d").asInstanceOf[java.math.BigDecimal] - val b: Boolean = valueMap.get("b").asInstanceOf[Boolean] - val a: List[String] = valueMap.get("a").asInstanceOf[java.util.ArrayList[String]].toArray().toList.map(_.asInstanceOf[String]) - - - s should be ("this is a string") - d should be (100) - b should equal(true) - a should equal(List("list")) + describe("removeExpr") { + it("should generate the correction expression when the lastModified is set") { + DynamoDB.removeExpr("key", Some("lastModifiedKey")) shouldBe "REMOVE key SET lastModifiedKey = :lastModifiedKey" } - - it ("should convert a nested JsObject to a valueMap") { - val nestedObj = NestedDynamoDBObj("string", 100, false, SimpleDynamoDBObj("strang", 500, true, List("list"))) - val json = Json.toJson(nestedObj).as[JsObject] - val valueMap = DynamoDB.jsonToValueMap(json) - - val ss: String = valueMap.get("ss").asInstanceOf[String] - val dd: BigDecimal = valueMap.get("dd").asInstanceOf[java.math.BigDecimal] - val bb: Boolean = valueMap.get("bb").asInstanceOf[Boolean] - - val simpleMap = valueMap.get("simple").asInstanceOf[ValueMap] - - val s: String = simpleMap.get("s").asInstanceOf[String] - val d: BigDecimal = simpleMap.get("d").asInstanceOf[java.math.BigDecimal] - val b: Boolean = simpleMap.get("b").asInstanceOf[Boolean] - val a: List[String] = simpleMap.get("a").asInstanceOf[java.util.ArrayList[String]].toArray().toList.map(_.asInstanceOf[String]) - - - ss should be ("string") - dd should be (100) - bb should equal(false) - - s should be ("strang") - d should be (500) - b should equal(true) - a should equal(List("list")) + it("should generate the correction expression when the lastModified is not set") { + DynamoDB.removeExpr("key", None) shouldBe "REMOVE key" } + } - it ("should convert a Collection to ValueMap") { - val collection = Collection.build(List("g2", "art", "batik"), ActionData("mighty.mouse@guardian.co.uk", DateTime.now)) - val json = Json.toJson(collection).as[JsObject] - val valueMap = DynamoDB.jsonToValueMap(json) - - val path = valueMap.get("path").asInstanceOf[java.util.ArrayList[String]].toArray.toList.asInstanceOf[List[String]] - val pathId = valueMap.get("pathId").asInstanceOf[String] - val actionData = valueMap.get("actionData").asInstanceOf[ValueMap] - val author = actionData.get("author").asInstanceOf[String] - - pathId should be (collection.pathId) - author should be (collection.actionData.author) - path should be (collection.path) + describe("setExpr") { + it("should generate the correction expression when the lastModified is set") { + DynamoDB.setExpr("key", Some("lastModifiedKey")) shouldBe "SET key = :value, lastModifiedKey = :lastModifiedKey" + } + it("should generate the correction expression when the lastModified is not set") { + DynamoDB.setExpr("key", None) shouldBe "SET key = :value" } } - describe("addLastModifiedUpdate") { - val testTime = new DateTime(2021, 3, 22, 14, 58) - - it ("should prefix a lastModified update to existing SET expression") { - val input = new UpdateItemSpec() - .withUpdateExpression("SET anotherKey = :anotherValue") - val output = DynamoDB.addLastModifiedUpdate(input, "lm", testTime) - output.getUpdateExpression shouldBe "SET lm = :lm, anotherKey = :anotherValue" + describe("addExpr") { + it("should generate the correction expression when the lastModified is set") { + DynamoDB.addExpr("key", Some("lastModifiedKey")) shouldBe "ADD key :value SET lastModifiedKey = :lastModifiedKey" } - - it ("should prefix a lastModified SET clause to existing ADD expression") { - val input = new UpdateItemSpec() - .withUpdateExpression("ADD anotherKey :anotherValue") - val output = DynamoDB.addLastModifiedUpdate(input, "lm", testTime) - output.getUpdateExpression shouldBe "SET lm = :lm ADD anotherKey :anotherValue" + it("should generate the correction expression when the lastModified is not set") { + DynamoDB.addExpr("key", None) shouldBe "ADD key :value" } + } - it ("should add a date time to an existing value map") { - val valueMap = new ValueMap() - valueMap.put(":anotherValue", "anotherValue") - val input = new UpdateItemSpec() - .withUpdateExpression("banana") - .withValueMap(valueMap) - val output = DynamoDB.addLastModifiedUpdate(input, "lm", testTime) - output.getValueMap.asScala shouldBe Map( - ":anotherValue" -> "anotherValue", - ":lm" -> testTime.toString - ) + describe("deleteExpr") { + it("should generate the correction expression when the lastModified is set") { + DynamoDB.deleteExpr("key", Some("lastModifiedKey")) shouldBe + "DELETE key :value SET lastModifiedKey = :lastModifiedKey" } - - it ("should add a date time when there is no value map") { - val input = new UpdateItemSpec() - .withUpdateExpression("banana") - val output = DynamoDB.addLastModifiedUpdate(input, "lm", testTime) - output.getValueMap.asScala shouldBe Map( - ":lm" -> testTime.toString - ) + it("should generate the correction expression when the lastModified is not set") { + DynamoDB.deleteExpr("key", None) shouldBe "DELETE key :value" } } - } case class SimpleDynamoDBObj(s: String, d: BigDecimal, b: Boolean, a: List[String]) diff --git a/common-lib/src/test/scala/com/gu/mediaservice/lib/aws/ThrallMessageSenderTest.scala b/common-lib/src/test/scala/com/gu/mediaservice/lib/aws/ThrallMessageSenderTest.scala index 0b505673211..f126f7be964 100644 --- a/common-lib/src/test/scala/com/gu/mediaservice/lib/aws/ThrallMessageSenderTest.scala +++ b/common-lib/src/test/scala/com/gu/mediaservice/lib/aws/ThrallMessageSenderTest.scala @@ -1,5 +1,6 @@ package com.gu.mediaservice.lib.aws +import com.gu.mediaservice.model.Instance import org.scalatest.funspec.AnyFunSpec import org.scalatest.matchers.should.Matchers import play.api.libs.json.Json @@ -12,7 +13,7 @@ class ThrallMessageSenderTest extends AnyFunSpec with Matchers { describe("json to message and back") { // This is most interested for ensuring time zone correctness it ("should convert a message to json and back again") { - val m = UpdateMessage(subject = "test") + val m = UpdateMessage(subject = "test", instance = Instance("an-instance")) val j = Json.toJson(m).toString() val m2 = Json.parse(j).as[UpdateMessage] m2 shouldEqual m @@ -20,7 +21,7 @@ class ThrallMessageSenderTest extends AnyFunSpec with Matchers { it ("should convert a message from an external source which does not have last modified") { val subject = "test" - val j = s"""{"subject":"$subject"}""" + val j = s"""{"subject":"$subject", "instance": {"id": "an-instance"}}""" val m = Json.parse(j).as[UpdateMessage] m.lastModified.getZone.toString should be ("UTC") } @@ -28,7 +29,7 @@ class ThrallMessageSenderTest extends AnyFunSpec with Matchers { it ("should convert a message last modified with an offset timezone to UTC") { val now = DateTime.now(DateTimeZone.forOffsetHours(9)) val nowUtc = new DateTime(now.getMillis()).toDateTime(DateTimeZone.UTC) - val m = UpdateMessage(subject = "test", lastModified = now) + val m = UpdateMessage(subject = "test", lastModified = now, instance = Instance("an-instance")) val j = Json.toJson(m).toString() val m2 = Json.parse(j).as[UpdateMessage] m2 shouldEqual m.copy(lastModified = nowUtc) diff --git a/common-lib/src/test/scala/com/gu/mediaservice/lib/cleanup/MetadataHelper.scala b/common-lib/src/test/scala/com/gu/mediaservice/lib/cleanup/MetadataHelper.scala index 00df37e4973..827fa41d94e 100644 --- a/common-lib/src/test/scala/com/gu/mediaservice/lib/cleanup/MetadataHelper.scala +++ b/common-lib/src/test/scala/com/gu/mediaservice/lib/cleanup/MetadataHelper.scala @@ -19,7 +19,7 @@ trait MetadataHelper { softDeletedMetadata = None, lastModified = None, identifiers = Map(), - uploadInfo = UploadInfo(), + uploadInfo = UploadInfo(isFeedUpload = Some(true)), source = Asset(URI.create("http://example.com/image.jpg"), Some(0), None, None, None, None), thumbnail = None, optimisedPng = None, diff --git a/common-lib/src/test/scala/com/gu/mediaservice/lib/cleanup/SupplierProcessorsTest.scala b/common-lib/src/test/scala/com/gu/mediaservice/lib/cleanup/SupplierProcessorsTest.scala index 6ee3a11d401..8a6da2cc22e 100644 --- a/common-lib/src/test/scala/com/gu/mediaservice/lib/cleanup/SupplierProcessorsTest.scala +++ b/common-lib/src/test/scala/com/gu/mediaservice/lib/cleanup/SupplierProcessorsTest.scala @@ -484,6 +484,175 @@ class SupplierProcessorsTest extends AnyFunSpec with Matchers with MetadataHelpe processedImage.usageRights should be (Agency("Rex Features")) } + + case class ImageDescriptionSpec(specDescription: String, image: Image, expectedDescription: String) + + val rexFixtures = List( + ImageDescriptionSpec("should remove instructions and credit when they match the metadata, 1", createImageFromMetadata( + "source" -> "Shutterstock Editorial", + "credit" -> "NurPhoto/Shutterstock", + "specialInstructions" -> "RESTRICTED TO EDITORIAL USE", + "byline" -> "Jose Breton", + "suppliersReference" -> "16507315bs", + "description" -> + """ + |RESTRICTED TO EDITORIAL USE + |Mandatory Credit: Photo by Jose Breton/NurPhoto/Shutterstock (16507315bs) + |Marcus Rashford left winger of Barcelona and England during the Copa del Rey quarter-final match between Albacete Balompie and FC Barcelona at Estadio Carlos Belmonte on February 3, 2026 in Albacete, Spain. + |Albacete Balompie v FC Barcelona - Copa Del Rey, Spain - 03 Feb 2026 + |""".stripMargin + ), + """ + |Marcus Rashford left winger of Barcelona and England during the Copa del Rey quarter-final match between Albacete Balompie and FC Barcelona at Estadio Carlos Belmonte on February 3, 2026 in Albacete, Spain. + |Albacete Balompie v FC Barcelona - Copa Del Rey, Spain - 03 Feb 2026 + |""".stripMargin), + ImageDescriptionSpec("should remove instructions and credit when they match the metadata, 2", createImageFromMetadata( + "source" -> "Shutterstock Editorial", + "credit" -> "ITV/Shutterstock", + "specialInstructions" -> "Editorial use only", + "byline" -> "Ken McKay", + "suppliersReference" -> "16513246a", + "description" -> + """ + |Editorial use only + |Mandatory Credit: Photo by Ken McKay/ITV/Shutterstock (16513246a) + |Katie Piper + |'Loose Women' TV show, London, UK - 04 Feb 2026 + |""".stripMargin + ), + """ + |Katie Piper + |'Loose Women' TV show, London, UK - 04 Feb 2026 + |""".stripMargin), + ImageDescriptionSpec("should remove credit when it matches the metadata, 1", createImageFromMetadata( + "source" -> "Shutterstock Editorial", + "credit" -> "ITV/Shutterstock", + "byline" -> "Action Press", + "suppliersReference" -> "16512200n", + "description" -> + """ + |Mandatory Credit: Photo by Action Press/Shutterstock (16512200n) + |Mark Tallman + |Apple TV+ Press Day, Santa Monica, USA - 03 Feb 2026 + |""".stripMargin + ), + """ + |Mark Tallman + |Apple TV+ Press Day, Santa Monica, USA - 03 Feb 2026 + |""".stripMargin), + ImageDescriptionSpec("should remove credit when it matches the metadata, 2", createImageFromMetadata( + "source" -> "Shutterstock Editorial", + "credit" -> "ITV/Shutterstock", + "byline" -> "Anthony Harvey", + "suppliersReference" -> "16501549o", + "description" -> + """ + |Mandatory Credit: Photo by Anthony Harvey/Shutterstock (16501549o) + |Alison Oliver + |'Wuthering Heights' film photocall, London, UK - 04 Feb 2026 + |""".stripMargin + ), + """ + |Alison Oliver + |'Wuthering Heights' film photocall, London, UK - 04 Feb 2026 + |""".stripMargin), + ImageDescriptionSpec("should not blow up when regex reserved chars are in relevant metadata", createImageFromMetadata( + "source" -> "Shutterstock Editorial", + "credit" -> "NurPhoto/Shutterstock", + "specialInstructions" -> "(RESTRICTED TO EDITORIAL USE", + "byline" -> "Jose Breton", + "suppliersReference" -> "(16507315bs", + "description" -> + """ + |(RESTRICTED TO EDITORIAL USE + |Mandatory Credit: Photo by Jose Breton/NurPhoto/Shutterstock ((16507315bs) + |Marcus Rashford left winger of Barcelona and England during the Copa del Rey quarter-final match between Albacete Balompie and FC Barcelona at Estadio Carlos Belmonte on February 3, 2026 in Albacete, Spain. + |Albacete Balompie v FC Barcelona - Copa Del Rey, Spain - 03 Feb 2026 + |""".stripMargin + ), + """ + |Marcus Rashford left winger of Barcelona and England during the Copa del Rey quarter-final match between Albacete Balompie and FC Barcelona at Estadio Carlos Belmonte on February 3, 2026 in Albacete, Spain. + |Albacete Balompie v FC Barcelona - Copa Del Rey, Spain - 03 Feb 2026 + |""".stripMargin), + ImageDescriptionSpec("should not remove credit when the credit includes a byline that is not in the metadata - 1", createImageFromMetadata( + "source" -> "Rex Features", + "credit" -> "REX/Shutterstock", + "byline" -> "ZUMA Wire", + "suppliersReference" -> "9011672i", + "description" -> + """ + |Mandatory Credit: Photo by Sachelle Babbar/ZUMA Wire/REX/Shutterstock (9011672i) + |New Yorkers demonstrate at 780 3rd avenue in Manhattan, the location of the Bank of Korea and the offices of Senator Chuck Schumer and Kirstin Gillibrand, against the sabre-rattling by both the Trump Administration and the government of the DPRK, also known as North Korea. + |Anti-nuclear war demonstration, New York, USA - 21 Aug 2017 + |""".stripMargin + ), + """ + |Mandatory Credit: Photo by Sachelle Babbar/ZUMA Wire/REX/Shutterstock (9011672i) + |New Yorkers demonstrate at 780 3rd avenue in Manhattan, the location of the Bank of Korea and the offices of Senator Chuck Schumer and Kirstin Gillibrand, against the sabre-rattling by both the Trump Administration and the government of the DPRK, also known as North Korea. + |Anti-nuclear war demonstration, New York, USA - 21 Aug 2017 + |""".stripMargin), + ImageDescriptionSpec("should not remove credit when the credit includes a byline that is not in the metadata - 2", createImageFromMetadata( + "source" -> "Rex Features", + "credit" -> "REX/Shutterstock", + "byline" -> "ZUMA Wire", + "suppliersReference" -> "9011672i", + "description" -> + """ + |Mandatory Credit: Photo by ZUMA Wire/Sachelle Babbar/REX/Shutterstock (9011672i) + |New Yorkers demonstrate at 780 3rd avenue in Manhattan, the location of the Bank of Korea and the offices of Senator Chuck Schumer and Kirstin Gillibrand, against the sabre-rattling by both the Trump Administration and the government of the DPRK, also known as North Korea. + |Anti-nuclear war demonstration, New York, USA - 21 Aug 2017 + |""".stripMargin + ), + """ + |Mandatory Credit: Photo by ZUMA Wire/Sachelle Babbar/REX/Shutterstock (9011672i) + |New Yorkers demonstrate at 780 3rd avenue in Manhattan, the location of the Bank of Korea and the offices of Senator Chuck Schumer and Kirstin Gillibrand, against the sabre-rattling by both the Trump Administration and the government of the DPRK, also known as North Korea. + |Anti-nuclear war demonstration, New York, USA - 21 Aug 2017 + |""".stripMargin), + ImageDescriptionSpec("should not remove credit when the credit includes a byline that is not in the metadata - 3", createImageFromMetadata( + "source" -> "Shutterstock Editorial", + "credit" -> "Pool/Yuri Gripas - Pool/CNP/Shutterstock", + "byline" -> "Yuri Gripas", + "suppliersReference" -> "16452941i", + "description" -> + """ + |Mandatory Credit: Photo by Yuri Gripas - Pool via CNP/Shutterstock (16452941i) + |White House Press Secretary Karoline Leavitt holds a press briefing in the James S Brady Press Briefing Room of the White House in Washington, DC, USA,. + |Karoline Leavitt press briefing - Washington, Washington, District of Columbia, USA - 26 Jan 2026 + |""".stripMargin + ), + """ + |Mandatory Credit: Photo by Yuri Gripas - Pool via CNP/Shutterstock (16452941i) + |White House Press Secretary Karoline Leavitt holds a press briefing in the James S Brady Press Briefing Room of the White House in Washington, DC, USA,. + |Karoline Leavitt press briefing - Washington, Washington, District of Columbia, USA - 26 Jan 2026 + |""".stripMargin), + ImageDescriptionSpec("should not remove credit when the credit includes a byline that is not in the metadata - 4", createImageFromMetadata( + "source" -> "Shutterstock Editorial", + "credit" -> "Nexpher/ZUMA Press Wire/Shutterstock", + "byline" -> "Kobe Li", + "suppliersReference" -> "16520092w", + "description" -> + """ + |Mandatory Credit: Photo by Kobe Li/Nexpher via ZUMA Press Wire/Shutterstock (16520092w) + |English snooker player, Mark Selby during a game at the 2026 World Snooker Grand Prix on February 5, 2026 in Hong Kong. + |2026 World Snooker Grand Prix - Day 3, Hong Kong, China - 05 Feb 2026 + |""".stripMargin + ), + """ + |Mandatory Credit: Photo by Kobe Li/Nexpher via ZUMA Press Wire/Shutterstock (16520092w) + |English snooker player, Mark Selby during a game at the 2026 World Snooker Grand Prix on February 5, 2026 in Hong Kong. + |2026 World Snooker Grand Prix - Day 3, Hong Kong, China - 05 Feb 2026 + |""".stripMargin), + ) + + rexFixtures.foreach { + case ImageDescriptionSpec(spec, image, expectedDescription) => + it(spec) { + val processedImage = applyProcessors(image) + + processedImage.usageRights should be(Agency("Rex Features")) + processedImage.metadata.description should be(Some(expectedDescription)) + } + } } diff --git a/common-lib/src/test/scala/com/gu/mediaservice/lib/collections/CollectionsManagerTest.scala b/common-lib/src/test/scala/com/gu/mediaservice/lib/collections/CollectionsManagerTest.scala index 9344ebf7d59..bcdc1f58772 100644 --- a/common-lib/src/test/scala/com/gu/mediaservice/lib/collections/CollectionsManagerTest.scala +++ b/common-lib/src/test/scala/com/gu/mediaservice/lib/collections/CollectionsManagerTest.scala @@ -85,6 +85,26 @@ class CollectionsManagerTest extends AnyFunSpec with Matchers { noIndex shouldBe Nil multiIndex shouldBe List(3, 4) } + it ("should find the filter the collections by index") { + val actionData = ActionData("me@you.com", DateTime.now()) + + val collection1 = Collection.build(List("g2"), actionData) + val collections2 = Collection.build(List("g2", "art"), actionData) + val collections3 = Collection.build(List("observer", "feature"), actionData) + + val collections = List( + collection1, + collections2, + collections3 + ) + val index = CollectionsManager.findIndexes(List("g2", "art"), collections) + val updatedColllections = CollectionsManager.filterCollectionsByIndexes(indexes = index, collections = collections) + + updatedColllections shouldEqual List( + collection1, + collections3 + ) + } } } diff --git a/common-lib/src/test/scala/com/gu/mediaservice/lib/collections/CssColoursTest.scala b/common-lib/src/test/scala/com/gu/mediaservice/lib/collections/CssColoursTest.scala new file mode 100644 index 00000000000..314fd4dd191 --- /dev/null +++ b/common-lib/src/test/scala/com/gu/mediaservice/lib/collections/CssColoursTest.scala @@ -0,0 +1,25 @@ +package com.gu.mediaservice.lib.collections + +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers + +class CssColoursTest extends AnyFunSpec with Matchers with CssColours { + + describe("CssColours") { + describe("getCssColour") { + it("should return exact match for specific collections") { + getCssColour(List("Home", "Supplements")) shouldBe Some("#008083") + } + it("should default of none for collections with no specific colour preferences or parents with a colour") { + getCssColour(List("Unknown")) shouldBe None + } + it("should return colour of parent for collection with no specific colour") { + getCssColour(List("Home", "Something")) shouldBe Some("#052962") + } + it("should return colour of closet parent for collection with no specific colour") { + getCssColour(List("Home", "Supplements", "Something", "Something else")) shouldBe Some("#008083") + } + } + } + +} diff --git a/common-lib/src/test/scala/com/gu/mediaservice/lib/imaging/ImageOperationsTest.scala b/common-lib/src/test/scala/com/gu/mediaservice/lib/imaging/ImageOperationsTest.scala index 8f78918f2db..44df76a8935 100644 --- a/common-lib/src/test/scala/com/gu/mediaservice/lib/imaging/ImageOperationsTest.scala +++ b/common-lib/src/test/scala/com/gu/mediaservice/lib/imaging/ImageOperationsTest.scala @@ -1,67 +1,511 @@ package com.gu.mediaservice.lib.imaging +import app.photofox.vipsffm.jextract.VipsRaw +import app.photofox.vipsffm.{VImage, Vips} +import com.gu.mediaservice.lib.BrowserViewableImage import com.gu.mediaservice.lib.logging.{LogMarker, MarkerMap} - -import java.io.File -import com.gu.mediaservice.model.Jpeg -import org.scalatest.time.{Millis, Span} -import org.scalatest.Ignore +import com.gu.mediaservice.model._ import org.scalatest.concurrent.ScalaFutures import org.scalatest.funspec.AnyFunSpec import org.scalatest.matchers.should.Matchers +import org.scalatest.time.{Millis, Span} +import java.io.File +import java.lang.foreign.Arena import scala.concurrent.ExecutionContext.Implicits.global // This test is disabled for now as it doesn't run on our CI environment, because GraphicsMagick is not present... -@Ignore class ImageOperationsTest extends AnyFunSpec with Matchers with ScalaFutures { + Vips.init() + implicit override val patienceConfig: PatienceConfig = PatienceConfig(timeout = Span(1000, Millis), interval = Span(25, Millis)) implicit val logMarker: LogMarker = MarkerMap() + private val metadata = ImageMetadata( + credit = Some("Tony McCrae"), + copyright = Some("Eel Pie Consulting Ltd"), + suppliersReference = Some("eelpie-123") + ) + + describe("thumbnail") { + it("should write thumbnail to output file") { + val image = fileAt("IMG_4403.jpg") + + val outputFile = new File("/Users/tony/Desktop/thumbnail.jpg") + val browserViewableImageImage = BrowserViewableImage("TODO", image, Tiff, Map.empty, false, Instance("TODO")) + + val eventualThumbnail = new ImageOperations("").createThumbnailVips(browserViewableImageImage, 240, 95, outputFile, None) + whenReady(eventualThumbnail) { r => + r._1.isFile should be(true) + } + } + + it("render LAB colour spaces correctly in sRGB") { + val image = fileAt("halfdome_LAB.tif") + + val outputFile = new File("/Users/tony/Desktop/out2.jpg") + val browserViewableImageImage = BrowserViewableImage("TODO", image, Tiff, Map.empty, false, Instance("TODO")) + + val eventualThumbnail = new ImageOperations("").createThumbnailVips(browserViewableImageImage, 1000, 95, outputFile, None) + whenReady(eventualThumbnail) { r => + r._1.isFile should be(true) + } + } + + it("render LAB 16 bits colour spaces correctly in 8 bit sRGB") { + val image = fileAt("halfdome_LAB16.tif") + + val outputFile = new File("/Users/tony/Desktop/out3.jpg") + val browserViewableImageImage = BrowserViewableImage("TODO", image, Tiff, Map.empty, false, Instance("TODO")) + + val eventualThumbnail = new ImageOperations("").createThumbnailVips(browserViewableImageImage, 1000, 95, outputFile, None) + whenReady(eventualThumbnail) { r => + r._1.isFile should be(true) + } + } + + it("render PNG with alpha correctly") { + val image = fileAt("with-alpha.png") + + val outputFile = new File("/Users/tony/Desktop/thumbnail-png-with-alpha.jpg") + val browserViewableImageImage = BrowserViewableImage("TODO", image, Tiff, Map.empty, false, Instance("TODO")) + + val eventualThumbnail = new ImageOperations("").createThumbnailVips(browserViewableImageImage, 1000, 95, outputFile, None) + whenReady(eventualThumbnail) { r => + r._1.isFile should be(true) + } + } + + it("render TIF with alpha correctly") { + val image = fileAt("with-alpha.tif") + + val outputFile = new File("/Users/tony/Desktop/thumbnail-tif-with-alpha.jpg") + val browserViewableImageImage = BrowserViewableImage("TODO", image, Tiff, Map.empty, false, Instance("TODO")) + + val eventualThumbnail = new ImageOperations("").createThumbnailVips(browserViewableImageImage, 1000, 95, outputFile, None) + whenReady(eventualThumbnail) { r => + r._1.isFile should be(true) + } + } + + it("render LAB colour spaces with alpha correctly in sRGB") { + val image = fileAt("lab8-with-alpha.tif") + + val outputFile = new File("/Users/tony/Desktop/out4.jpg") + val browserViewableImageImage = BrowserViewableImage("TODO", image, Tiff, Map.empty, false, Instance("TODO")) + + val eventualThumbnail = new ImageOperations("").createThumbnailVips(browserViewableImageImage, 1000, 95, outputFile, None) + whenReady(eventualThumbnail) { r => + r._1.isFile should be(true) + } + } + + it("render Heif correctly") { + val image = fileAt("IMG_0128.HEIC") + + val outputFile = new File("/Users/tony/Desktop/thumbnail-heic.jpg") + val browserViewableImageImage = BrowserViewableImage("TODO", image, Jpeg, Map.empty, false, Instance("TODO")) + + val eventualThumbnail = new ImageOperations("").createThumbnailVips(browserViewableImageImage, 1000, 95, outputFile, None) + whenReady(eventualThumbnail) { r => + r._1.isFile should be(true) + } + } + + } + + describe("resize") { + it("should output resized image to file in chosen format") { + implicit val arena: Arena = Arena.ofShared() + val fullSizedImage = VImage.newFromFile(arena, fileAt("IMG_4403.jpg").getAbsolutePath) + val imageOperations = new ImageOperations("") + + val outputFile = new File("/Users/tony/Desktop/out5.jpg") + + val eventuallyResized = imageOperations.resizeImageVips(fullSizedImage, Dimensions(1000, 800), 95, outputFile, Jpeg) + + whenReady(eventuallyResized) { resized => + arena.close() + resized.isFile should be(true) + } + } + + it("render LAB colour spaces correctly in sRGB") { + implicit val arena: Arena = Arena.ofShared + val imageOperations = new ImageOperations("") + + val fullSizedImage = VImage.newFromFile(arena, fileAt("halfdome_LAB.tif").getAbsolutePath) + val outputFile = new File("/Users/tony/Desktop/out6.jpg") + + val eventuallyResized = imageOperations.resizeImageVips(fullSizedImage, Dimensions(800, 600), 95, outputFile, Jpeg) + + whenReady(eventuallyResized) { resized => + arena.close() + resized.isFile should be(true) + } + } + + it("render LAB colour spaces correctly as PNG") { + implicit val arena: Arena = Arena.ofShared + val imageOperations = new ImageOperations("") + + val fullSizedImage = VImage.newFromFile(arena, fileAt("halfdome_LAB.tif").getAbsolutePath) + val outputFile = new File("/Users/tony/Desktop/out7.png") + + val eventuallyResized = imageOperations.resizeImageVips(fullSizedImage, Dimensions(800, 600), 95, outputFile, Png) + + whenReady(eventuallyResized) { resized => + arena.close() + resized.isFile should be(true) + } + } + + it("render LAB 16 bit colour spaces correctly") { + implicit val arena: Arena = Arena.ofShared + val imageOperations = new ImageOperations("") + + val fullSizedImage = VImage.newFromFile(arena, fileAt("halfdome_LAB16.tif").getAbsolutePath) + val outputFile = new File("/Users/tony/Desktop/out8.jpg") + + val eventuallyResized = imageOperations.resizeImageVips(fullSizedImage, Dimensions(800, 600), 95, outputFile, Jpeg) + + whenReady(eventuallyResized) { resized => + arena.close() + resized.isFile should be(true) + } + } + + it("render PNG with alpha correctly") { + implicit val arena: Arena = Arena.ofShared + val imageOperations = new ImageOperations("") + + val image = fileAt("with-alpha.png") + val fullSizedImage = VImage.newFromFile(arena, image.getAbsolutePath) + val outputFile = new File("/Users/tony/Desktop/resized-png-with-alpha.png") + + val eventuallyResized = imageOperations.resizeImageVips(fullSizedImage, Dimensions(800, 600), 95, outputFile, Png) + + whenReady(eventuallyResized) { resized => + arena.close() + resized.isFile should be(true) + } + } + + it("render LAB TIFF with alpha correctly") { + implicit val arena: Arena = Arena.ofShared + val imageOperations = new ImageOperations("") + + val image = fileAt("lab8-with-alpha.tif") + val fullSizedImage = VImage.newFromFile(arena, image.getAbsolutePath) + val outputFile = new File("/Users/tony/Desktop/out13.jpg") + + val eventuallyResized = imageOperations.resizeImageVips(fullSizedImage, Dimensions(800, 600), 95, outputFile, Jpeg) + + whenReady(eventuallyResized) { resized => + arena.close() + resized.isFile should be(true) + } + } + + it("render LAB16 TIFF with alpha correctly") { + implicit val arena: Arena = Arena.ofShared + val imageOperations = new ImageOperations("") + + val image = fileAt("lab16-with-alpha-unsigned-unassociated.tif") + val fullSizedImage = VImage.newFromFile(arena, image.getAbsolutePath) + val outputFile = new File("/Users/tony/Desktop/resized-lab16-tif-with-alpha.png") + + val eventualResized = imageOperations.resizeImageVips(fullSizedImage, Dimensions(1200, 800), 95, outputFile, Png) + whenReady(eventualResized) { resized => + arena.close() + resized.isFile should be(true) + } + } + } + + describe("alpha") { + it("should return false for RGB for a Jpeg with no alpha") { + implicit val arena: Arena = Arena.ofShared + val image = VImage.newFromFile(arena, fileAt("rgb-wo-profile.jpg").getAbsolutePath) + val hasAlpha = ImageOperations.hasAlpha(image) + arena.close() + hasAlpha should be(false) + } + + it("should return true for PNG with alpha") { + implicit val arena: Arena = Arena.ofShared + val image = VImage.newFromFile(arena, fileAt("with-alpha.png").getAbsolutePath) + val hasAlpha = ImageOperations.hasAlpha(image) + arena.close() + hasAlpha should be(true) + } + + it("should return true for TIFF with alpha") { + implicit val arena: Arena = Arena.ofShared + val image = VImage.newFromFile(arena, fileAt("with-alpha.tif").getAbsolutePath) + val hasAlpha = ImageOperations.hasAlpha(image) + arena.close() + hasAlpha should be(true) + } + + it("should return false for LAB8 TIFF with no alpha") { + implicit val arena: Arena = Arena.ofShared + val hasAlpha = ImageOperations.hasAlpha(VImage.newFromFile(arena, fileAt("halfdome_LAB.tif").getAbsolutePath)) + hasAlpha should be(false) + + val hasAlpha2 = ImageOperations.hasAlpha(VImage.newFromFile(arena, fileAt("TIFF 8bit noAlpha.tif").getAbsolutePath)) + arena.close() + hasAlpha2 should be(false) + } + + it("should return false for LAB 16 TIF with no alpha") { + implicit val arena: Arena = Arena.ofShared + val image = VImage.newFromFile(arena, fileAt("halfdome_LAB16.tif").getAbsolutePath) + val hasAlpha = ImageOperations.hasAlpha(image) + arena.close() + hasAlpha should be(false) + } + + it("should return true for LAB8 TIF with unassociated alpha") { + implicit val arena: Arena = Arena.ofShared + val image = VImage.newFromFile(arena, fileAt("TIFF 8bit unassociatedAlpha.tif").getAbsolutePath) + val hasAlpha = ImageOperations.hasAlpha(image) + arena.close() + hasAlpha should be(true) + } + + it("render LAB TIFF with alpha correctly") {} + implicit val arena: Arena = Arena.ofShared + val imageOperations = new ImageOperations("") + + val image = fileAt("lab8-with-alpha.tif") + val fullSizedImage = VImage.newFromFile(arena, image.getAbsolutePath) + val outputFile = new File("/Users/tony/Desktop/out13.jpg") + + val eventualResized = imageOperations.resizeImageVips(fullSizedImage, Dimensions(800, 600), 95, outputFile, Jpeg) + whenReady(eventualResized) { resized => + arena.close() + resized.isFile should be(true) + } + } + describe("identifyColourModel") { it("should return RGB for a JPG image with RGB image data and no embedded profile") { val image = fileAt("rgb-wo-profile.jpg") - val colourModelFuture = ImageOperations.identifyColourModel(image, Jpeg) + val colourModelFuture = ImageOperations.getImageInformation(image) whenReady(colourModelFuture) { colourModel => - colourModel should be (Some("RGB")) + colourModel._3 should be(Some("RGB")) } } it("should return RGB for a JPG image with RGB image data and an RGB embedded profile") { val image = fileAt("rgb-with-rgb-profile.jpg") - val colourModelFuture = ImageOperations.identifyColourModel(image, Jpeg) + val colourModelFuture = ImageOperations.getImageInformation(image) whenReady(colourModelFuture) { colourModel => - colourModel should be (Some("RGB")) + colourModel._3 should be(Some("RGB")) + } + } + + it("should return RGB for a PNG image with RGB image data and an embedded profile") { + val image = fileAt("cs-black-000.png") + val colourModelFuture = ImageOperations.getImageInformation(image) + whenReady(colourModelFuture) { colourModel => + colourModel._3 should be(Some("RGB")) } } it("should return RGB for a JPG image with RGB image data and an incorrect CMYK embedded profile") { val image = fileAt("rgb-with-cmyk-profile.jpg") - val colourModelFuture = ImageOperations.identifyColourModel(image, Jpeg) + val colourModelFuture = ImageOperations.getImageInformation(image) whenReady(colourModelFuture) { colourModel => - colourModel should be (Some("RGB")) + colourModel._3 should be(Some("RGB")) } } it("should return CMYK for a JPG image with CMYK image data") { val image = fileAt("cmyk.jpg") - val colourModelFuture = ImageOperations.identifyColourModel(image, Jpeg) + val colourModelFuture = ImageOperations.getImageInformation(image) whenReady(colourModelFuture) { colourModel => - colourModel should be (Some("CMYK")) + colourModel._3 should be(Some("CMYK")) } } it("should return Greyscale for a JPG image with greyscale image data and no embedded profile") { val image = fileAt("grayscale-wo-profile.jpg") - val colourModelFuture = ImageOperations.identifyColourModel(image, Jpeg) + val colourModelFuture = ImageOperations.getImageInformation(image) + whenReady(colourModelFuture) { colourModel => + colourModel._3 should be(Some("Greyscale")) + } + } + + it("should return RGB for a PNG image with 16 bit RGB image data") { + val image = fileAt("schaik.com_pngsuite/basi2c16.png") + val colourModelFuture = ImageOperations.getImageInformation(image) + whenReady(colourModelFuture) { colourModel => + colourModel._3 should be(Some("RGB")) + } + } + + it("should return LAB for a TIFF image with LAB16 image data") { + val image = fileAt("halfdome_LAB16.tif") + val colourModelFuture = ImageOperations.getImageInformation(image) + whenReady(colourModelFuture) { colourModel => + colourModel._3 should be(Some("LAB")) + } + } + + it("should return CMYK for a TIFF image with CMYK image data") { + val image = fileAt("CMYK-with-profile.jpg") + val colourModelFuture = ImageOperations.getImageInformation(image) whenReady(colourModelFuture) { colourModel => - colourModel should be (Some("Greyscale")) + colourModel._3 should be(Some("CMYK")) + } + } + } + + describe("dimensions") { + it("should return dimensions of horizontal image") { + val inputFile = fileAt("exif-orientated-no-rotation.jpg") + val dimsFuture = ImageOperations.getImageInformation(inputFile) + whenReady(dimsFuture) { dims => + dims._1.get shouldBe new Dimensions(3456, 2304) + } + } + + it("should return uncorrected dimensions for exif oriented images") { + val inputFile = fileAt("exif-orientated.jpg") + val dimsFuture = ImageOperations.getImageInformation(inputFile) + whenReady(dimsFuture) { dims => + dims._1.get shouldBe new Dimensions(3456, 2304) + } + } + + it("should read the correct dimensions for a tiff image") { + val inputFile = fileAt("flower.tif") + val dimsFuture = ImageOperations.getImageInformation(inputFile) + whenReady(dimsFuture) { dimOpt => + dimOpt._1 should be(Symbol("defined")) + dimOpt._1.get.width should be(73) + dimOpt._1.get.height should be(43) + } + } + + it("should read the correct dimensions for a png image") { + val inputFile = fileAt("schaik.com_pngsuite/basn0g08.png") + val dimsFuture = ImageOperations.getImageInformation(inputFile) + whenReady(dimsFuture) { dimOpt => + dimOpt._1 should be(Symbol("defined")) + dimOpt._1.get.width should be(32) + dimOpt._1.get.height should be(32) } } } - // TODO: test cropImage and its conversions + describe("orientation") { + it("should capture exif orientation tag from JPG images") { + val image = fileAt("exif-orientated.jpg") + val orientationFuture = ImageOperations.getImageInformation(image) + whenReady(orientationFuture) { orientationOpt => + orientationOpt._2 should be(defined) + orientationOpt._2.get.exifOrientation should be(Some(6)) + } + } + + it("should ignore 0 degree exif orientation tag as it has no material effect") { + val image = fileAt("exif-orientated-no-rotation.jpg") + val orientationFuture = ImageOperations.getImageInformation(image) + whenReady(orientationFuture) { orientationOpt => + orientationOpt._2 should be(None) + } + } + } + + describe("graphic detection") { + it("should return not graphic for true colour jpeg") { + val arena = Arena.ofConfined + val image = VImage.newFromFile(arena, fileAt("exif-orientated-no-rotation.jpg").getAbsolutePath) + ImageOperations.isGraphicVips(image)(arena) should be(false) + arena.close() + } + + it("should return is graphic for depth 2 tiff") { + val arena = Arena.ofConfined + val image = VImage.newFromFile(arena, fileAt("flower.tif").getAbsolutePath) + ImageOperations.isGraphicVips(image)(arena) should be(true) + arena.close() + } + + it("should return is graphic for depth 4 png with alpha") { + val arena = Arena.ofConfined + val image = VImage.newFromFile(arena, fileAt("schaik.com_pngsuite/tbbn0g04.png").getAbsolutePath) + ImageOperations.isGraphicVips(image)(arena) should be(true) + arena.close() + } + + it("should return is graphic for depth 8 indexed png") { + val arena = Arena.ofConfined + val image = VImage.newFromFile(arena, fileAt("schaik.com_pngsuite/basn3p08.png").getAbsolutePath) + ImageOperations.isGraphicVips(image)(arena) should be(true) + arena.close() + } + + } + + describe("cropping") { + val operations = new ImageOperations("") + + it("should create unscaled master crop to resize from full sized images") { + implicit val arena: Arena = Arena.ofConfined + //val fullsizedImage = fileAt("Lab 16bpc (7d0b7c7b8e890d7e5d369093aa437bd833e20f71).tiff") + val fullsizedImage = fileAt("IMG_4403.jpg") + val metadata = ImageMetadata() + + val masterCrop = operations.cropImageVips(fullsizedImage, Bounds(100, 100, 2000, 2400), metadata, None) + + val outputFile = new File("/Users/tony/Desktop/master.jpg") + operations.saveImageToFile(masterCrop, Jpeg, 95, outputFile, keep = Some(VipsRaw.VIPS_FOREIGN_KEEP_XMP)) + arena.close() + } + + it("should create unscaled master crop from CMYK full sized image") { + implicit val arena: Arena = Arena.ofConfined + val fullsizedImage = fileAt("CMYK-with-profile.jpg") + val metadata = ImageMetadata() + + val masterCrop = operations.cropImageVips(fullsizedImage, Bounds(100, 100, 2000, 2400), metadata, None) + + val outputFile = new File("/Users/tony/Desktop/master-from-cmyk.jpg") + operations.saveImageToFile(masterCrop, Jpeg, 95, outputFile, keep = Some(VipsRaw.VIPS_FOREIGN_KEEP_XMP)) + + arena.close() + } + + it("should create files foreach crop size") { + implicit val arena: Arena = Arena.ofShared() + val fullsizedImage = fileAt("CMYK-with-profile.jpg") + val metadata = ImageMetadata() + + val masterCrop = operations.cropImageVips(fullsizedImage, Bounds(100, 100, 3000, 2000), metadata, None) + val landscapeCropSizingWidths = Seq( + Dimensions(140, 100), + Dimensions(320, 200), + Dimensions(800, 600), + Dimensions(1000, 1200), + Dimensions(2000, 3000), + ) + implicit val i: Instance = Instance("id") + + val crops = operations.createCrops(masterCrop, landscapeCropSizingWidths.toList, "test-image-id", + Bounds(0, 0, 1000, 1200), + Jpeg, + new File("/Users/tony/tmp/crops"), + 75 + ) + + arena.close() + } + } def fileAt(resourcePath: String): File = { new File(getClass.getResource(s"/$resourcePath").toURI) diff --git a/common-lib/src/test/scala/com/gu/mediaservice/lib/json/JsonOrderingTest.scala b/common-lib/src/test/scala/com/gu/mediaservice/lib/json/JsonOrderingTest.scala index 3a0751bf33a..9bbed90d53f 100644 --- a/common-lib/src/test/scala/com/gu/mediaservice/lib/json/JsonOrderingTest.scala +++ b/common-lib/src/test/scala/com/gu/mediaservice/lib/json/JsonOrderingTest.scala @@ -26,7 +26,7 @@ class JsonOrderingTest extends AnyFreeSpec with Matchers { uploadedBy = "Biden", softDeletedMetadata = None, lastModified = None, - uploadInfo = UploadInfo(None), + uploadInfo = UploadInfo(None, None), source = Asset(new URI("fileUri"), None, None, None), optimisedPng = None, originalUsageRights = Handout(None), diff --git a/common-lib/src/test/scala/com/gu/mediaservice/lib/metadata/ImageMetadataConverterTest.scala b/common-lib/src/test/scala/com/gu/mediaservice/lib/metadata/ImageMetadataConverterTest.scala index 792f38879ca..8f0e6d2142a 100644 --- a/common-lib/src/test/scala/com/gu/mediaservice/lib/metadata/ImageMetadataConverterTest.scala +++ b/common-lib/src/test/scala/com/gu/mediaservice/lib/metadata/ImageMetadataConverterTest.scala @@ -340,6 +340,7 @@ class ImageMetadataConverterTest extends AnyFunSpec with Matchers { it("should clean up 'just date' dates into iso format") { ImageMetadataConverter.cleanDate("2014-12-16") shouldBe "2014-12-16T00:00:00.000Z" + ImageMetadataConverter.cleanDate("2014-08-20") shouldBe "2014-08-20T00:00:00.000Z" } it("should clean up iso dates with seconds into iso format") { diff --git a/common-lib/src/test/scala/com/gu/mediaservice/model/ExternalThrallMessageTest.scala b/common-lib/src/test/scala/com/gu/mediaservice/model/ExternalThrallMessageTest.scala index 44a8d4f169e..ee1ad991dd8 100644 --- a/common-lib/src/test/scala/com/gu/mediaservice/model/ExternalThrallMessageTest.scala +++ b/common-lib/src/test/scala/com/gu/mediaservice/model/ExternalThrallMessageTest.scala @@ -24,59 +24,59 @@ class ExternalThrallMessageTest extends AnyFreeSpec with Matchers with TableDriv "Make some JSON" - { "from an imageMessage" in { val image = ImageTest.createImage("hello") - val message = ImageMessage(nowUtc, image.copy(uploadTime = nowUtc)) + val message = ImageMessage(nowUtc, image.copy(uploadTime = nowUtc), instance = Instance("an-instance")) //Manually set the image time, because the time zone data is lost in //conversion roundTrip(message) } "from a DeleteImageMessage" in { - val dim = DeleteImageMessage("hey", nowUtc) + val dim = DeleteImageMessage("hey", nowUtc, instance = Instance("an-instance")) roundTrip(dim) } "from a DeleteImageExportsMessage" in { - val diem = DeleteImageExportsMessage("carpe", nowUtc) + val diem = DeleteImageExportsMessage("carpe", nowUtc, instance = Instance("an-instance")) roundTrip(diem) } "from a UpdateImageExportsMessage" in { - val uiem = UpdateImageExportsMessage("id", nowUtc, Seq()) + val uiem = UpdateImageExportsMessage("id", nowUtc, Seq(), instance = Instance("an-instance")) roundTrip(uiem) } "from a UpdateImageUserMetadataMessage" in { - val msg = UpdateImageUserMetadataMessage("hello", nowUtc, Edits(metadata = ImageMetadata())) + val msg = UpdateImageUserMetadataMessage("hello", nowUtc, Edits(metadata = ImageMetadata()), instance = Instance("an-instance")) roundTrip(msg) } "from a UpdateImageUsagesMessage" in { - val msg = UpdateImageUsagesMessage("hello", nowUtc, UsageNotice("hello", JsArray())) + val msg = UpdateImageUsagesMessage("hello", nowUtc, UsageNotice("hello", JsArray(), instance = Instance("an-instance")), instance = Instance("an-instance")) roundTrip(msg) } "from a ReplaceImageLeasesMessage" in { - val msg = ReplaceImageLeasesMessage("hello", nowUtc, Seq()) + val msg = ReplaceImageLeasesMessage("hello", nowUtc, Seq(), instance = Instance("an-instance")) roundTrip(msg) } "from a AddImageLeaseMessage" in { - val msg = AddImageLeaseMessage("hello", nowUtc, MediaLease(None, None, notes = None, mediaId = "")) + val msg = AddImageLeaseMessage("hello", nowUtc, MediaLease(None, None, notes = None, mediaId = ""), instance = Instance("an-instance")) roundTrip(msg) } "from a RemoveImageLeaseMessage" in { - val msg = RemoveImageLeaseMessage("hello", nowUtc, "bye") + val msg = RemoveImageLeaseMessage("hello", nowUtc, "bye", instance = Instance("an-instance")) roundTrip(msg) } "from a SetImageCollectionsMessage" in { - val msg = SetImageCollectionsMessage("hello", nowUtc, Seq()) + val msg = SetImageCollectionsMessage("hello", nowUtc, Seq(), instance = Instance("an-instance")) roundTrip(msg) } "from a DeleteUsagesMessage" in { - val msg = DeleteUsagesMessage("hello", nowUtc) + val msg = DeleteUsagesMessage("hello", nowUtc, instance = Instance("an-instance")) roundTrip(msg) } "from a UpdateImageSyndicationMetadataMessage" in { - val msg = UpdateImageSyndicationMetadataMessage("hello", nowUtc, None) + val msg = UpdateImageSyndicationMetadataMessage("hello", nowUtc, None, instance = Instance("an-instance")) roundTrip(msg) } "from a UpdateImagePhotoshootMetadataMessage" in { - val msg = UpdateImagePhotoshootMetadataMessage("hello", nowUtc, Edits(metadata = ImageMetadata())) + val msg = UpdateImagePhotoshootMetadataMessage("hello", nowUtc, Edits(metadata = ImageMetadata()), instance = Instance("an-instance")) roundTrip(msg) } } diff --git a/common-lib/src/test/scala/com/gu/mediaservice/model/ImageTest.scala b/common-lib/src/test/scala/com/gu/mediaservice/model/ImageTest.scala index 487be7ee6df..e3734ac5772 100644 --- a/common-lib/src/test/scala/com/gu/mediaservice/model/ImageTest.scala +++ b/common-lib/src/test/scala/com/gu/mediaservice/model/ImageTest.scala @@ -14,7 +14,9 @@ class ImageTest extends AnyFunSpec with Matchers { describe("Image syndication status") { it("should be UnsuitableForSyndication by default") { - val image = createImage() + val image = createImage( + usageRights = NoRights + ) image.usages.length shouldBe 0 image.syndicationRights shouldBe None @@ -23,23 +25,21 @@ class ImageTest extends AnyFunSpec with Matchers { image.syndicationStatus shouldBe UnsuitableForSyndication } - it("should be AwaitingReviewForSyndication if syndication rights are acquired") { - val image = createImage( - syndicationRights = Some(rightsAcquired) - ) + it("should be AwaitingReviewForSyndication if owned") { + val image = createImage() image.syndicationStatus shouldBe AwaitingReviewForSyndication } - it("should be UnsuitableForSyndication if syndication rights are not acquired") { + it("should be UnsuitableForSyndication if not owned") { val image = createImage( - syndicationRights = Some(noRightsAcquired) + usageRights = Agency("An agency") ) image.syndicationStatus shouldBe UnsuitableForSyndication } - it("should be UnsuitableForSyndication if there is no syndication rights") { + it("should be UnsuitableForSyndication if not owned even if has syndication usages") { val imageId = UUID.randomUUID().toString val usages = List( @@ -60,7 +60,8 @@ class ImageTest extends AnyFunSpec with Matchers { val image = createImage( id = imageId, usages = usages, - leases = Some(leaseByMedia) + leases = Some(leaseByMedia), + usageRights = NoRights ) image.syndicationStatus shouldBe UnsuitableForSyndication @@ -135,7 +136,7 @@ class ImageTest extends AnyFunSpec with Matchers { } object ImageTest { - def createImage(id: String = UUID.randomUUID().toString, usages: List[Usage] = List(), leases: Option[LeasesByMedia] = None, syndicationRights: Option[SyndicationRights] = None): Image = { + def createImage(id: String = UUID.randomUUID().toString, usages: List[Usage] = List(), leases: Option[LeasesByMedia] = None, syndicationRights: Option[SyndicationRights] = None, usageRights: UsageRights = StaffPhotographer("T. Hanks", "The Guardian")): Image = { Image( id = id, uploadTime = DateTime.now(), @@ -143,7 +144,7 @@ object ImageTest { softDeletedMetadata = None, lastModified = None, identifiers = Map.empty, - uploadInfo = UploadInfo(filename = Some(s"test_$id.jpeg")), + uploadInfo = UploadInfo(filename = Some(s"test_$id.jpeg"), isFeedUpload = Some(true)), source = Asset( file = new URI(s"https://file/$id"), size = Some(1L), @@ -157,8 +158,8 @@ object ImageTest { userMetadata = None, metadata = ImageMetadata(dateTaken = None, title = Some(s"Test image $id"), keywords = None), originalMetadata = ImageMetadata(), - usageRights = StaffPhotographer("T. Hanks", "The Guardian"), - originalUsageRights = StaffPhotographer("T. Hanks", "The Guardian"), + usageRights = usageRights, + originalUsageRights = usageRights, exports = Nil, syndicationRights = syndicationRights, diff --git a/container-images/jdk-vips/Dockerfile b/container-images/jdk-vips/Dockerfile new file mode 100644 index 00000000000..c5dde5a3f49 --- /dev/null +++ b/container-images/jdk-vips/Dockerfile @@ -0,0 +1,44 @@ +FROM eclipse-temurin:25-noble +RUN apt-get update +RUN apt-get -y --no-install-suggests install -f build-essential ninja-build python3-pip bc wget +RUN apt-get -y --no-install-suggests install meson +RUN apt -y --no-install-suggests install \ + libfftw3-dev \ + libopenexr-dev \ + libgsf-1-dev \ + libglib2.0-dev \ + liborc-dev \ + libopenslide-dev \ + libmatio-dev \ + libwebp-dev \ + libjpeg-turbo8-dev \ + libexpat1-dev \ + libexif-dev \ + libtiff5-dev \ + libcfitsio-dev \ + libpoppler-glib-dev \ + librsvg2-dev \ + libpango1.0-dev \ + libopenjp2-7-dev \ + liblcms2-dev \ + libimagequant-dev \ + libheif-dev + +WORKDIR /tmp +RUN wget https://github.com/libvips/libvips/releases/download/v8.18.2/vips-8.18.2.tar.xz +RUN tar xf vips-8.18.2.tar.xz +WORKDIR /tmp/vips-8.18.2 +RUN meson setup build +WORKDIR /tmp/vips-8.18.2/build +RUN meson compile +RUN meson test +RUN meson install +RUN ldconfig + +RUN rm /tmp/vips-8.18.2.tar.xz +RUN rm -r /tmp/vips-8.18.2/ + +RUN apt -y --no-install-suggests install \ + libjemalloc-dev \ + graphicsmagick \ + graphicsmagick-imagemagick-compat diff --git a/container-images/jdk-vips/cloudbuild.yaml b/container-images/jdk-vips/cloudbuild.yaml new file mode 100644 index 00000000000..18bd224954e --- /dev/null +++ b/container-images/jdk-vips/cloudbuild.yaml @@ -0,0 +1,6 @@ +steps: + - name: 'gcr.io/cloud-builders/docker' + args: ['build', '-t', 'eu.gcr.io/$PROJECT_ID/jdk-vips:25-8.18.2', '.'] + - name: 'gcr.io/cloud-builders/docker' + args: ['push', 'eu.gcr.io/$PROJECT_ID/jdk-vips:25-8.18.2'] + diff --git a/cropper/app/CropperComponents.scala b/cropper/app/CropperComponents.scala index c51f554d364..0bd730b3606 100644 --- a/cropper/app/CropperComponents.scala +++ b/cropper/app/CropperComponents.scala @@ -1,5 +1,8 @@ +import app.photofox.vipsffm.{Vips, VipsHelper} +import com.gu.mediaservice.GridClient +import com.gu.mediaservice.lib.aws.S3 import com.gu.mediaservice.lib.imaging.ImageOperations -import com.gu.mediaservice.lib.management.{InnerServiceStatusCheckController, Management} +import com.gu.mediaservice.lib.management.Management import com.gu.mediaservice.lib.play.GridComponents import controllers.CropperController import lib.{CropStore, CropperConfig, Crops, Notifications} @@ -10,15 +13,21 @@ class CropperComponents(context: Context) extends GridComponents(context, new Cr final override val buildInfo = utils.buildinfo.BuildInfo val store = new CropStore(config) - val imageOperations = new ImageOperations(context.environment.rootPath.getAbsolutePath) + val imageOperations = { + Vips.init() + VipsHelper.cache_set_max(0) + new ImageOperations(context.environment.rootPath.getAbsolutePath) + } + val s3 = new S3(config) - val crops = new Crops(config, store, imageOperations) + val crops = new Crops(config, store, imageOperations, config.imageBucket, s3) val notifications = new Notifications(config) - val controller = new CropperController(auth, crops, store, notifications, config, controllerComponents, wsClient, authorisation) + private val gridClient = GridClient(config.services)(wsClient) + + val controller = new CropperController(auth, crops, store, notifications, config, controllerComponents, authorisation, gridClient) val permissionsAwareManagement = new Management(controllerComponents, buildInfo) - val InnerServiceStatusCheckController = new InnerServiceStatusCheckController(auth, controllerComponents, config.services, wsClient) - override lazy val router = new Routes(httpErrorHandler, controller, permissionsAwareManagement, InnerServiceStatusCheckController) + override lazy val router = new Routes(httpErrorHandler, controller, permissionsAwareManagement) } diff --git a/cropper/app/controllers/CropperController.scala b/cropper/app/controllers/CropperController.scala index dc2f05aff7b..8b52017bfe5 100644 --- a/cropper/app/controllers/CropperController.scala +++ b/cropper/app/controllers/CropperController.scala @@ -2,12 +2,14 @@ package controllers import _root_.play.api.libs.json._ import _root_.play.api.mvc.{BaseController, ControllerComponents} +import com.gu.mediaservice.GridClient import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.argo.model.Link import com.gu.mediaservice.lib.auth.Authentication.Principal import com.gu.mediaservice.lib.auth.Permissions.{DeleteCropsOrUsages, PrincipalFilter} import com.gu.mediaservice.lib.auth._ import com.gu.mediaservice.lib.aws.UpdateMessage +import com.gu.mediaservice.lib.config.InstanceForRequest import com.gu.mediaservice.lib.imaging.ExportResult import com.gu.mediaservice.lib.logging.{LogMarker, MarkerMap} import com.gu.mediaservice.lib.play.RequestLoggingFilter @@ -16,7 +18,6 @@ import com.gu.mediaservice.syntax.MessageSubjects import lib._ import model._ import org.joda.time.DateTime -import play.api.libs.ws.WSClient import java.net.URI import scala.concurrent.{ExecutionContext, Future} @@ -30,23 +31,26 @@ case object ApiRequestFailed extends Exception("Failed to fetch the source") class CropperController(auth: Authentication, crops: Crops, store: CropStore, notifications: Notifications, config: CropperConfig, override val controllerComponents: ControllerComponents, - ws: WSClient, authorisation: Authorisation)(implicit val ec: ExecutionContext) - extends BaseController with MessageSubjects with ArgoHelpers { + authorisation: Authorisation, + gridClient: GridClient)(implicit val ec: ExecutionContext) + extends BaseController with MessageSubjects with ArgoHelpers with MediaApiUrls with InstanceForRequest { // Stupid name clash between Argo and Play import com.gu.mediaservice.lib.argo.model.{Action => ArgoAction} val AuthenticatedAndAuthorisedToDeleteCrops = auth andThen authorisation.CommonActionFilters.authorisedForDeleteCropsOrUsages - val indexResponse = { + private def indexResponse(instance: Instance) = { val indexData = Map("description" -> "This is the Cropper Service") val indexLinks = List( - Link("crop", s"${config.rootUri}/crops") + Link("crop", s"${config.rootUri(instance)}/crops") ) respond(indexData, indexLinks) } - def index = auth { indexResponse } + def index = auth { request => + indexResponse(instanceOf(request)) + } def addExport = auth.async(parse.json) { httpRequest => httpRequest.body.validate[ExportRequest] map { exportRequest => @@ -58,10 +62,10 @@ class CropperController(auth: Authentication, crops: Crops, store: CropStore, no val user = httpRequest.user val onBehalfOfPrincipal = auth.getOnBehalfOfPrincipal(user) - executeRequest(exportRequest, user, onBehalfOfPrincipal).map { case (imageId, export) => + executeRequest(exportRequest, user, onBehalfOfPrincipal, httpRequest).map { case (imageId, export) => val cropJson = Json.toJson(export).as[JsObject] - val updateMessage = UpdateMessage(subject = UpdateImageExports, id = Some(imageId), crops = Some(Seq(export))) + val updateMessage = UpdateMessage(subject = UpdateImageExports, id = Some(imageId), crops = Some(Seq(export)), instance = instanceOf(httpRequest)) notifications.publish(updateMessage) Ok(cropJson).as(ArgoMediaType) @@ -76,9 +80,6 @@ class CropperController(auth: Authentication, crops: Crops, store: CropStore, no case InvalidImage => logger.error(logMarker, InvalidImage.getMessage) respondError(BadRequest, "invalid-image", InvalidImage.getMessage) - case MissingSecureSourceUrl => - logger.error(logMarker, MissingSecureSourceUrl.getMessage) - respondError(BadRequest, "no-source-image", MissingSecureSourceUrl.getMessage) case InvalidCropRequest => logger.error(logMarker, InvalidCropRequest.getMessage) respondError(BadRequest, "invalid-crop", InvalidCropRequest.getMessage) @@ -102,10 +103,12 @@ class CropperController(auth: Authentication, crops: Crops, store: CropStore, no private val canDeleteCrops: PrincipalFilter = authorisation.hasPermissionTo(DeleteCropsOrUsages) - private def downloadExportLink(imageId: String, exportId: String, width: Int) = Link(s"crop-download-$exportId-$width", s"${config.apiUri}/images/$imageId/export/$exportId/asset/$width/download") + private def downloadExportMasterLink(imageId: String, exportId: String)(implicit instance: Instance) = Link(s"crop-download-$exportId-master", s"${config.apiUri(instance)}/images/$imageId/export/$exportId/master/download") - def getCrops(id: String) = auth.async { httpRequest => + private def downloadExportLink(imageId: String, exportId: String, width: Int)(implicit instance: Instance) = Link(s"crop-download-$exportId-$width", s"${config.apiUri(instance)}/images/$imageId/export/$exportId/asset/$width/download") + def getCrops(id: String) = auth.async { httpRequest => + implicit val instance: Instance = instanceOf(httpRequest) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "getCrops", "requestId" -> RequestLoggingFilter.getRequestId(httpRequest), @@ -114,24 +117,31 @@ class CropperController(auth: Authentication, crops: Crops, store: CropStore, no logger.info(logMarker, s"getting crops for $id") - store.listCrops(id) map (_.toList) map { crops => + store.listCrops(id, instance) map (_.toList) map { crops => val deleteCropsAction = - ArgoAction("delete-crops", URI.create(s"${config.rootUri}/crops/$id"), "DELETE") - - lazy val cropDownloadLinks = for { - crop <- crops - asset <- crop.assets - dimensions <- asset.dimensions - width = dimensions.width - cropId <- crop.id - } yield downloadExportLink(id, cropId, width) + ArgoAction("delete-crops", URI.create(s"${config.rootUri(instance)}/crops/$id"), "DELETE") + + lazy val cropDownloadAssetsLinks = + for { + crop <- crops + asset <- crop.assets + dimensions <- asset.dimensions + width = dimensions.width + cropId <- crop.id + } yield downloadExportLink(id, cropId, width) + + lazy val cropDownloadMasterLinks = + for { + crop <- crops + cropId <- crop.id + } yield downloadExportMasterLink(id, cropId) val links = (for { crop <- crops.headOption link = Link("image", crop.specification.uri) } yield { if (config.canDownloadCrop) { - link :: cropDownloadLinks + link :: cropDownloadAssetsLinks ++ cropDownloadMasterLinks } else List(link) }) getOrElse List() @@ -144,13 +154,14 @@ class CropperController(auth: Authentication, crops: Crops, store: CropStore, no } def deleteCrops(id: String) = AuthenticatedAndAuthorisedToDeleteCrops.async { httpRequest => + implicit val instance: Instance = instanceOf(httpRequest) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "deleteCrops", "requestId" -> RequestLoggingFilter.getRequestId(httpRequest), "imageId" -> id ) store.deleteCrops(id).map { _ => - val updateMessage = UpdateMessage(subject = DeleteImageExports, id = Some(id)) + val updateMessage = UpdateMessage(subject = DeleteImageExports, id = Some(id), instance = instance) notifications.publish(updateMessage) Accepted } recover { @@ -159,11 +170,12 @@ class CropperController(auth: Authentication, crops: Crops, store: CropStore, no } def executeRequest( - exportRequest: ExportRequest, user: Principal, onBehalfOfPrincipal: Authentication.OnBehalfOfPrincipal + exportRequest: ExportRequest, user: Principal, onBehalfOfPrincipal: Authentication.OnBehalfOfPrincipal, + request: Authentication.Request[JsValue] )(implicit logMarker: LogMarker): Future[(String, Crop)] = { - + implicit val instance: Instance = instanceOf(request) for { - _ <- verify(isMediaApiUri(exportRequest.uri), InvalidSource) + _ <- verify(isMediaApiImageUri(exportRequest.uri, config.apiUri(instance)), InvalidSource) apiImage <- fetchSourceFromApi(exportRequest.uri, onBehalfOfPrincipal) _ <- verify(apiImage.valid, InvalidImage) // Image should always have dimensions, but we want to safely extract the Option @@ -178,44 +190,13 @@ class CropperController(auth: Authentication, crops: Crops, store: CropStore, no specification = cropSpec ) markersWithCropDetails = logMarker ++ Map("imageId" -> apiImage.id, "cropId" -> Crop.getCropId(cropSpec.bounds)) - ExportResult(id, masterSizing, sizings) <- crops.makeExport(apiImage, crop)(markersWithCropDetails) + ExportResult(id, masterSizing, sizings) <- crops.makeExport(apiImage, crop)(markersWithCropDetails, instance) finalCrop = Crop.createFromCrop(crop, masterSizing, sizings) } yield (id, finalCrop) } - // TODO: lame, parse into URI object and compare host instead - def isMediaApiUri(uri: String): Boolean = uri.startsWith(config.apiUri) - - def fetchSourceFromApi(uri: String, onBehalfOfPrincipal: Authentication.OnBehalfOfPrincipal): Future[SourceImage] = { - - case class HttpClientResponse(status: Int, statusText: String, json: JsValue) - - val baseRequest = ws.url(uri) - .withQueryStringParameters("include" -> "fileMetadata") - - val request = onBehalfOfPrincipal(baseRequest) - - val responseFuture = request.get().map { r => - HttpClientResponse(r.status, r.statusText, Json.parse(r.body)) - } - - responseFuture recoverWith { - case NonFatal(e) => - logger.warn(s"HTTP request to fetch source failed: $e") - Future.failed(ApiRequestFailed) - } - - for (resp <- responseFuture) - yield { - if (resp.status == 404) { - throw ImageNotFound - } else if (resp.status != 200) { - logger.warn(s"HTTP status ${resp.status} ${resp.statusText} from $uri") - throw ApiRequestFailed - } else { - resp.json.as[SourceImage] - } - } + private def fetchSourceFromApi(uri: String, onBehalfOfPrincipal: Authentication.OnBehalfOfPrincipal)(implicit instance: Instance): Future[SourceImage] = { + gridClient.getSourceImage(imageIdFrom(uri), onBehalfOfPrincipal) } def verify(cond: => Boolean, error: Throwable): Future[Unit] = diff --git a/cropper/app/controllers/MediaApiUrls.scala b/cropper/app/controllers/MediaApiUrls.scala new file mode 100644 index 00000000000..5ccb8f4161e --- /dev/null +++ b/cropper/app/controllers/MediaApiUrls.scala @@ -0,0 +1,16 @@ +package controllers + +trait MediaApiUrls { + + def isMediaApiImageUri(uri: String, apiUri: String): Boolean = { + val hasMediaApiPrefix = uri.startsWith(apiUri) + val suffix = uri.drop(apiUri.length) + val suffixComponents = suffix.split("/") + val hasImageSuffix = suffixComponents.length == 3 && suffixComponents(1) == "images" + hasMediaApiPrefix && hasImageSuffix + } + + def imageIdFrom(uri: String): String = { + uri.split("/").last + } +} diff --git a/cropper/app/lib/AspectRatio.scala b/cropper/app/lib/AspectRatio.scala index b093f1b5875..28161afe301 100644 --- a/cropper/app/lib/AspectRatio.scala +++ b/cropper/app/lib/AspectRatio.scala @@ -9,7 +9,8 @@ object AspectRatio { Ratio("5:3", 5, 3), Ratio("2:3", 2, 3), Ratio("16:9", 16, 9), - Ratio("1:1", 1, 1) + Ratio("1:1", 1, 1), + Ratio("3:2", 3, 2) ) def clean(aspect: String): Option[Float] = knownRatios diff --git a/cropper/app/lib/CropStore.scala b/cropper/app/lib/CropStore.scala index a81b141793c..034790420f6 100644 --- a/cropper/app/lib/CropStore.scala +++ b/cropper/app/lib/CropStore.scala @@ -26,8 +26,8 @@ class CropStore(config: CropperConfig) extends S3ImageStorage(config) with CropS } } - def listCrops(id: String): Future[List[Crop]] = { - list(config.imgPublishingBucket, id).map { crops => + def listCrops(id: String, instance: Instance): Future[List[Crop]] = { + list(config.imgPublishingBucket, folderForImagesCrops(id, instance)).map { crops => // TODO crops layout want to be pull up crops.foldLeft(Map[String, Crop]()) { case (map, (s3Object)) => { val filename::containingFolder::_ = s3Object.uri.getPath.split("/").reverse.toList @@ -49,9 +49,11 @@ class CropStore(config: CropperConfig) extends S3ImageStorage(config) with CropS date = userMetadata.get("date").flatMap(parseDateTime) dimensions = Dimensions(width, height) + key = config.imgPublishingBucket.keyFromS3URL(s3Object.uri) + sizing = Asset( - translateImgHost(s3Object.uri), + signedCropAssetUrl(key), Some(s3Object.size), objectMetadata.contentType, Some(dimensions), @@ -70,11 +72,20 @@ class CropStore(config: CropperConfig) extends S3ImageStorage(config) with CropS } } - def deleteCrops(id: String)(implicit logMarker: LogMarker) = { - deleteFolder(config.imgPublishingBucket, id) + def deleteCrops(id: String)(implicit logMarker: LogMarker, instance: Instance) = { + deleteFolder(config.imgPublishingBucket, folderForImagesCrops(id, instance)) } // FIXME: this doesn't really belong here def translateImgHost(uri: URI): URI = new URI("https", config.imgPublishingHost, uri.getPath, uri.getFragment) + + private def folderForImagesCrops(id: String, instance: Instance) = { + instance.id + "/" + id + } + + private def signedCropAssetUrl(key: String): URI = { + signUrlTony(config.imgPublishingBucket, key).toURI + } + } diff --git a/cropper/app/lib/CropperConfig.scala b/cropper/app/lib/CropperConfig.scala index 25f78a5dacc..5e082585bbb 100644 --- a/cropper/app/lib/CropperConfig.scala +++ b/cropper/app/lib/CropperConfig.scala @@ -1,21 +1,28 @@ package lib +import com.gu.mediaservice.lib.aws.{S3, S3Bucket} import com.gu.mediaservice.lib.config.{CommonConfig, GridConfigResources} +import com.gu.mediaservice.model.Instance import java.io.File class CropperConfig(resources: GridConfigResources) extends CommonConfig(resources) { - val imgPublishingBucket = string("publishing.image.bucket") - + // TODO this is common with media-api download exports + val imgPublishingBucket: S3Bucket = S3Bucket( + string("publishing.image.bucket.name"), + string("publishing.image.bucket.endpoint"), + boolean("publishing.image.bucket.pathStyleURLs"), + clientFor(string("publishing.image.bucket.endpoint")) + ) val canDownloadCrop: Boolean = boolean("canDownloadCrop") val imgPublishingHost = string("publishing.image.host") // Note: work around CloudFormation not allowing optional parameters val imgPublishingSecureHost = stringOpt("publishing.image.secure.host").filterNot(_.isEmpty) - val rootUri = services.cropperBaseUri - val apiUri = services.apiBaseUri + val rootUri: Instance => String = services.cropperBaseUri + val apiUri: Instance => String = services.apiBaseUri val tempDir: File = new File(stringDefault("crop.output.tmp.dir", "/tmp")) diff --git a/cropper/app/lib/Crops.scala b/cropper/app/lib/Crops.scala index a38771aa000..631d2ac1727 100644 --- a/cropper/app/lib/Crops.scala +++ b/cropper/app/lib/Crops.scala @@ -1,95 +1,66 @@ package lib -import java.io.File -import com.gu.mediaservice.lib.metadata.FileMetadataHelper +import app.photofox.vipsffm.VImage +import app.photofox.vipsffm.jextract.VipsRaw import com.gu.mediaservice.lib.Files +import com.gu.mediaservice.lib.aws.{S3, S3Bucket} import com.gu.mediaservice.lib.imaging.{ExportResult, ImageOperations} import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker, Stopwatch} import com.gu.mediaservice.model._ +import java.io.File +import java.lang.foreign.Arena import scala.concurrent.{ExecutionContext, Future} import scala.util.Try case object InvalidImage extends Exception("Invalid image cannot be cropped") case object MissingMimeType extends Exception("Missing mimeType from source API") -case object MissingSecureSourceUrl extends Exception("Missing secureUrl from source API") case object InvalidCropRequest extends Exception("Crop request invalid for image dimensions") -case class MasterCrop(sizing: Future[Asset], file: File, dimensions: Dimensions, aspectRatio: Float) +case class MasterCrop(image: VImage, dimensions: Dimensions, aspectRatio: Float) -class Crops(config: CropperConfig, store: CropStore, imageOperations: ImageOperations)(implicit ec: ExecutionContext) extends GridLogging { +class Crops(config: CropperConfig, store: CropStore, imageOperations: ImageOperations, imageBucket: S3Bucket, s3: S3)(implicit ec: ExecutionContext) extends GridLogging { import Files._ - private val cropQuality = 75d - private val masterCropQuality = 95d + private val cropQuality = 75 + private val jpegMasterCropQuality = 95 // For PNGs, Magick considers "quality" parameter as effort spent on compression - 1 meaning none, 100 meaning max. // We don't overly care about output crop file sizes here, but prefer a fast output, so turn it right down. - private val pngCropQuality = 1d + private val pngMasterCropQuality = 1 // No effort spend compressing the PNG master - def outputFilename(source: SourceImage, bounds: Bounds, outputWidth: Int, fileType: MimeType, isMaster: Boolean = false): String = { + def outputFilename(imageId: String, bounds: Bounds, outputWidth: Int, fileType: MimeType, isMaster: Boolean = false)(implicit instance: Instance): String = { val masterString: String = if (isMaster) "master/" else "" - s"${source.id}/${Crop.getCropId(bounds)}/$masterString$outputWidth${fileType.fileExtension}" + instance.id + "/" + s"$imageId/${Crop.getCropId(bounds)}/$masterString$outputWidth${fileType.fileExtension}" } - def createMasterCrop( + private def createMasterCrop( apiImage: SourceImage, sourceFile: File, crop: Crop, - mediaType: MimeType, - colourModel: Option[String], - orientationMetadata: Option[OrientationMetadata], - )(implicit logMarker: LogMarker): Future[MasterCrop] = { + metadata: ImageMetadata, + orientationMetadata: Option[OrientationMetadata] + )(implicit logMarker: LogMarker, arena: Arena): MasterCrop = { - Stopwatch.async(s"creating master crop for ${apiImage.id}") { + Stopwatch(s"creating master crop for ${apiImage.id}") { val source = crop.specification - val metadata = apiImage.metadata - val iccColourSpace = FileMetadataHelper.normalisedIccColourSpace(apiImage.fileMetadata) - // pngs are always lossless, so quality only means effort spent compressing them. We don't - // care too much about filesize of master crops, so skip expensive compression to get faster cropping - val quality = if (mediaType == Png) pngCropQuality else masterCropQuality - - for { - strip <- imageOperations.cropImage( - sourceFile, apiImage.source.mimeType, source.bounds, quality, config.tempDir, - iccColourSpace, colourModel, mediaType, isTransformedFromSource = false, - orientationMetadata = orientationMetadata - ) - file: File <- imageOperations.appendMetadata(strip, metadata) - dimensions = Dimensions(source.bounds.width, source.bounds.height) - filename = outputFilename(apiImage, source.bounds, dimensions.width, mediaType, isMaster = true) - sizing = store.storeCropSizing(file, filename, mediaType, crop, dimensions) - dirtyAspect = source.bounds.width.toFloat / source.bounds.height - aspect = crop.specification.aspectRatio.flatMap(AspectRatio.clean).getOrElse(dirtyAspect) - } - yield MasterCrop(sizing, file, dimensions, aspect) - } - } - - def createCrops(sourceFile: File, dimensionList: List[Dimensions], apiImage: SourceImage, crop: Crop, cropType: MimeType)(implicit logMarker: LogMarker): Future[List[Asset]] = { - val quality = if (cropType == Png) pngCropQuality else cropQuality - - Stopwatch.async(s"creating crops for ${apiImage.id}") { - Future.sequence(dimensionList.map { dimensions => - val cropLogMarker = logMarker ++ Map("crop-dimensions" -> s"${dimensions.width}x${dimensions.height}") - for { - file <- imageOperations.resizeImage(sourceFile, - apiImage.source.mimeType, - dimensions, - quality, - config.tempDir, - cropType)(cropLogMarker) - optimisedFile = imageOperations.optimiseImage(file, cropType)(cropLogMarker) - filename = outputFilename(apiImage, crop.specification.bounds, dimensions.width, cropType) - sizing <- store.storeCropSizing(optimisedFile, filename, cropType, crop, dimensions)(cropLogMarker) - _ <- delete(file) - _ <- delete(optimisedFile) - } - yield sizing - }) + logger.info(logMarker, s"creating master crop for ${apiImage.id}") + val masterImage = imageOperations.cropImageVips( + sourceFile, + source.bounds, + metadata, + orientationMetadata = orientationMetadata + ) + + //file: File <- imageOperations.appendMetadata(strip, metadata) + val dimensions = Dimensions(source.bounds.width, source.bounds.height) + val dirtyAspect = source.bounds.width.toFloat / source.bounds.height + val aspect = crop.specification.aspectRatio.flatMap(AspectRatio.clean).getOrElse(dirtyAspect) + + MasterCrop(masterImage, dimensions, aspect) } } - def deleteCrops(id: String)(implicit logMarker: LogMarker): Future[Unit] = store.deleteCrops(id) + def deleteCrops(id: String)(implicit logMarker: LogMarker, instance: Instance): Future[Unit] = store.deleteCrops(id) private def dimensionsFromConfig(bounds: Bounds, aspectRatio: Float): List[Dimensions] = if (bounds.isPortrait) config.portraitCropSizingHeights.filter(_ <= bounds.height).map(h => Dimensions(math.round(h * aspectRatio), h)) @@ -106,45 +77,99 @@ class Crops(config: CropperConfig, store: CropStore, imageOperations: ImageOpera positiveCoords && strictlyPositiveSize && withinBounds } - def makeExport(apiImage: SourceImage, crop: Crop)(implicit logMarker: LogMarker): Future[ExportResult] = { - val source = crop.specification + def makeExport(apiImage: SourceImage, crop: Crop)(implicit logMarker: LogMarker, instance: Instance): Future[ExportResult] = { + val source = crop.specification val mimeType = apiImage.source.mimeType.getOrElse(throw MissingMimeType) - val secureUrl = apiImage.source.secureUrl.getOrElse(throw MissingSecureSourceUrl) - val colourType = apiImage.fileMetadata.colourModelInformation.getOrElse("colorType", "") - val hasAlpha = apiImage.fileMetadata.colourModelInformation.get("hasAlpha").flatMap(a => Try(a.toBoolean).toOption).getOrElse(true) - val cropType = Crops.cropType(mimeType, colourType, hasAlpha) + val secureFile = apiImage.source.file + + val key = imageBucket.keyFromS3URL(secureFile) + val secureUrl = s3.signUrlTony(imageBucket, key) - Stopwatch.async(s"making crop assets for ${apiImage.id} ${Crop.getCropId(source.bounds)}") { - for { - sourceFile <- tempFileFromURL(secureUrl, "cropSource", "", config.tempDir) - colourModel <- ImageOperations.identifyColourModel(sourceFile, mimeType) - masterCrop <- createMasterCrop(apiImage, sourceFile, crop, cropType, colourModel, apiImage.source.orientationMetadata) + //val eventualResult = Stopwatch(s"making crop assets for ${apiImage.id} ${Crop.getCropId(source.bounds)}") { + tempFileFromURL(secureUrl, "cropSource", "", config.tempDir).flatMap { sourceFile => + logger.info("Starting vips operations") + implicit val arena: Arena = Arena.ofShared() + val masterCrop = createMasterCrop(apiImage, sourceFile, crop, apiImage.metadata, apiImage.source.orientationMetadata) - outputDims = dimensionsFromConfig(source.bounds, masterCrop.aspectRatio) :+ masterCrop.dimensions + val isGraphic = ImageOperations.isGraphicVips(masterCrop.image) + val hasAlpha = ImageOperations.hasAlpha(masterCrop.image) + val cropType = Crops.cropType(mimeType, isGraphic = isGraphic, hasAlpha = hasAlpha) - sizes <- createCrops(masterCrop.file, outputDims, apiImage, crop, cropType) - masterSize <- masterCrop.sizing - _ <- Future.sequence(List(masterCrop.file, sourceFile).map(delete)) + // pngs are always lossless, so quality only means effort spent compressing them. We don't + // care too much about filesize of master crops, so skip expensive compression to get faster cropping + val masterQuality = if (mimeType == Png) pngMasterCropQuality else jpegMasterCropQuality + + // High quality rendering with minimal compression which will be used as the CDN resizer origin + logger.info("Requesting master file save") + val eventualMasterSaved = Future { + val masterCropFile = File.createTempFile(s"crop-", s"${cropType.fileExtension}", config.tempDir) // TODO function for this + imageOperations.saveImageToFile(masterCrop.image, cropType, masterQuality, masterCropFile, keep = Some(VipsRaw.VIPS_FOREIGN_KEEP_XMP)) + masterCropFile + } + + // Static crops; higher compression + logger.info("Requesting resize file saves") + val outputDims = dimensionsFromConfig(source.bounds, masterCrop.aspectRatio) :+ masterCrop.dimensions + val eventualResizes = imageOperations.createCrops(masterCrop.image, outputDims, apiImage.id, crop.specification.bounds, cropType, config.tempDir, cropQuality) + + // Store assets after master and resize file generation completes; to avoid sending a partial set on failure + // Delete the stored files and return the export result + eventualMasterSaved.flatMap { masterCropFile => + eventualResizes.flatMap { resizes => + // All vips operations have completed; we can close the arena + arena.close() + logger.info("Finished vips operations") + + val eventuallyStoredMasterAsset = store.storeCropSizing(masterCropFile, outputFilename(apiImage.id, source.bounds, masterCrop.dimensions.width, cropType, isMaster = true), + cropType, crop, masterCrop.dimensions).map { masterAsset => + logger.info("Master Crop stored") + delete(masterCropFile) + masterAsset + } + + val eventualStoredResizeAssets = Future.sequence(resizes.map { resize: (File, String, Dimensions) => + val file = resize._1 + val filename = resize._2 + val dimensions = resize._3 + logger.info(s"Storing crop for: $file, $filename, $cropType") + for { + resizedAsset: Asset <- store.storeCropSizing(file, filename, cropType, crop, dimensions) + _ <- delete(file) + } + yield resizedAsset + }) + + for { + masterAsset: Asset <- eventuallyStoredMasterAsset + resizedAssets: Seq[Asset] <- eventualStoredResizeAssets + } yield { + logger.info("Store assets completed") + delete(sourceFile) + ExportResult(apiImage.id, masterAsset, resizedAssets.toList) + } + } } - yield ExportResult(apiImage.id, masterSize, sizes) } } } -object Crops { +object Crops extends GridLogging { /** * The aim here is to decide whether the crops should be JPEG or PNGs depending on a predicted quality/size trade-off. * - If the image has transparency then it should always be a PNG as the transparency is not available in JPEG * - If the image is not true colour then we assume it is a graphic that should be retained as a PNG */ - def cropType(mediaType: MimeType, colourType: String, hasAlpha: Boolean): MimeType = { - val isGraphic = !colourType.matches("True[ ]?Color.*") + def cropType(mediaType: MimeType, isGraphic: Boolean, hasAlpha: Boolean): MimeType = { val outputAsPng = hasAlpha || isGraphic - mediaType match { - case Png | Tiff if outputAsPng => Png + val decision = mediaType match { + case Png if outputAsPng => Png + case Tiff if outputAsPng => Png case _ => Jpeg } + + logger.info(s"Choose crop type for $mediaType, $isGraphic, $hasAlpha: " + decision) + decision } } diff --git a/cropper/conf/routes b/cropper/conf/routes index c4aad4e4ee0..207c5df4b17 100644 --- a/cropper/conf/routes +++ b/cropper/conf/routes @@ -8,7 +8,6 @@ DELETE /crops/:id controllers.CropperControl # Management GET /management/healthcheck com.gu.mediaservice.lib.management.Management.healthCheck GET /management/manifest com.gu.mediaservice.lib.management.Management.manifest -GET /management/whoAmI com.gu.mediaservice.lib.management.InnerServiceStatusCheckController.whoAmI(depth: Int) # Shoo robots away GET /robots.txt com.gu.mediaservice.lib.management.Management.disallowRobots diff --git a/cropper/test/controllers/MediaApiUrlsTest.scala b/cropper/test/controllers/MediaApiUrlsTest.scala new file mode 100644 index 00000000000..6033c6b8ab4 --- /dev/null +++ b/cropper/test/controllers/MediaApiUrlsTest.scala @@ -0,0 +1,22 @@ +package controllers + +import com.gu.mediaservice.lib.imaging.ImageOperations +import com.gu.mediaservice.model._ +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers +import org.scalatestplus.mockito.MockitoSugar +import org.scalatest._ +import flatspec._ +import matchers._ + +class MediaApiUrlsTest extends AnyFlatSpec with Matchers with MediaApiUrls { + + "media api urls" should + "identify media API image urls" in { + isMediaApiImageUri("https://media.api.test.com/images/cb5b8c05b690db2d034457b5461ef32abf29eff8", "https://media.api.test.com") shouldBe true + isMediaApiImageUri("https://media.api.test.com/images", "https://media.api.test.com") shouldBe false + isMediaApiImageUri("images/cb5b8c05b690db2d034457b5461ef32abf29eff8", "https://media.api.test.com") shouldBe false + isMediaApiImageUri("cb5b8c05b690db2d034457b5461ef32abf29eff8", "https://media.api.test.com") shouldBe false + } + +} diff --git a/cropper/test/lib/CropsTest.scala b/cropper/test/lib/CropsTest.scala index a0d0ba8a62c..cf66d76899d 100644 --- a/cropper/test/lib/CropsTest.scala +++ b/cropper/test/lib/CropsTest.scala @@ -1,7 +1,10 @@ package lib +import com.amazonaws.services.s3.AmazonS3 +import com.gu.mediaservice.lib.aws.{S3, S3Bucket} import com.gu.mediaservice.lib.imaging.ImageOperations import com.gu.mediaservice.model._ +import org.mockito.Mockito.when import org.scalatest.funspec.AnyFunSpec import org.scalatest.matchers.should.Matchers import org.scalatestplus.mockito.MockitoSugar @@ -9,64 +12,75 @@ import org.scalatestplus.mockito.MockitoSugar class CropsTest extends AnyFunSpec with Matchers with MockitoSugar { import scala.concurrent.ExecutionContext.Implicits.global + private implicit val instance: Instance = Instance(id = "an-instance") + it("should return JPEG when the input type is a JPEG") { - Crops.cropType(Jpeg, "True Color", hasAlpha = false) shouldBe Jpeg - Crops.cropType(Jpeg, "Monkey", hasAlpha = false) shouldBe Jpeg + Crops.cropType(Jpeg, isGraphic = false, hasAlpha = false) shouldBe Jpeg + Crops.cropType(Jpeg, isGraphic = true, hasAlpha = false) shouldBe Jpeg } it("should return PNG when the input type is PNG and it has alpha") { - Crops.cropType(Png, "Monkey", hasAlpha = true) shouldBe Png + Crops.cropType(Png, isGraphic = true, hasAlpha = true) shouldBe Png } it("should return PNG when the input type is PNG and it has alpha even if it is True Color") { - Crops.cropType(Png, "True Color", hasAlpha = true) shouldBe Png + Crops.cropType(Png, isGraphic = false, hasAlpha = true) shouldBe Png } it("should return PNG when the input type is PNG and it is NOT true color (a graphic)") { - Crops.cropType(Png, "Monkey", hasAlpha = false) shouldBe Png + Crops.cropType(Png, isGraphic = true, hasAlpha = false) shouldBe Png } it("should return JPEG when the input type is PNG and it is true color") { - Crops.cropType(Png, "True Color", hasAlpha = false) shouldBe Jpeg + Crops.cropType(Png, isGraphic = false, hasAlpha = false) shouldBe Jpeg } it("should return PNG when the input type is TIFF and it has alpha") { - Crops.cropType(Tiff, "Monkey", hasAlpha = true) shouldBe Png + Crops.cropType(Tiff, isGraphic = false, hasAlpha = true) shouldBe Png } it("should return PNG when the input type is TIFF and it doesn't have alpha or is true color") { - Crops.cropType(Tiff, "Monkey", hasAlpha = false) shouldBe Png + Crops.cropType(Tiff, isGraphic = true, hasAlpha = false) shouldBe Png } it("should return JPEG when the input type is TIFF and it doesn't have alpha and it is true color") { - Crops.cropType(Tiff, "TrueColor", hasAlpha = false) shouldBe Jpeg + Crops.cropType(Tiff, isGraphic = false, hasAlpha = false) shouldBe Jpeg } - private val config = mock[CropperConfig] + private val config = { + val mockConfig = mock[CropperConfig] + when(mockConfig.awsRegion).thenReturn("eu-west-1") + when(mockConfig.googleS3AccessKey).thenReturn(None) + when(mockConfig.googleS3SecretKey).thenReturn(None) + mockConfig + } private val store = mock[CropStore] private val imageOperations: ImageOperations = mock[ImageOperations] private val source: SourceImage = SourceImage("test", mock[Asset], valid = true, mock[ImageMetadata], mock[FileMetadata]) private val bounds: Bounds = Bounds(10, 20, 30, 40) private val outputWidth = 1234 + private val mockS3Client = mock[AmazonS3] + private val imageBucket = S3Bucket("crops-bucket", S3.AmazonAwsS3Endpoint, usesPathStyleURLs = false, mockS3Client) + private val s3 = new S3(config) it("should should construct a correct address for a master jpg") { - val outputFilename = new Crops(config, store, imageOperations) - .outputFilename(source, bounds, outputWidth, Jpeg, isMaster = true) - outputFilename shouldBe "test/10_20_30_40/master/1234.jpg" + val outputFilename = new Crops(config, store, imageOperations, imageBucket, s3) + .outputFilename(source.id, bounds, outputWidth, Jpeg, isMaster = true) + outputFilename shouldBe "an-instance/test/10_20_30_40/master/1234.jpg" } it("should should construct a correct address for a non-master jpg") { - val outputFilename = new Crops(config, store, imageOperations) - .outputFilename(source, bounds, outputWidth, Jpeg) - outputFilename shouldBe "test/10_20_30_40/1234.jpg" + val outputFilename = new Crops(config, store, imageOperations, imageBucket, s3) + .outputFilename(source.id, bounds, outputWidth, Jpeg) + outputFilename shouldBe "an-instance/test/10_20_30_40/1234.jpg" } it("should should construct a correct address for a non-master tiff") { - val outputFilename = new Crops(config, store, imageOperations) - .outputFilename(source, bounds, outputWidth, Tiff) - outputFilename shouldBe "test/10_20_30_40/1234.tiff" + val outputFilename = new Crops(config, store, imageOperations, imageBucket, s3) + .outputFilename(source.id, bounds, outputWidth, Tiff) + outputFilename shouldBe "an-instance/test/10_20_30_40/1234.tiff" } it("should should construct a correct address for a non-master png") { - val outputFilename = new Crops(config, store, imageOperations) - .outputFilename(source, bounds, outputWidth, Png) - outputFilename shouldBe "test/10_20_30_40/1234.png" + val outputFilename = new Crops(config, store, imageOperations, imageBucket, s3) + .outputFilename(source.id, bounds, outputWidth, Png) + outputFilename shouldBe "an-instance/test/10_20_30_40/1234.png" } } diff --git a/dev/cloudformation/grid-dev-core.yml b/dev/cloudformation/grid-dev-core.yml index 9cc2dc03c3e..10a20b149fa 100644 --- a/dev/cloudformation/grid-dev-core.yml +++ b/dev/cloudformation/grid-dev-core.yml @@ -1,4 +1,4 @@ -AWSTemplateFormatVersion: '2010-09-09' +AWSTemplateFormatVersion: "2010-09-09" Description: Core resources for Grid in DEV Resources: ThrallMessageStream: @@ -15,7 +15,7 @@ Resources: RetentionPeriodHours: 168 IngestSqsQueue: - Type: AWS::SQS::Queue # standard queue will suffice (Fifo not worth performance penalty) + Type: AWS::SQS::Queue # standard queue will suffice (Fifo not worth performance penalty) Properties: QueueName: IngestSqsQueue ReceiveMessageWaitTimeSeconds: 20 # maximum - most efficient for long polling @@ -29,15 +29,15 @@ Resources: CorsRules: - Id: AllowUploadsFromBrowser AllowedHeaders: - - '*' + - "*" AllowedMethods: - PUT AllowedOrigins: - - 'https://media.local.dev-gutools.co.uk' + - "https://media.local.dev-gutools.co.uk" NotificationConfiguration: QueueConfigurations: - - Event: "s3:ObjectCreated:*" - Queue: !GetAtt IngestSqsQueue.Arn + - Event: "s3:ObjectCreated:*" + Queue: !GetAtt IngestSqsQueue.Arn IngestQueueFailBucket: Type: AWS::S3::Bucket @@ -47,13 +47,13 @@ Resources: Properties: CorsConfiguration: CorsRules: - - AllowedMethods: - - GET - AllowedOrigins: - - '*' - ExposedHeaders: - - Date - MaxAge: '3600' + - AllowedMethods: + - GET + AllowedOrigins: + - "*" + ExposedHeaders: + - Date + MaxAge: "3600" ThumbBucket: Type: AWS::S3::Bucket @@ -130,19 +130,19 @@ Resources: Projection: ProjectionType: KEYS_ONLY ProvisionedThroughput: - ReadCapacityUnits: '4' - WriteCapacityUnits: '2' + ReadCapacityUnits: "4" + WriteCapacityUnits: "2" CollectionsDynamoTable: Type: AWS::DynamoDB::Table Properties: TableName: CollectionsTable AttributeDefinitions: - - AttributeName: id - AttributeType: S + - AttributeName: id + AttributeType: S KeySchema: - - AttributeName: id - KeyType: HASH + - AttributeName: id + KeyType: HASH ProvisionedThroughput: ReadCapacityUnits: 1 WriteCapacityUnits: 1 @@ -152,11 +152,11 @@ Resources: Properties: TableName: ImageCollectionsTable AttributeDefinitions: - - AttributeName: id - AttributeType: S + - AttributeName: id + AttributeType: S KeySchema: - - AttributeName: id - KeyType: HASH + - AttributeName: id + KeyType: HASH ProvisionedThroughput: ReadCapacityUnits: 1 WriteCapacityUnits: 1 @@ -166,26 +166,26 @@ Resources: Properties: TableName: LeasesTable AttributeDefinitions: - - AttributeName: id - AttributeType: S - - AttributeName: mediaId - AttributeType: S + - AttributeName: id + AttributeType: S + - AttributeName: mediaId + AttributeType: S KeySchema: - - AttributeName: id - KeyType: HASH + - AttributeName: id + KeyType: HASH ProvisionedThroughput: ReadCapacityUnits: 1 WriteCapacityUnits: 1 GlobalSecondaryIndexes: - - IndexName: mediaId - KeySchema: - - AttributeName: mediaId - KeyType: HASH - Projection: - ProjectionType: ALL - ProvisionedThroughput: - ReadCapacityUnits: 1 - WriteCapacityUnits: 1 + - IndexName: mediaId + KeySchema: + - AttributeName: mediaId + KeyType: HASH + Projection: + ProjectionType: ALL + ProvisionedThroughput: + ReadCapacityUnits: 1 + WriteCapacityUnits: 1 IndexedImageMetadataQueue: Type: AWS::SQS::Queue IndexedImageTopic: @@ -193,34 +193,50 @@ Resources: DependsOn: IndexedImageMetadataQueue Properties: Subscription: - - Endpoint: !GetAtt 'IndexedImageMetadataQueue.Arn' - Protocol: sqs + - Endpoint: !GetAtt "IndexedImageMetadataQueue.Arn" + Protocol: sqs + DownscaledImageBucket: + Type: AWS::S3::Bucket + + ImageEmbedderDLQ: + Type: AWS::SQS::Queue + Properties: + QueueName: image-embedder-DLQ-DEV + MessageRetentionPeriod: 1209600 # 14 days + ImageEmbedderQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: image-embedder-DEV + VisibilityTimeout: 60 + RedrivePolicy: + deadLetterTargetArn: !GetAtt ImageEmbedderDLQ.Arn + maxReceiveCount: 3 UsageRecordTable: Type: AWS::DynamoDB::Table Properties: TableName: UsageRecordTable AttributeDefinitions: - - AttributeName: usage_id - AttributeType: S - - AttributeName: grouping - AttributeType: S - - AttributeName: media_id - AttributeType: S - KeySchema: - - AttributeName: grouping - KeyType: HASH - - AttributeName: usage_id - KeyType: RANGE - GlobalSecondaryIndexes: - - IndexName: media_id - KeySchema: + - AttributeName: usage_id + AttributeType: S + - AttributeName: grouping + AttributeType: S - AttributeName: media_id + AttributeType: S + KeySchema: + - AttributeName: grouping KeyType: HASH - Projection: - ProjectionType: ALL - ProvisionedThroughput: - ReadCapacityUnits: 1 - WriteCapacityUnits: 1 + - AttributeName: usage_id + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: media_id + KeySchema: + - AttributeName: media_id + KeyType: HASH + Projection: + ProjectionType: ALL + ProvisionedThroughput: + ReadCapacityUnits: 1 + WriteCapacityUnits: 1 ProvisionedThroughput: ReadCapacityUnits: 1 WriteCapacityUnits: 1 @@ -230,26 +246,25 @@ Resources: Properties: TableName: LiveContentPollTable AttributeDefinitions: - - AttributeName: id - AttributeType: S + - AttributeName: id + AttributeType: S KeySchema: - - AttributeName: id - KeyType: HASH + - AttributeName: id + KeyType: HASH ProvisionedThroughput: ReadCapacityUnits: 1 WriteCapacityUnits: 1 - PreviewContentPollTable: Type: AWS::DynamoDB::Table Properties: TableName: PreviewContentPollTable AttributeDefinitions: - - AttributeName: id - AttributeType: S + - AttributeName: id + AttributeType: S KeySchema: - - AttributeName: id - KeyType: HASH + - AttributeName: id + KeyType: HASH ProvisionedThroughput: ReadCapacityUnits: 1 WriteCapacityUnits: 1 @@ -259,11 +274,11 @@ Resources: Properties: TableName: UploadStatusTable AttributeDefinitions: - - AttributeName: id - AttributeType: S + - AttributeName: id + AttributeType: S KeySchema: - - AttributeName: id - KeyType: HASH + - AttributeName: id + KeyType: HASH ProvisionedThroughput: ReadCapacityUnits: 1 WriteCapacityUnits: 1 @@ -287,54 +302,60 @@ Resources: Outputs: ImageBucket: - Value: !Ref 'ImageBucket' + Value: !Ref "ImageBucket" ThumbBucket: - Value: !Ref 'ThumbBucket' + Value: !Ref "ThumbBucket" KeyBucket: - Value: !Ref 'KeyBucket' + Value: !Ref "KeyBucket" ImageOriginBucket: - Value: !Ref 'ImageOriginBucket' + Value: !Ref "ImageOriginBucket" ImageOriginWebsite: - Value: !GetAtt 'ImageOriginBucket.WebsiteURL' + Value: !GetAtt "ImageOriginBucket.WebsiteURL" ConfigBucket: - Value: !Ref 'ConfigBucket' + Value: !Ref "ConfigBucket" CollectionsBucket: - Value: !Ref 'CollectionsBucket' + Value: !Ref "CollectionsBucket" S3WatcherIngestBucket: - Value: !Ref 'S3WatcherIngestBucket' + Value: !Ref "S3WatcherIngestBucket" S3WatcherFailBucket: - Value: !Ref 'S3WatcherFailBucket' + Value: !Ref "S3WatcherFailBucket" UsageMailBucket: - Value: !Ref 'UsageMailBucket' + Value: !Ref "UsageMailBucket" SyndicationDynamoTable: - Value: !Ref 'SyndicationDynamoTable' + Value: !Ref "SyndicationDynamoTable" EditsDynamoTable: - Value: !Ref 'EditsDynamoTable' + Value: !Ref "EditsDynamoTable" CollectionsDynamoTable: - Value: !Ref 'CollectionsDynamoTable' + Value: !Ref "CollectionsDynamoTable" ImageCollectionsDynamoTable: - Value: !Ref 'ImageCollectionsDynamoTable' + Value: !Ref "ImageCollectionsDynamoTable" LeasesDynamoTable: - Value: !Ref 'LeasesDynamoTable' + Value: !Ref "LeasesDynamoTable" IndexedImageTopicArn: - Value: !Ref 'IndexedImageTopic' + Value: !Ref "IndexedImageTopic" IndexedImageMetadataQueueUrl: - Value: !Ref 'IndexedImageMetadataQueue' + Value: !Ref "IndexedImageMetadataQueue" UsageRecordTable: - Value: !Ref 'UsageRecordTable' + Value: !Ref "UsageRecordTable" LiveContentPollTable: - Value: !Ref 'LiveContentPollTable' + Value: !Ref "LiveContentPollTable" PreviewContentPollTable: - Value: !Ref 'PreviewContentPollTable' + Value: !Ref "PreviewContentPollTable" UploadStatusTable: - Value: !Ref 'UploadStatusTable' + Value: !Ref "UploadStatusTable" SoftDeletedMetadataTable: - Value: !Ref 'SoftDeletedMetadataTable' + Value: !Ref "SoftDeletedMetadataTable" IngestSqsQueueUrl: - Value: !Ref 'IngestSqsQueue' + Value: !Ref "IngestSqsQueue" IngestSqsQueueArn: - Value: !GetAtt IngestSqsQueue.Arn + Value: !GetAtt IngestSqsQueue.Arn IngestQueueBucket: - Value: !Ref 'IngestQueueBucket' + Value: !Ref "IngestQueueBucket" IngestQueueFailBucket: - Value: !Ref 'IngestQueueFailBucket' + Value: !Ref "IngestQueueFailBucket" + ImageEmbedderQueueUrl: + Value: !Ref "ImageEmbedderQueue" + ImageEmbedderQueueArn: + Value: !GetAtt ImageEmbedderQueue.Arn + DownscaledImageBucket: + Value: !Ref "DownscaledImageBucket" diff --git a/dev/oidc-provider/package-lock.json b/dev/oidc-provider/package-lock.json index b4ffdea22c9..d14276de07e 100644 --- a/dev/oidc-provider/package-lock.json +++ b/dev/oidc-provider/package-lock.json @@ -3007,9 +3007,9 @@ "dev": true }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "dependencies": { "argparse": "^1.0.7", @@ -6553,9 +6553,9 @@ "dev": true }, "js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "requires": { "argparse": "^1.0.7", diff --git a/dev/script/generate-config/service-config.js b/dev/script/generate-config/service-config.js index d969844af30..705dab99a5e 100644 --- a/dev/script/generate-config/service-config.js +++ b/dev/script/generate-config/service-config.js @@ -27,6 +27,55 @@ function getCommonConfig(config) { |sqs.ingest.queue.url="${config.coreStackProps.IngestSqsQueue.replace("http://localhost:4576", `https://localstack.media.${config.DOMAIN}`)}" |s3.ingest.bucket="${config.coreStackProps.IngestQueueBucket}" |s3.fail.bucket="${config.coreStackProps.IngestQueueFailBucket}" + |usageRightsConfigProvider.config.freeSuppliers=[ + | "AAP", + | "Alamy", + | "Allstar Picture Library", + | "AP", + | "EPA", + | "Getty Images", + | "PA", + | "Reuters", + | "Rex Features", + | "Ronald Grant Archive", + | "Action Images" + |] + |usageRightsConfigProvider.config.externalStaffPhotographers=[{ + | name="Example publication", + | photographers=["Example External Photographer"], + |}] + |usageRightsConfigProvider.config.internalStaffPhotographers=[{ + | name="Example publication", + | photographers=["Example Internal Photographer"] + |}] + |usageRightsConfigProvider.config.contractedPhotographers=[{ + | name="Example publication", + | photographers=["Example Contracted Photographer"] + |}] + |usageRightsConfigProvider.config.contractIllustrators=[{ + | name="Example publication", + | photographers=["Example Contracted Illustrator"] + |}] + |usageRightsConfigProvider.config.staffIllustrators=["Example Staff Illustrator"] + |agencyPicks.ingredients={ + | "metadata.description": [ + | "topshot", // Getty + | "topshots", // Getty + | "bestpix", // Getty + | "PABest", // PA + | "TPX IMAGES OF THE DAY", // Reuters + | "epaselect", // EPA + | "APTOPIX", // AP + | ], + | "metadata.keywords": [ + | "epaselect", // EPA + | "aptopix", // AP + | "APTOPIX", // AP + | "SPOTLIGHT", // Rex/Shutterstock + | "spotlight", // Rex/Shutterstock + | "Spotlight" // Rex/Shutterstock + | ] + |} |`; } @@ -66,14 +115,16 @@ function getImageLoaderConfig(config) { |aws.region="${config.AWS_DEFAULT_REGION}" |s3.image.bucket="${config.coreStackProps.ImageBucket}" |s3.thumb.bucket="${config.coreStackProps.ThumbBucket}" - |s3.quarantine.bucket="${config.coreStackProps.QuarantineBucket}" + |#uncomment to enable the quarantine bucket + |#s3.quarantine.bucket="${config.coreStackProps.QuarantineBucket}" |s3.config.bucket="${config.coreStackProps.ConfigBucket}" |dynamo.table.upload.status="UploadStatusTable" |aws.local.endpoint="https://localstack.media.${config.DOMAIN}" |security.cors.allowedOrigins="${getCorsAllowedOriginString(config)}" |metrics.request.enabled=false |transcoded.mime.types="image/tiff" - |upload.quarantine.enabled=false + |s3.vectors.shouldEmbed=false + |sqs.image.embedder.queue.url="${config.coreStackProps.ImageEmbedderQueue}" |`; } @@ -101,9 +152,7 @@ function getKahunaConfig(config){ return stripMargin`${getCommonConfig(config)} |aws.region="${config.AWS_DEFAULT_REGION}" - |origin.full="images.media.${config.DOMAIN}" |origin.thumb="localstack.media.${config.DOMAIN}" - |origin.images="images.media.${config.DOMAIN}" |origin.crops="public.media.${config.DOMAIN}" |google.tracking.id="${config.google.tracking.id}" |links.feedbackForm="${config.links.feedbackForm}" @@ -136,7 +185,6 @@ function getMediaApiConfig(config) { |s3.thumb.bucket="${config.coreStackProps.ThumbBucket}" |s3.config.bucket="${config.coreStackProps.ConfigBucket}" |s3.usagemail.bucket="${config.coreStackProps.UsageMailBucket}" - |persistence.identifier="picdarUrn" |es6.url="${config.es6.url}" |es6.shards=${config.es6.shards} |es6.replicas=${config.es6.replicas} @@ -179,7 +227,6 @@ function getThrallConfig(config) { |s3.image.bucket="${config.coreStackProps.ImageBucket}" |s3.thumb.bucket="${config.coreStackProps.ThumbBucket}" |s3.reaper.bucket="${config.coreStackProps.ReaperBucket}" - |persistence.identifier="picdarUrn" |indexed.image.sns.topic.arn="${config.coreStackProps.IndexedImageTopic}" |es6.url="${config.es6.url}" |es6.shards=${config.es6.shards} diff --git a/docs/06-objects-of-interest/02-config.md b/docs/06-objects-of-interest/02-config.md index 804401001d8..7411f7fbb2b 100644 --- a/docs/06-objects-of-interest/02-config.md +++ b/docs/06-objects-of-interest/02-config.md @@ -86,12 +86,6 @@ Service-specific configs. These will override all other config files. Json Object Array [] - - persistence.identifier
Used by the reaper to retain images which have a particular identifier (e.g. picdarUrn for Guardian) - True - string - - persistence.onlyTheseCollections
Used by the reaper…
    diff --git a/image-counter-lambda/package-lock.json b/image-counter-lambda/package-lock.json index cc6316f5326..94cc959e54e 100644 --- a/image-counter-lambda/package-lock.json +++ b/image-counter-lambda/package-lock.json @@ -4508,9 +4508,9 @@ "dev": true }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "dependencies": { "argparse": "^1.0.7", @@ -9127,9 +9127,9 @@ "dev": true }, "js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "requires": { "argparse": "^1.0.7", diff --git a/image-embedder-lambda/README.md b/image-embedder-lambda/README.md new file mode 100644 index 00000000000..c9ee80b84aa --- /dev/null +++ b/image-embedder-lambda/README.md @@ -0,0 +1,118 @@ +# Embedder Lambda + +This is a service responsible for embedding uploaded images to the Grid and writing those embeddings to the S3 Vector Store. + +## Documentation + +- [Native Modules & Bundling](docs/native-modules-bundling.md) - How we handle `sharp` and other native Node.js modules for Lambda deployment + +## Running Locally with Localstack + +The ImageEmbedder queue can be run locally by polling the localstack SQS queue and executing the lambda code with the help of the `localRun` script. + +### What Actually Runs + +The local runner: + +- ✅ **Polls the localstack SQS queue** for messages +- ✅ **Executes the lambda handler code** +- ✅ **Fetches images from localstack S3** +- ⚠️ **Calls AWS Bedrock** for embeddings (requires AWS credentials) +- ⚠️ **Stores vectors in AWS S3 Vectors** (requires AWS credentials) + +**Important:** Bedrock and S3 Vectors are not available in localstack, so the lambda will connect to **AWS services**. Make sure you have AWS credentials configured. + +### Prerequisites + +1. Core CloudFormation stack deployed to localstack (run `dev/script/setup.sh`) +1. Localstack running via `dev/script/start.sh` +1. Dependencies installed: `npm install` +1. **AWS credentials configured** for Bedrock and S3 Vectors access + +### Start the Local Runner + +```bash +npm run local +``` + +This will: + +- Build the lambda code +- Start polling the localstack SQS queue (`image-embedder-DEV`) +- Execute the lambda handler when messages arrive +- Delete messages from the queue on success +- Allow failed messages to retry or go to the DLQ + +### Configuration + +Environment variables you can set: + +- `LOCALSTACK_ENDPOINT` - Localstack endpoint (default: `http://localhost:4566`) +- `QUEUE_URL` - Full SQS queue URL (default: `http://localhost:4566/000000000000/image-embedder-DEV`) +- `POLL_INTERVAL_MS` - How often to poll for messages (default: `5000`) +- `AWS_PROFILE` - AWS credentials profile to use for Bedrock/S3 Vectors + +Example: + +```bash +AWS_PROFILE=media-service POLL_INTERVAL_MS=2000 npm run local +``` + +### Testing + +To test the lambda locally, first get the name of the image bucket within localstack (its final part is autogenerated when creating the stack): + +```bash +LOCALSTACK_IMAGE_BUCKET=$(aws cloudformation describe-stack-resources \ + --stack-name grid-dev-core \ + --endpoint-url http://localhost:4566 \ + --profile media-service \ + --region eu-west-1 \ + | jq -r '.StackResources[] | select(.LogicalResourceId == "ImageBucket") | .PhysicalResourceId') +``` + +Then send a message to the queue: + +#### TODO +But how do we actually get an image into the bucket? The easiest way is to run the app and upload. But that should trigger the lambda anyway right? So what's the point of this "manually putting stuff on the queue" bit? + +```bash +aws sqs send-message \ + --queue-url http://localhost:4566/000000000000/image-embedder-DEV \ + --message-body "{\"imageId\":\"test-123\",\"s3Bucket\":\"$LOCALSTACK_IMAGE_BUCKET\",\"s3Key\":\"test/image.jpg\",\"fileType\":\"image/jpeg\"}" \ + --endpoint-url http://localhost:4566 \ + --profile media-service \ + --region eu-west-1 +``` + +The lambda will: + +1. Receive the message from localstack +2. Try to fetch the image from the S3 bucket within localstack +3. Send the image to AWS Bedrock (the real thing, not localstack) for embedding +4. Store the embedding in AWS S3 Vectors (the real thing, not localstack) + +## Exercising the real lambda in AWS TEST +```bash +scripts/embedder-deploy/send-message.sh \ + ce05aa50f210261bb2b830daf6c5ce6f11a16f84 \ + image/jpeg \ + media-service-test-imagebucket-1qt2lbcwnpgl0 \ + c/e/0/5/a/a/ce05aa50f210261bb2b830daf6c5ce6f11a16f84 +``` + +```bash +scripts/embedder-deploy/send-message-batch.sh \ + ce05aa50f210261bb2b830daf6c5ce6f11a16f84 \ + image/jpeg \ + media-service-test-imagebucket-1qt2lbcwnpgl0 \ + c/e/0/5/a/a/ce05aa50f210261bb2b830daf6c5ce6f11a16f84 +``` + +## Integration Tests + +Run manually (not in CI) with `npm run test:integration`. Requires AWS credentials with S3 and Bedrock access. + +- Input images are downloaded from S3 once and cached locally in `test-data/input/` +- Output images (e.g. downscaled) are written to `test-data/output/` +- Both directories are gitignored; having files locally allows quick visual inspection to check images haven't been mangled \ No newline at end of file diff --git a/image-embedder-lambda/__tests__/integration/downscale.integration.test.ts b/image-embedder-lambda/__tests__/integration/downscale.integration.test.ts new file mode 100644 index 00000000000..f7021800336 --- /dev/null +++ b/image-embedder-lambda/__tests__/integration/downscale.integration.test.ts @@ -0,0 +1,167 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import sharp from "sharp"; +import { downscaleImageIfNeeded } from "../../src/index"; +import { + MAX_IMAGE_SIZE_BYTES, + MAX_PIXELS_COHERE_V4, +} from "../../src/constants"; +import { + ensureDirectoriesExist, + getTestImage, + writeOutputImage, +} from "./localTestFiles"; + +const TEST_BUCKET = "image-embedding-test"; + +interface ImageDimensions { + width: number; + height: number; + pixels: number; +} + +async function getImageDimensions( + imageBytes: Uint8Array, +): Promise { + const { width, height } = await sharp(imageBytes).metadata(); + return { width, height, pixels: width * height }; +} + +interface DownscaleTestCase { + s3Key: string; + mimeType: "image/jpeg" | "image/png"; + inputWidth: number; + inputHeight: number; + inputBytes: number; + expectedOutputWidth: number; + expectedOutputHeight: number; + expectedOutputPixels: number; + expectedOutputBytes: number; + shouldDownscale: boolean; +} + +// These values would need to be adjusted if the downscaling logic changes, +// but the test will still assert that they are within the limits required by Cohere via Bedrock. +// This acts a sanity check that we're not excessively reducing the size by mistake. +// It also allows us to see at a glance in the test names precisely what resizing takes places. +const TEST_CASES: DownscaleTestCase[] = [ + { + s3Key: "large-images/aaf514e9530271ab5639bb5f496eef97cdce9b7a.jpeg", + mimeType: "image/jpeg", + inputWidth: 6976, + inputHeight: 4634, + inputBytes: 5_242_808, + expectedOutputWidth: 1923, + expectedOutputHeight: 1277, + expectedOutputPixels: 2_455_671, + expectedOutputBytes: 797_314, + shouldDownscale: true, + }, + { + s3Key: "large-images/5f8871b3686d06dadf3e7556cca2601c3b276288.jpeg", + mimeType: "image/jpeg", + inputWidth: 3454, + inputHeight: 2303, + inputBytes: 5_242_970, + expectedOutputWidth: 1920, + expectedOutputHeight: 1280, + expectedOutputPixels: 2_457_600, + expectedOutputBytes: 643_568, + shouldDownscale: true, + }, + { + s3Key: "large-images/fed92369dbbc961708ab883da815fc4c7f52597e.jpeg", + mimeType: "image/jpeg", + inputWidth: 5024, + inputHeight: 3395, + inputBytes: 10_360_979, + expectedOutputWidth: 1907, + expectedOutputHeight: 1289, + expectedOutputPixels: 2_458_123, + expectedOutputBytes: 494_080, + shouldDownscale: true, + }, + { + s3Key: "pngs/339d129c0b0f47507f7d299bf28046d40c12d368.png", + mimeType: "image/png", + inputWidth: 725, + inputHeight: 725, + inputBytes: 131_137, + expectedOutputWidth: 725, + expectedOutputHeight: 725, + expectedOutputPixels: 525_625, + expectedOutputBytes: 131_137, + shouldDownscale: false, + }, + { + s3Key: "pngs/c2039d7b0ba13910d7f8147128b86199784465ae.png", + mimeType: "image/png", + inputWidth: 3340, + inputHeight: 5380, + inputBytes: 16_368_332, + expectedOutputWidth: 1235, + expectedOutputHeight: 1989, + expectedOutputPixels: 2_456_415, + expectedOutputBytes: 3_898_056, + shouldDownscale: true, + }, +]; + +// Give test names like this, +// so we can see at a glance how much downscaling actually happened: +// ✓ image/jpeg: 6976x4634 (32,326,784 px, 5,242,808 B) → 1923x1277 (2,455,671 px, 797,314 B) +function formatTestName(tc: DownscaleTestCase): string { + const inputPixels = tc.inputWidth * tc.inputHeight; + const input = `${tc.inputWidth}x${tc.inputHeight} (${inputPixels.toLocaleString()} px, ${tc.inputBytes.toLocaleString()} B)`; + const output = `${tc.expectedOutputWidth}x${tc.expectedOutputHeight} (${tc.expectedOutputPixels.toLocaleString()} px, ${tc.expectedOutputBytes.toLocaleString()} B)`; + return tc.shouldDownscale + ? `${tc.mimeType}: ${input} → ${output}` + : `${tc.mimeType}: ${input} (no change needed)`; +} + +describe(`Downscaling images to not exceed ${MAX_IMAGE_SIZE_BYTES.toLocaleString()} bytes and ${MAX_PIXELS_COHERE_V4.toLocaleString()} pixels`, () => { + const s3Client = new S3Client({ region: "eu-west-1" }); + + beforeAll(async () => { + await ensureDirectoriesExist(); + }); + + for (const tc of TEST_CASES) { + it(formatTestName(tc), async () => { + const inputImage = await getTestImage(TEST_BUCKET, tc.s3Key, s3Client); + + const inputDimensions = await getImageDimensions(inputImage); + expect(inputDimensions.width).toBe(tc.inputWidth); + expect(inputDimensions.height).toBe(tc.inputHeight); + expect(inputImage.length).toBe(tc.inputBytes); + + const outputImage = await downscaleImageIfNeeded( + inputImage, + tc.mimeType, + MAX_IMAGE_SIZE_BYTES, + MAX_PIXELS_COHERE_V4, + ); + + if (tc.shouldDownscale) { + await writeOutputImage(outputImage, tc.s3Key, "_downscaled"); + } + + const outputDimensions = await getImageDimensions(outputImage); + + expect(outputDimensions.width).toBe(tc.expectedOutputWidth); + expect(outputDimensions.height).toBe(tc.expectedOutputHeight); + expect(outputDimensions.pixels).toBe(tc.expectedOutputPixels); + expect(outputImage.length).toBe(tc.expectedOutputBytes); + + if (tc.shouldDownscale) { + expect(outputImage.length).toBeLessThan(inputImage.length); + expect(outputDimensions.pixels).toBeLessThanOrEqual( + MAX_PIXELS_COHERE_V4, + ); + } else { + expect(outputImage).toBe(inputImage); + } + + expect(outputImage.length).toBeLessThanOrEqual(MAX_IMAGE_SIZE_BYTES); + }); + } +}); diff --git a/image-embedder-lambda/__tests__/integration/embedding.integration.test.ts b/image-embedder-lambda/__tests__/integration/embedding.integration.test.ts new file mode 100644 index 00000000000..9d2dceab1b0 --- /dev/null +++ b/image-embedder-lambda/__tests__/integration/embedding.integration.test.ts @@ -0,0 +1,93 @@ +import { BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime"; +import { S3Client } from "@aws-sdk/client-s3"; +import { embedImage } from "../../src/index"; +import { ensureDirectoriesExist, getTestImage } from "./localTestFiles"; + +/** + * Integration tests for Bedrock embedding. + * + * These tests call real AWS services and require: + * - Valid AWS credentials with S3 and Bedrock permissions + * - Test images uploaded to the specified S3 bucket (first run only) + * + * Run with: npm run test:integration + * + * Downloaded images are cached locally in __tests__/integration/test-data/input/ + */ + +const TEST_BUCKET = "image-embedding-test"; + +interface TestImage { + name: string; + key: string; + expectedBytes: number; + mimeType: "image/jpeg" | "image/png" | "image/tiff"; +} + +const TEST_IMAGES: TestImage[] = [ + { + name: "JPEG over 5 MB but just under 5 MiB", + key: "large-images/aaf514e9530271ab5639bb5f496eef97cdce9b7a.jpeg", + expectedBytes: 5_242_808, + mimeType: "image/jpeg", + }, + { + name: "JPEG just over 5 MiB", + key: "large-images/5f8871b3686d06dadf3e7556cca2601c3b276288.jpeg", + expectedBytes: 5_242_970, + mimeType: "image/jpeg", + }, + { + name: "JPEG over 10 MB", + key: "large-images/fed92369dbbc961708ab883da815fc4c7f52597e.jpeg", + expectedBytes: 10_360_979, + mimeType: "image/jpeg", + }, + { + name: "Small PNG", + key: "pngs/339d129c0b0f47507f7d299bf28046d40c12d368.png", + expectedBytes: 131_137, + mimeType: "image/png", + }, + { + name: "Large PNG", + key: "pngs/c2039d7b0ba13910d7f8147128b86199784465ae.png", + expectedBytes: 16_368_332, + mimeType: "image/png", + }, + { + name: "Small TIFF", + key: "tiffs/b927f8924960874eda447208baa3fe7963cba8c4.tiff", + expectedBytes: 1_542_708, + mimeType: "image/tiff", + }, + { + name: "Large TIFF", + key: "tiffs/7d0b7c7b8e890d7e5d369093aa437bd833e20f71.tiff", + expectedBytes: 6_038_108, + mimeType: "image/tiff", + }, +]; + +describe("Embedding with Cohere v3 via Bedrock", () => { + const s3Client = new S3Client({ region: "eu-west-1" }); + const bedrockClient = new BedrockRuntimeClient({ region: "eu-west-1" }); + + beforeAll(async () => { + await ensureDirectoriesExist(); + }); + + it.each(TEST_IMAGES)("should embed $name", async (image) => { + const imageBytes = await getTestImage(TEST_BUCKET, image.key, s3Client); + expect(imageBytes.length).toBe(image.expectedBytes); + + const response = await embedImage( + image.key, + imageBytes, + image.mimeType, + bedrockClient, + s3Client, + ); + expect(response.$metadata.httpStatusCode).toBe(200); + }); +}); diff --git a/image-embedder-lambda/__tests__/integration/localTestFiles.ts b/image-embedder-lambda/__tests__/integration/localTestFiles.ts new file mode 100644 index 00000000000..c9a23186a2f --- /dev/null +++ b/image-embedder-lambda/__tests__/integration/localTestFiles.ts @@ -0,0 +1,61 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import { S3Client } from "@aws-sdk/client-s3"; +import { getImageFromS3 } from "../../src/index"; + +const CACHE_DIR = path.join(__dirname, "test-data", "input"); +const OUTPUT_DIR = path.join(__dirname, "test-data", "output"); + +export async function ensureDirectoriesExist(): Promise { + await fs.mkdir(CACHE_DIR, { recursive: true }); + await fs.mkdir(OUTPUT_DIR, { recursive: true }); +} + +function getCachePath(s3Key: string): string { + return path.join(CACHE_DIR, s3Key.replace(/\//g, "_")); +} + +function getOutputPath(s3Key: string, suffix: string = ""): string { + const baseName = s3Key.replace(/\//g, "_"); + const ext = path.extname(baseName); + const name = baseName.slice(0, -ext.length); + return path.join(OUTPUT_DIR, `${name}${suffix}${ext}`); +} + +export async function getTestImage( + bucket: string, + s3Key: string, + s3Client: S3Client, +): Promise { + const cachePath = getCachePath(s3Key); + + try { + const cached = await fs.readFile(cachePath); + console.log(`Using cached file: ${cachePath}`); + return new Uint8Array(cached); + } catch { + console.log(`Cache miss, downloading from S3: ${s3Key}`); + const imageBytes = await getImageFromS3(bucket, s3Key, s3Client); + if (!imageBytes) { + throw new Error(`Couldn"t get image ${s3Key} from bucket ${bucket}`); + } + + await fs.writeFile(cachePath, imageBytes); + console.log(`Cached to: ${cachePath}`); + return imageBytes; + } +} + +// Output images are *not* cached, +// because that would defeat the point of the tests +// when the downscaling logic is changed. +export async function writeOutputImage( + imageBytes: Uint8Array, + s3Key: string, + suffix: string = "_output", +): Promise { + const outputPath = getOutputPath(s3Key, suffix); + await fs.writeFile(outputPath, imageBytes); + console.log(`Wrote output to: ${outputPath}`); + return outputPath; +} diff --git a/image-embedder-lambda/docs/native-modules-bundling.md b/image-embedder-lambda/docs/native-modules-bundling.md new file mode 100644 index 00000000000..385e6f4d405 --- /dev/null +++ b/image-embedder-lambda/docs/native-modules-bundling.md @@ -0,0 +1,121 @@ +# Node.js Lambda Native Modules & Bundling + +## The Problem + +Native Node.js modules like `sharp` ship platform-specific prebuilt binaries. When bundling on CI (linux-x64), the native code doesn't work on the Lambda runtime (linux-arm64). + +Error looks like: +``` +Error: Could not load the "sharp" module using the linux-arm64 runtime +``` + +## How Native Modules Work in Node + +- `sharp` itself is just a JavaScript wrapper +- The actual compiled code lives in separate packages: `@img/sharp--` +- These are "optional dependencies" – npm installs whichever ones match your platform +- Sharp also has JS dependencies: `detect-libc`, `semver`, `@img/colour` + +After installing for multiple platforms, `node_modules/@img/` contains: +``` +sharp-darwin-arm64/ # macOS ARM (M-series Macs) +sharp-linux-arm64/ # Linux ARM (Lambda target) +sharp-libvips-linux-arm64/ # libvips library that sharp depends on +...etc +``` + +## Current Solution (build.sh) + +1. Mark `sharp` as external in esbuild (so it doesn't try to inline native code) +2. Install sharp with target platform flags: `npm install --os=linux --cpu=arm64 sharp` +3. Copy sharp + all its dependencies into `dist/node_modules/` +4. Zip the whole `dist/` directory + +The `--external:sharp` flag makes esbuild emit `require("sharp")` unchanged, which Node resolves at runtime from the bundled `node_modules/`. + +## Testing Locally + +Run a linux-arm64 container that mimics Lambda: + +```bash +docker run --rm -it --platform linux/arm64 \ + -v $(pwd)/image-embedder-lambda/dist:/var/task \ + --entrypoint "" \ + public.ecr.aws/lambda/nodejs:20 \ + /bin/bash +``` + +Then inside: +```bash +cd /var/task +node -e "const sharp = require('sharp'); console.log(sharp.versions)" +``` + +To actually invoke the handler: +```bash +docker run --rm -it --platform linux/arm64 \ + -p 9000:8080 \ + -v $(pwd)/image-embedder-lambda/dist:/var/task \ + public.ecr.aws/lambda/nodejs:20 \ + index.handler +``` + +Then curl it: +```bash +curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" \ + -d '{"Records": [{"messageId": "test", "body": "{}"}]}' +``` + +## Alternative: CDK NodejsFunction + +CDK's `NodejsFunction` can handle all of this automatically: + +```typescript +new lambdaNodejs.NodejsFunction(this, "ImageEmbedder", { + bundling: { + externalModules: ["sharp"], + nodeModules: ["sharp"], + commandHooks: { + beforeBundling() { return []; }, + beforeInstall() { return []; }, + afterBundling(_inputDir: string, outputDir: string): string[] { + return [ + `cd ${outputDir}`, + "npm install --os=linux --cpu=arm64 sharp" + ]; + } + } + } +}); +``` + +### When does bundling happen? + +**During `cdk synth`**, not at deploy time: + +1. CDK traverses your construct tree +2. `NodejsFunction` triggers esbuild bundling +3. `commandHooks` run (beforeBundling → bundling → afterBundling) +4. Output goes to `cdk.out/asset.xxxxx/` +5. CloudFormation template references the asset + +Then `cdk deploy` uploads assets to S3 and creates/updates the stack. + +### Why this is nice + +- Single tool handles infrastructure + build +- No separate build scripts to maintain +- Asset hashing ensures deploys only happen when code changes + +### Why we're not using it (yet) + +Don't think anyone else in Guardian P&E is using this approach, so it may break some assumptions of standard Guardian CI/CD, e.g. that the CloudFormation deploy artifact doesn't contain the application code. + +We should definitely try it out at some point to see what the pain points are! + +## References + +- https://sharp.pixelplumbing.com/install#cross-platform +- https://sharp.pixelplumbing.com/install#aws-lambda +- https://github.com/evanw/esbuild/issues/1051 (native .node modules discussion) +- https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda_nodejs-readme.html diff --git a/image-embedder-lambda/jest.config.ts b/image-embedder-lambda/jest.config.ts new file mode 100644 index 00000000000..07c7b8592ee --- /dev/null +++ b/image-embedder-lambda/jest.config.ts @@ -0,0 +1,16 @@ +import type { Config } from "jest"; + +const config: Config = { + preset: "ts-jest", + testEnvironment: "node", + testMatch: ["**/__tests__/**/*.test.ts"], + testPathIgnorePatterns: [ + "/node_modules/", + // Skip integration tests by default, because these call Bedrock for real, + // costing us time, money and throughput + "/__tests__/integration/", + ], + moduleFileExtensions: ["ts", "js", "json"], +}; + +export default config; diff --git a/image-embedder-lambda/jest.integration.config.ts b/image-embedder-lambda/jest.integration.config.ts new file mode 100644 index 00000000000..45a36288c30 --- /dev/null +++ b/image-embedder-lambda/jest.integration.config.ts @@ -0,0 +1,16 @@ +import type { Config } from "jest"; + +const config: Config = { + preset: "ts-jest", + testEnvironment: "node", + testMatch: ["**/__tests__/integration/**/*.test.ts"], + moduleFileExtensions: ["ts", "js", "json"], + // Integration tests may take longer due to real AWS calls + testTimeout: 30000, + // Suppress console.log output when running tests + silent: true, + // Show individual test results + verbose: true, +}; + +export default config; diff --git a/image-embedder-lambda/localRun.ts b/image-embedder-lambda/localRun.ts new file mode 100644 index 00000000000..3f812483746 --- /dev/null +++ b/image-embedder-lambda/localRun.ts @@ -0,0 +1,191 @@ +#!/usr/bin/env ts-node + +import { + SQSClient, + ReceiveMessageCommand, + DeleteMessageCommand, +} from "@aws-sdk/client-sqs"; +import { + CloudFormationClient, + DescribeStackResourcesCommand, + StackResource, +} from "@aws-sdk/client-cloudformation"; +import { Context, SQSEvent } from "aws-lambda"; + +const LOCALSTACK_ENDPOINT = + process.env.LOCALSTACK_ENDPOINT || "http://localhost:4566"; +const QUEUE_URL = + process.env.QUEUE_URL || + "http://localhost:4566/000000000000/image-embedder-DEV"; +const POLL_INTERVAL_MS = parseInt(process.env.POLL_INTERVAL_MS || "5000", 10); + +const sqsClient = new SQSClient({ + region: "eu-west-1", + endpoint: LOCALSTACK_ENDPOINT, + credentials: { + accessKeyId: "test", + secretAccessKey: "test", + }, +}); + +const cfnClient = new CloudFormationClient({ + region: "eu-west-1", + endpoint: LOCALSTACK_ENDPOINT, + credentials: { + accessKeyId: "test", + secretAccessKey: "test", + }, +}); + +async function getStackResource( + stackName: string, + logicalResourceId: string +): Promise { + const response = await cfnClient.send( + new DescribeStackResourcesCommand({ StackName: stackName }) + ); + const resource = response.StackResources?.find( + (r: StackResource) => r.LogicalResourceId === logicalResourceId + ); + if (!resource?.PhysicalResourceId) { + throw new Error( + `Resource ${logicalResourceId} not found in stack ${stackName}` + ); + } + return resource.PhysicalResourceId; +} + +async function main() { + // Fetch bucket name from CloudFormation stack + const downscaledImageBucket = await getStackResource( + "grid-dev-core", + "DownscaledImageBucket" + ); + + // Set all environment variables before importing handler + process.env.AWS_PROFILE = "media-service"; + process.env.IS_LOCAL = "true"; + process.env.LOCALSTACK_ENDPOINT = LOCALSTACK_ENDPOINT; + process.env.DOWNSCALED_IMAGE_BUCKET = downscaledImageBucket; + + // Import handler AFTER setting environment variables + // Use require() because ts-node hooks into require, not dynamic import() + const { handler } = require("./src/index"); + + console.log("Image Embedder Local Runner"); + console.log("============================"); + console.log(`Queue URL: ${QUEUE_URL}`); + console.log(`Localstack Endpoint: ${LOCALSTACK_ENDPOINT}`); + console.log(`Downscaled Image Bucket: ${downscaledImageBucket}`); + console.log(`Poll Interval: ${POLL_INTERVAL_MS}ms`); + console.log(""); + console.log("Waiting for messages..."); + console.log(""); + + let isProcessing = false; + + async function pollQueue(): Promise { + if (isProcessing) { + return; + } + + try { + const command = new ReceiveMessageCommand({ + QueueUrl: QUEUE_URL, + MaxNumberOfMessages: 1, + WaitTimeSeconds: 10, // Long polling + VisibilityTimeout: 60, + }); + + const response = await sqsClient.send(command); + + if (response.Messages && response.Messages.length > 0) { + isProcessing = true; + + for (const message of response.Messages) { + console.log(`[${new Date().toISOString()}] Received message`); + console.log(`Message ID: ${message.MessageId}`); + + try { + // Convert SQS message to Lambda SQS event format + const event: SQSEvent = { + Records: [ + { + messageId: message.MessageId!, + receiptHandle: message.ReceiptHandle!, + body: message.Body!, + attributes: { + ApproximateReceiveCount: "1", + SentTimestamp: Date.now().toString(), + SenderId: "local", + ApproximateFirstReceiveTimestamp: Date.now().toString(), + }, + messageAttributes: {}, + md5OfBody: message.MD5OfBody!, + eventSource: "aws:sqs", + eventSourceARN: `arn:aws:sqs:eu-west-1:000000000000:image-embedder-DEV`, + awsRegion: "eu-west-1", + }, + ], + }; + + // Mock Lambda context + const context: Context = { + callbackWaitsForEmptyEventLoop: true, + functionName: "image-embedder-DEV", + functionVersion: "$LATEST", + invokedFunctionArn: + "arn:aws:lambda:eu-west-1:000000000000:function:image-embedder-DEV", + memoryLimitInMB: "512", + awsRequestId: Math.random().toString(36).substring(7), + logGroupName: "/aws/lambda/image-embedder-DEV", + logStreamName: new Date().toISOString(), + getRemainingTimeInMillis: () => 60000, + done: () => {}, + fail: () => {}, + succeed: () => {}, + }; + + console.log("Invoking lambda handler..."); + await handler(event, context); + console.log("✓ Lambda handler completed successfully"); + + // Delete message from queue on success + await sqsClient.send( + new DeleteMessageCommand({ + QueueUrl: QUEUE_URL, + ReceiptHandle: message.ReceiptHandle, + }) + ); + console.log("✓ Message deleted from queue"); + } catch (error) { + console.error("✗ Lambda handler failed:", error); + console.error("Message will be retried or sent to DLQ"); + } + + console.log(""); + } + + isProcessing = false; + } + } catch (error) { + console.error("Error polling queue:", error); + isProcessing = false; + } + } + + // Start polling + setInterval(pollQueue, POLL_INTERVAL_MS); + pollQueue(); // Start immediately +} + +main().catch((error) => { + console.error("Failed to start local runner:", error); + process.exit(1); +}); + +// Handle graceful shutdown +process.on("SIGINT", () => { + console.log("\nShutting down..."); + process.exit(0); +}); diff --git a/image-embedder-lambda/package-lock.json b/image-embedder-lambda/package-lock.json new file mode 100644 index 00000000000..5adac152928 --- /dev/null +++ b/image-embedder-lambda/package-lock.json @@ -0,0 +1,12488 @@ +{ + "name": "image-embedder-lambda", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "image-embedder-lambda", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@aws-sdk/client-bedrock-runtime": "^3.971.0", + "@aws-sdk/client-s3": "^3.971.0", + "@aws-sdk/client-s3vectors": "^3.971.0", + "@aws-sdk/client-sqs": "^3.948.0", + "package-lock.json": "^1.0.0", + "sharp": "^0.34.5" + }, + "devDependencies": { + "@aws-sdk/client-cloudformation": "^3.981.0", + "@types/aws-lambda": "^8.10.159", + "@types/jest": "^30.0.0", + "@types/node": "^25.0.0", + "esbuild": "0.27.0", + "jest": "^30.2.0", + "npm-run-all": "^4.1.5", + "ts-jest": "^29.4.6", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.971.0.tgz", + "integrity": "sha512-W5c454+PPeN67yKicYkGphzWS/X395Q9DSliQP2ziQekgfd+ESBz54yKzoi/dq8KQoQTGztVzHuP1DR6cIhE9w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.970.0", + "@aws-sdk/credential-provider-node": "3.971.0", + "@aws-sdk/eventstream-handler-node": "3.971.0", + "@aws-sdk/middleware-eventstream": "3.969.0", + "@aws-sdk/middleware-host-header": "3.969.0", + "@aws-sdk/middleware-logger": "3.969.0", + "@aws-sdk/middleware-recursion-detection": "3.969.0", + "@aws-sdk/middleware-user-agent": "3.970.0", + "@aws-sdk/middleware-websocket": "3.971.0", + "@aws-sdk/region-config-resolver": "3.969.0", + "@aws-sdk/token-providers": "3.971.0", + "@aws-sdk/types": "3.969.0", + "@aws-sdk/util-endpoints": "3.970.0", + "@aws-sdk/util-user-agent-browser": "3.969.0", + "@aws-sdk/util-user-agent-node": "3.971.0", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.20.6", + "@smithy/eventstream-serde-browser": "^4.2.8", + "@smithy/eventstream-serde-config-resolver": "^4.3.8", + "@smithy/eventstream-serde-node": "^4.2.8", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.7", + "@smithy/middleware-retry": "^4.4.23", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.22", + "@smithy/util-defaults-mode-node": "^4.2.25", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation": { + "version": "3.981.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudformation/-/client-cloudformation-3.981.0.tgz", + "integrity": "sha512-HTIQGNjcB58v3XisyobG8G7oRwUEdlbNJKV6rvMUPXxaJEhd1gGq6EVOJQk/kenu9Axl8J66Eo3rvL+rVBUK1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.5", + "@aws-sdk/credential-provider-node": "^3.972.4", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.5", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.981.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.3", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.22.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.12", + "@smithy/middleware-retry": "^4.4.29", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.28", + "@smithy/util-defaults-mode-node": "^4.2.31", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/client-sso": { + "version": "3.980.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.980.0.tgz", + "integrity": "sha512-AhNXQaJ46C1I+lQ+6Kj+L24il5K9lqqIanJd8lMszPmP7bLnmX0wTKK0dxywcvrLdij3zhWttjAKEBNgLtS8/A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.5", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.5", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.980.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.3", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.22.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.12", + "@smithy/middleware-retry": "^4.4.29", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.28", + "@smithy/util-defaults-mode-node": "^4.2.31", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/client-sso/node_modules/@aws-sdk/util-endpoints": { + "version": "3.980.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.980.0.tgz", + "integrity": "sha512-AjKBNEc+rjOZQE1HwcD9aCELqg1GmUj1rtICKuY8cgwB73xJ4U/kNyqKKpN2k9emGqlfDY2D8itIp/vDc6OKpw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/core": { + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.5.tgz", + "integrity": "sha512-IMM7xGfLGW6lMvubsA4j6BHU5FPgGAxoQ/NA63KqNLMwTS+PeMBcx8DPHL12Vg6yqOZnqok9Mu4H2BdQyq7gSA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/xml-builder": "^3.972.2", + "@smithy/core": "^3.22.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.11.1", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.3.tgz", + "integrity": "sha512-OBYNY4xQPq7Rx+oOhtyuyO0AQvdJSpXRg7JuPNBJH4a1XXIzJQl4UHQTPKZKwfJXmYLpv4+OkcFen4LYmDPd3g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.5", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.5.tgz", + "integrity": "sha512-GpvBgEmSZPvlDekd26Zi+XsI27Qz7y0utUx0g2fSTSiDzhnd1FSa1owuodxR0BcUKNL7U2cOVhhDxgZ4iSoPVg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.5", + "@aws-sdk/types": "^3.973.1", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.1", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.10", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.3.tgz", + "integrity": "sha512-rMQAIxstP7cLgYfsRGrGOlpyMl0l8JL2mcke3dsIPLWke05zKOFyR7yoJzWCsI/QiIxjRbxpvPiAeKEA6CoYkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.5", + "@aws-sdk/credential-provider-env": "^3.972.3", + "@aws-sdk/credential-provider-http": "^3.972.5", + "@aws-sdk/credential-provider-login": "^3.972.3", + "@aws-sdk/credential-provider-process": "^3.972.3", + "@aws-sdk/credential-provider-sso": "^3.972.3", + "@aws-sdk/credential-provider-web-identity": "^3.972.3", + "@aws-sdk/nested-clients": "3.980.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.3.tgz", + "integrity": "sha512-Gc3O91iVvA47kp2CLIXOwuo5ffo1cIpmmyIewcYjAcvurdFHQ8YdcBe1KHidnbbBO4/ZtywGBACsAX5vr3UdoA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.5", + "@aws-sdk/nested-clients": "3.980.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.4.tgz", + "integrity": "sha512-UwerdzosMSY7V5oIZm3NsMDZPv2aSVzSkZxYxIOWHBeKTZlUqW7XpHtJMZ4PZpJ+HMRhgP+MDGQx4THndgqJfQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.3", + "@aws-sdk/credential-provider-http": "^3.972.5", + "@aws-sdk/credential-provider-ini": "^3.972.3", + "@aws-sdk/credential-provider-process": "^3.972.3", + "@aws-sdk/credential-provider-sso": "^3.972.3", + "@aws-sdk/credential-provider-web-identity": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.3.tgz", + "integrity": "sha512-xkSY7zjRqeVc6TXK2xr3z1bTLm0wD8cj3lAkproRGaO4Ku7dPlKy843YKnHrUOUzOnMezdZ4xtmFc0eKIDTo2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.5", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.3.tgz", + "integrity": "sha512-8Ww3F5Ngk8dZ6JPL/V5LhCU1BwMfQd3tLdoEuzaewX8FdnT633tPr+KTHySz9FK7fFPcz5qG3R5edVEhWQD4AA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.980.0", + "@aws-sdk/core": "^3.973.5", + "@aws-sdk/token-providers": "3.980.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.3.tgz", + "integrity": "sha512-62VufdcH5rRfiRKZRcf1wVbbt/1jAntMj1+J0qAd+r5pQRg2t0/P9/Rz16B1o5/0Se9lVL506LRjrhIJAhYBfA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.5", + "@aws-sdk/nested-clients": "3.980.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.3.tgz", + "integrity": "sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.3.tgz", + "integrity": "sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.3.tgz", + "integrity": "sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.5.tgz", + "integrity": "sha512-TVZQ6PWPwQbahUI8V+Er+gS41ctIawcI/uMNmQtQ7RMcg3JYn6gyKAFKUb3HFYx2OjYlx1u11sETSwwEUxVHTg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.5", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.980.0", + "@smithy/core": "^3.22.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/middleware-user-agent/node_modules/@aws-sdk/util-endpoints": { + "version": "3.980.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.980.0.tgz", + "integrity": "sha512-AjKBNEc+rjOZQE1HwcD9aCELqg1GmUj1rtICKuY8cgwB73xJ4U/kNyqKKpN2k9emGqlfDY2D8itIp/vDc6OKpw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/nested-clients": { + "version": "3.980.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.980.0.tgz", + "integrity": "sha512-/dONY5xc5/CCKzOqHZCTidtAR4lJXWkGefXvTRKdSKMGaYbbKsxDckisd6GfnvPSLxWtvQzwgRGRutMRoYUApQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.5", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.5", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.980.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.3", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.22.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.12", + "@smithy/middleware-retry": "^4.4.29", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.28", + "@smithy/util-defaults-mode-node": "^4.2.31", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/util-endpoints": { + "version": "3.980.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.980.0.tgz", + "integrity": "sha512-AjKBNEc+rjOZQE1HwcD9aCELqg1GmUj1rtICKuY8cgwB73xJ4U/kNyqKKpN2k9emGqlfDY2D8itIp/vDc6OKpw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.3.tgz", + "integrity": "sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/token-providers": { + "version": "3.980.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.980.0.tgz", + "integrity": "sha512-1nFileg1wAgDmieRoj9dOawgr2hhlh7xdvcH57b1NnqfPaVlcqVJyPc6k3TLDUFPY69eEwNxdGue/0wIz58vjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.5", + "@aws-sdk/nested-clients": "3.980.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/types": { + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.1.tgz", + "integrity": "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/util-endpoints": { + "version": "3.981.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.981.0.tgz", + "integrity": "sha512-a8nXh/H3/4j+sxhZk+N3acSDlgwTVSZbX9i55dx41gI1H+geuonuRG+Shv3GZsCb46vzc08RK2qC78ypO8uRlg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.3.tgz", + "integrity": "sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.3.tgz", + "integrity": "sha512-gqG+02/lXQtO0j3US6EVnxtwwoXQC5l2qkhLCrqUrqdtcQxV7FDMbm9wLjKqoronSHyELGTjbFKK/xV5q1bZNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.5", + "@aws-sdk/types": "^3.973.1", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.3.tgz", + "integrity": "sha512-bCk63RsBNCWW4tt5atv5Sbrh+3J3e8YzgyF6aZb1JeXcdzG4k5SlPLeTMFOIXFuuFHIwgphUhn4i3uS/q49eww==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "fast-xml-parser": "5.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation/node_modules/fast-xml-parser": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", + "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.971.0.tgz", + "integrity": "sha512-BBUne390fKa4C4QvZlUZ5gKcu+Uyid4IyQ20N4jl0vS7SK2xpfXlJcgKqPW5ts6kx6hWTQBk6sH5Lf12RvuJxg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.970.0", + "@aws-sdk/credential-provider-node": "3.971.0", + "@aws-sdk/middleware-bucket-endpoint": "3.969.0", + "@aws-sdk/middleware-expect-continue": "3.969.0", + "@aws-sdk/middleware-flexible-checksums": "3.971.0", + "@aws-sdk/middleware-host-header": "3.969.0", + "@aws-sdk/middleware-location-constraint": "3.969.0", + "@aws-sdk/middleware-logger": "3.969.0", + "@aws-sdk/middleware-recursion-detection": "3.969.0", + "@aws-sdk/middleware-sdk-s3": "3.970.0", + "@aws-sdk/middleware-ssec": "3.971.0", + "@aws-sdk/middleware-user-agent": "3.970.0", + "@aws-sdk/region-config-resolver": "3.969.0", + "@aws-sdk/signature-v4-multi-region": "3.970.0", + "@aws-sdk/types": "3.969.0", + "@aws-sdk/util-endpoints": "3.970.0", + "@aws-sdk/util-user-agent-browser": "3.969.0", + "@aws-sdk/util-user-agent-node": "3.971.0", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.20.6", + "@smithy/eventstream-serde-browser": "^4.2.8", + "@smithy/eventstream-serde-config-resolver": "^4.3.8", + "@smithy/eventstream-serde-node": "^4.2.8", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-blob-browser": "^4.2.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/hash-stream-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/md5-js": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.7", + "@smithy/middleware-retry": "^4.4.23", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.22", + "@smithy/util-defaults-mode-node": "^4.2.25", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3vectors": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3vectors/-/client-s3vectors-3.971.0.tgz", + "integrity": "sha512-/KmN8qUGPG2pCnLWa6a7bHt5svH/F6GcX6eAP48fT4S0Q+xJFLhENJrefaDvoqM9/d2jC/OMU5NemlT4uS5f9Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.970.0", + "@aws-sdk/credential-provider-node": "3.971.0", + "@aws-sdk/middleware-host-header": "3.969.0", + "@aws-sdk/middleware-logger": "3.969.0", + "@aws-sdk/middleware-recursion-detection": "3.969.0", + "@aws-sdk/middleware-user-agent": "3.970.0", + "@aws-sdk/region-config-resolver": "3.969.0", + "@aws-sdk/types": "3.969.0", + "@aws-sdk/util-endpoints": "3.970.0", + "@aws-sdk/util-user-agent-browser": "3.969.0", + "@aws-sdk/util-user-agent-node": "3.971.0", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.20.6", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.7", + "@smithy/middleware-retry": "^4.4.23", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.22", + "@smithy/util-defaults-mode-node": "^4.2.25", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs": { + "version": "3.975.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.975.0.tgz", + "integrity": "sha512-6KS8T24LkEp2QZ/529SKVHQTgvCDUWXE8NtrILUBxZ9e3BiprjC9JSEdMqgh82BUD8s8yv4nnoa4Faiz7lRFpw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.1", + "@aws-sdk/credential-provider-node": "^3.972.1", + "@aws-sdk/middleware-host-header": "^3.972.1", + "@aws-sdk/middleware-logger": "^3.972.1", + "@aws-sdk/middleware-recursion-detection": "^3.972.1", + "@aws-sdk/middleware-sdk-sqs": "^3.972.2", + "@aws-sdk/middleware-user-agent": "^3.972.2", + "@aws-sdk/region-config-resolver": "^3.972.1", + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/util-endpoints": "3.972.0", + "@aws-sdk/util-user-agent-browser": "^3.972.1", + "@aws-sdk/util-user-agent-node": "^3.972.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.21.1", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/md5-js": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.11", + "@smithy/middleware-retry": "^4.4.27", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.26", + "@smithy/util-defaults-mode-node": "^4.2.29", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/client-sso": { + "version": "3.975.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.975.0.tgz", + "integrity": "sha512-HpgJuleH7P6uILxzJKQOmlHdwaCY+xYC6VgRDzlwVEqU/HXjo4m2gOAyjUbpXlBOCWfGgMUzfBlNJ9z3MboqEQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.1", + "@aws-sdk/middleware-host-header": "^3.972.1", + "@aws-sdk/middleware-logger": "^3.972.1", + "@aws-sdk/middleware-recursion-detection": "^3.972.1", + "@aws-sdk/middleware-user-agent": "^3.972.2", + "@aws-sdk/region-config-resolver": "^3.972.1", + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/util-endpoints": "3.972.0", + "@aws-sdk/util-user-agent-browser": "^3.972.1", + "@aws-sdk/util-user-agent-node": "^3.972.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.21.1", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.11", + "@smithy/middleware-retry": "^4.4.27", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.26", + "@smithy/util-defaults-mode-node": "^4.2.29", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/core": { + "version": "3.973.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.2.tgz", + "integrity": "sha512-XwOjX86CNtmhO/Tx2vmNt1tT1yda045LXVm453w9crrkl7oyDEWV3ASg2xNi6hCPWbx1BXtLKJduQiGZnmHXrg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/xml-builder": "^3.972.2", + "@smithy/core": "^3.21.1", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.2.tgz", + "integrity": "sha512-wzH1EdrZsytG1xN9UHaK12J9+kfrnd2+c8y0LVoS4O4laEjPoie1qVK3k8/rZe7KOtvULzyMnO3FT4Krr9Z0Dg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.2", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.3.tgz", + "integrity": "sha512-IbBGWhaxiEl64fznwh5PDEB0N7YJEAvK5b6nRtPVUKdKAHlOPgo6B9XB8mqWDs8Ct0oF/E34ZLiq2U0L5xDkrg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.2", + "@aws-sdk/types": "^3.973.1", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.10", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.2.tgz", + "integrity": "sha512-Jrb8sLm6k8+L7520irBrvCtdLxNtrG7arIxe9TCeMJt/HxqMGJdbIjw8wILzkEHLMIi4MecF2FbXCln7OT1Tag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.2", + "@aws-sdk/credential-provider-env": "^3.972.2", + "@aws-sdk/credential-provider-http": "^3.972.3", + "@aws-sdk/credential-provider-login": "^3.972.2", + "@aws-sdk/credential-provider-process": "^3.972.2", + "@aws-sdk/credential-provider-sso": "^3.972.2", + "@aws-sdk/credential-provider-web-identity": "^3.972.2", + "@aws-sdk/nested-clients": "3.975.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.2.tgz", + "integrity": "sha512-mlaw2aiI3DrimW85ZMn3g7qrtHueidS58IGytZ+mbFpsYLK5wMjCAKZQtt7VatLMtSBG/dn/EY4njbnYXIDKeQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.2", + "@aws-sdk/nested-clients": "3.975.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.2.tgz", + "integrity": "sha512-Lz1J5IZdTjLYTVIcDP5DVDgi1xlgsF3p1cnvmbfKbjCRhQpftN2e2J4NFfRRvPD54W9+bZ8l5VipPXtTYK7aEg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.2", + "@aws-sdk/credential-provider-http": "^3.972.3", + "@aws-sdk/credential-provider-ini": "^3.972.2", + "@aws-sdk/credential-provider-process": "^3.972.2", + "@aws-sdk/credential-provider-sso": "^3.972.2", + "@aws-sdk/credential-provider-web-identity": "^3.972.2", + "@aws-sdk/types": "^3.973.1", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.2.tgz", + "integrity": "sha512-NLKLTT7jnUe9GpQAVkPTJO+cs2FjlQDt5fArIYS7h/Iw/CvamzgGYGFRVD2SE05nOHCMwafUSi42If8esGFV+g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.2", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.2.tgz", + "integrity": "sha512-YpwDn8g3gCGUl61cCV0sRxP2pFIwg+ZsMfWQ/GalSyjXtRkctCMFA+u0yPb/Q4uTfNEiya1Y4nm0C5rIHyPW5Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.975.0", + "@aws-sdk/core": "^3.973.2", + "@aws-sdk/token-providers": "3.975.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.2.tgz", + "integrity": "sha512-x9DAiN9Qz+NjJ99ltDiVQ8d511M/tuF/9MFbe2jUgo7HZhD6+x4S3iT1YcP07ndwDUjmzKGmeOEgE24k4qvfdg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.2", + "@aws-sdk/nested-clients": "3.975.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.2.tgz", + "integrity": "sha512-42hZ8jEXT2uR6YybCzNq9OomqHPw43YIfRfz17biZjMQA4jKSQUaHIl6VvqO2Ddl5904pXg2Yd/ku78S0Ikgog==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.2.tgz", + "integrity": "sha512-iUzdXKOgi4JVDDEG/VvoNw50FryRCEm0qAudw12DcZoiNJWl0rN6SYVLcL1xwugMfQncCXieK5UBlG6mhH7iYA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.2.tgz", + "integrity": "sha512-/mzlyzJDtngNFd/rAYvqx29a2d0VuiYKN84Y/Mu9mGw7cfMOCyRK+896tb9wV6MoPRHUX7IXuKCIL8nzz2Pz5A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.3.tgz", + "integrity": "sha512-zq6aTiO/BiAIOA8EH8nB+wYvvnZ14Md9Gomm5DDhParshVEVglAyNPO5ADK4ZXFQbftIoO+Vgcvf4gewW/+iYQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.2", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.972.0", + "@smithy/core": "^3.21.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/nested-clients": { + "version": "3.975.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.975.0.tgz", + "integrity": "sha512-OkeFHPlQj2c/Y5bQGkX14pxhDWUGUFt3LRHhjcDKsSCw6lrxKcxN3WFZN0qbJwKNydP+knL5nxvfgKiCLpTLRA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.1", + "@aws-sdk/middleware-host-header": "^3.972.1", + "@aws-sdk/middleware-logger": "^3.972.1", + "@aws-sdk/middleware-recursion-detection": "^3.972.1", + "@aws-sdk/middleware-user-agent": "^3.972.2", + "@aws-sdk/region-config-resolver": "^3.972.1", + "@aws-sdk/types": "^3.973.0", + "@aws-sdk/util-endpoints": "3.972.0", + "@aws-sdk/util-user-agent-browser": "^3.972.1", + "@aws-sdk/util-user-agent-node": "^3.972.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.21.1", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.11", + "@smithy/middleware-retry": "^4.4.27", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.26", + "@smithy/util-defaults-mode-node": "^4.2.29", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.2.tgz", + "integrity": "sha512-/7vRBsfmiOlg2X67EdKrzzQGw5/SbkXb7ALHQmlQLkZh8qNgvS2G2dDC6NtF3hzFlpP3j2k+KIEtql/6VrI6JA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/token-providers": { + "version": "3.975.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.975.0.tgz", + "integrity": "sha512-AWQt64hkVbDQ+CmM09wnvSk2mVyH4iRROkmYkr3/lmUtFNbE2L/fnw26sckZnUcFCsHPqbkQrcsZAnTcBLbH4w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.1", + "@aws-sdk/nested-clients": "3.975.0", + "@aws-sdk/types": "^3.973.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/types": { + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.1.tgz", + "integrity": "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/util-endpoints": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.972.0.tgz", + "integrity": "sha512-6JHsl1V/a1ZW8D8AFfd4R52fwZPnZ5H4U6DS8m/bWT8qad72NvbOFAC7U2cDtFs2TShqUO3TEiX/EJibtY3ijg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.972.0", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/util-endpoints/node_modules/@aws-sdk/types": { + "version": "3.972.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.972.0.tgz", + "integrity": "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.2.tgz", + "integrity": "sha512-gz76bUyebPZRxIsBHJUd/v+yiyFzm9adHbr8NykP2nm+z/rFyvQneOHajrUejtmnc5tTBeaDPL4X25TnagRk4A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.2.tgz", + "integrity": "sha512-vnxOc4C6AR7hVbwyFo1YuH0GB6dgJlWt8nIOOJpnzJAWJPkUMPJ9Zv2lnKsSU7TTZbhP2hEO8OZ4PYH59XFv8Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.2.tgz", + "integrity": "sha512-jGOOV/bV1DhkkUhHiZ3/1GZ67cZyOXaDb7d1rYD6ZiXf5V9tBNOcgqXwRRPvrCbYaFRa1pPMFb3ZjqjWpR3YfA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.971.0.tgz", + "integrity": "sha512-Xx+w6DQqJxDdymYyIxyKJnRzPvVJ4e/Aw0czO7aC9L/iraaV7AG8QtRe93OGW6aoHSh72CIiinnpJJfLsQqP4g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.970.0", + "@aws-sdk/middleware-host-header": "3.969.0", + "@aws-sdk/middleware-logger": "3.969.0", + "@aws-sdk/middleware-recursion-detection": "3.969.0", + "@aws-sdk/middleware-user-agent": "3.970.0", + "@aws-sdk/region-config-resolver": "3.969.0", + "@aws-sdk/types": "3.969.0", + "@aws-sdk/util-endpoints": "3.970.0", + "@aws-sdk/util-user-agent-browser": "3.969.0", + "@aws-sdk/util-user-agent-node": "3.971.0", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.20.6", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.7", + "@smithy/middleware-retry": "^4.4.23", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.22", + "@smithy/util-defaults-mode-node": "^4.2.25", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.970.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.970.0.tgz", + "integrity": "sha512-klpzObldOq8HXzDjDlY6K8rMhYZU6mXRz6P9F9N+tWnjoYFfeBMra8wYApydElTUYQKP1O7RLHwH1OKFfKcqIA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@aws-sdk/xml-builder": "3.969.0", + "@smithy/core": "^3.20.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.969.0.tgz", + "integrity": "sha512-IGNkP54HD3uuLnrPCYsv3ZD478UYq+9WwKrIVJ9Pdi3hxPg8562CH3ZHf8hEgfePN31P9Kj+Zu9kq2Qcjjt61A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.970.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.970.0.tgz", + "integrity": "sha512-rtVzXzEtAfZBfh+lq3DAvRar4c3jyptweOAJR2DweyXx71QSMY+O879hjpMwES7jl07a3O1zlnFIDo4KP/96kQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.970.0", + "@aws-sdk/types": "3.969.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.970.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.970.0.tgz", + "integrity": "sha512-CjDbWL7JxjLc9ZxQilMusWSw05yRvUJKRpz59IxDpWUnSMHC9JMMUUkOy5Izk8UAtzi6gupRWArp4NG4labt9Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.970.0", + "@aws-sdk/types": "3.969.0", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.10", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.971.0.tgz", + "integrity": "sha512-c0TGJG4xyfTZz3SInXfGU8i5iOFRrLmy4Bo7lMyH+IpngohYMYGYl61omXqf2zdwMbDv+YJ9AviQTcCaEUKi8w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.970.0", + "@aws-sdk/credential-provider-env": "3.970.0", + "@aws-sdk/credential-provider-http": "3.970.0", + "@aws-sdk/credential-provider-login": "3.971.0", + "@aws-sdk/credential-provider-process": "3.970.0", + "@aws-sdk/credential-provider-sso": "3.971.0", + "@aws-sdk/credential-provider-web-identity": "3.971.0", + "@aws-sdk/nested-clients": "3.971.0", + "@aws-sdk/types": "3.969.0", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.971.0.tgz", + "integrity": "sha512-yhbzmDOsk0RXD3rTPhZra4AWVnVAC4nFWbTp+sUty1hrOPurUmhuz8bjpLqYTHGnlMbJp+UqkQONhS2+2LzW2g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.970.0", + "@aws-sdk/nested-clients": "3.971.0", + "@aws-sdk/types": "3.969.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.971.0.tgz", + "integrity": "sha512-epUJBAKivtJqalnEBRsYIULKYV063o/5mXNJshZfyvkAgNIzc27CmmKRXTN4zaNOZg8g/UprFp25BGsi19x3nQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.970.0", + "@aws-sdk/credential-provider-http": "3.970.0", + "@aws-sdk/credential-provider-ini": "3.971.0", + "@aws-sdk/credential-provider-process": "3.970.0", + "@aws-sdk/credential-provider-sso": "3.971.0", + "@aws-sdk/credential-provider-web-identity": "3.971.0", + "@aws-sdk/types": "3.969.0", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.970.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.970.0.tgz", + "integrity": "sha512-0XeT8OaT9iMA62DFV9+m6mZfJhrD0WNKf4IvsIpj2Z7XbaYfz3CoDDvNoALf3rPY9NzyMHgDxOspmqdvXP00mw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.970.0", + "@aws-sdk/types": "3.969.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.971.0.tgz", + "integrity": "sha512-dY0hMQ7dLVPQNJ8GyqXADxa9w5wNfmukgQniLxGVn+dMRx3YLViMp5ZpTSQpFhCWNF0oKQrYAI5cHhUJU1hETw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.971.0", + "@aws-sdk/core": "3.970.0", + "@aws-sdk/token-providers": "3.971.0", + "@aws-sdk/types": "3.969.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.971.0.tgz", + "integrity": "sha512-F1AwfNLr7H52T640LNON/h34YDiMuIqW/ZreGzhRR6vnFGaSPtNSKAKB2ssAMkLM8EVg8MjEAYD3NCUiEo+t/w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.970.0", + "@aws-sdk/nested-clients": "3.971.0", + "@aws-sdk/types": "3.969.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.971.0.tgz", + "integrity": "sha512-odU6det/GlQ6CwRjjZggcrkIc2sqH2kF6d6nHuFDowPDwbsFrNNssMTQatKqJ+N6XXL7ylN429VZ898uzsBLTA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/eventstream-codec": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.969.0.tgz", + "integrity": "sha512-MlbrlixtkTVhYhoasblKOkr7n2yydvUZjjxTnBhIuHmkyBS1619oGnTfq/uLeGYb4NYXdeQ5OYcqsRGvmWSuTw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@aws-sdk/util-arn-parser": "3.968.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.969.0.tgz", + "integrity": "sha512-gLWuRGZUR4gM5tAAII4Z6zga5wOXWhLSkZLdC2i/K30GlfjJhiXyJodOyAHezxWm8WjUxP9anJq7vlOIQwVKYw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.969.0.tgz", + "integrity": "sha512-qXygzSi8osok7tH9oeuS3HoKw6jRfbvg5Me/X5RlHOvSSqQz8c5O9f3MjUApaCUSwbAU92KrbZWasw2PKiaVHg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.971.0.tgz", + "integrity": "sha512-+hGUDUxeIw8s2kkjfeXym0XZxdh0cqkHkDpEanWYdS1gnWkIR+gf9u/DKbKqGHXILPaqHXhWpLTQTVlaB4sI7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "3.970.0", + "@aws-sdk/crc64-nvme": "3.969.0", + "@aws-sdk/types": "3.969.0", + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.969.0.tgz", + "integrity": "sha512-AWa4rVsAfBR4xqm7pybQ8sUNJYnjyP/bJjfAw34qPuh3M9XrfGbAHG0aiAfQGrBnmS28jlO6Kz69o+c6PRw1dw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.969.0.tgz", + "integrity": "sha512-zH7pDfMLG/C4GWMOpvJEoYcSpj7XsNP9+irlgqwi667sUQ6doHQJ3yyDut3yiTk0maq1VgmriPFELyI9lrvH/g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.969.0.tgz", + "integrity": "sha512-xwrxfip7Y2iTtCMJ+iifN1E1XMOuhxIHY9DreMCvgdl4r7+48x2S1bCYPWH3eNY85/7CapBWdJ8cerpEl12sQQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.969.0.tgz", + "integrity": "sha512-2r3PuNquU3CcS1Am4vn/KHFwLi8QFjMdA/R+CRDXT4AFO/0qxevF/YStW3gAKntQIgWgQV8ZdEtKAoJvLI4UWg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.970.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.970.0.tgz", + "integrity": "sha512-v/Y5F1lbFFY7vMeG5yYxuhnn0CAshz6KMxkz1pDyPxejNE9HtA0w8R6OTBh/bVdIm44QpjhbI7qeLdOE/PLzXQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.970.0", + "@aws-sdk/types": "3.969.0", + "@aws-sdk/util-arn-parser": "3.968.0", + "@smithy/core": "^3.20.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-sqs": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.3.tgz", + "integrity": "sha512-cf7aig6bH4IrlI8pKl3QMQ9SlaEoLSEBf+3Gm+d32kwNuwrNH4jbORKiXfTwunDtmcU5WAMe041u8VEW/xNXIA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/smithy-client": "^4.10.12", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-sqs/node_modules/@aws-sdk/types": { + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.1.tgz", + "integrity": "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.971.0.tgz", + "integrity": "sha512-QGVhvRveYG64ZhnS/b971PxXM6N2NU79Fxck4EfQ7am8v1Br0ctoeDDAn9nXNblLGw87we9Z65F7hMxxiFHd3w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.970.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.970.0.tgz", + "integrity": "sha512-dnSJGGUGSFGEX2NzvjwSefH+hmZQ347AwbLhAsi0cdnISSge+pcGfOFrJt2XfBIypwFe27chQhlfuf/gWdzpZg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.970.0", + "@aws-sdk/types": "3.969.0", + "@aws-sdk/util-endpoints": "3.970.0", + "@smithy/core": "^3.20.6", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.971.0.tgz", + "integrity": "sha512-6xsYfJ2kFa8RucaiSEB6F9rhh8mv0xEc7dfOX5lED2HRAPDWTqODKKqJprtCdyYDmT8ICrTZSkensfTsJQU+DQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@aws-sdk/util-format-url": "3.969.0", + "@smithy/eventstream-codec": "^4.2.8", + "@smithy/eventstream-serde-browser": "^4.2.8", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.971.0.tgz", + "integrity": "sha512-TWaILL8GyYlhGrxxnmbkazM4QsXatwQgoWUvo251FXmUOsiXDFDVX3hoGIfB3CaJhV2pJPfebHUNJtY6TjZ11g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.970.0", + "@aws-sdk/middleware-host-header": "3.969.0", + "@aws-sdk/middleware-logger": "3.969.0", + "@aws-sdk/middleware-recursion-detection": "3.969.0", + "@aws-sdk/middleware-user-agent": "3.970.0", + "@aws-sdk/region-config-resolver": "3.969.0", + "@aws-sdk/types": "3.969.0", + "@aws-sdk/util-endpoints": "3.970.0", + "@aws-sdk/util-user-agent-browser": "3.969.0", + "@aws-sdk/util-user-agent-node": "3.971.0", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.20.6", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.7", + "@smithy/middleware-retry": "^4.4.23", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.10.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.22", + "@smithy/util-defaults-mode-node": "^4.2.25", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.969.0.tgz", + "integrity": "sha512-scj9OXqKpcjJ4jsFLtqYWz3IaNvNOQTFFvEY8XMJXTv+3qF5I7/x9SJtKzTRJEBF3spjzBUYPtGFbs9sj4fisQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/config-resolver": "^4.4.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.970.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.970.0.tgz", + "integrity": "sha512-z3syXfuK/x/IsKf/AeYmgc2NT7fcJ+3fHaGO+fkghkV9WEba3fPyOwtTBX4KpFMNb2t50zDGZwbzW1/5ighcUQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.970.0", + "@aws-sdk/types": "3.969.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.971.0.tgz", + "integrity": "sha512-4hKGWZbmuDdONMJV0HJ+9jwTDb0zLfKxcCLx2GEnBY31Gt9GeyIQ+DZ97Bb++0voawj6pnZToFikXTyrEq2x+w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.970.0", + "@aws-sdk/nested-clients": "3.971.0", + "@aws-sdk/types": "3.969.0", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.969.0.tgz", + "integrity": "sha512-7IIzM5TdiXn+VtgPdVLjmE6uUBUtnga0f4RiSEI1WW10RPuNvZ9U+pL3SwDiRDAdoGrOF9tSLJOFZmfuwYuVYQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.968.0.tgz", + "integrity": "sha512-gqqvYcitIIM2K4lrDX9de9YvOfXBcVdxfT/iLnvHJd4YHvSXlt+gs+AsL4FfPCxG4IG9A+FyulP9Sb1MEA75vw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.970.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.970.0.tgz", + "integrity": "sha512-TZNZqFcMUtjvhZoZRtpEGQAdULYiy6rcGiXAbLU7e9LSpIYlRqpLa207oMNfgbzlL2PnHko+eVg8rajDiSOYCg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.969.0.tgz", + "integrity": "sha512-C7ZiE8orcrEF9In+XDlIKrZhMjp0HCPUH6u74pgadE3T2LRre5TmOQcTt785/wVS2G0we9cxkjlzMrfDsfPvFw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.893.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.893.0.tgz", + "integrity": "sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.969.0.tgz", + "integrity": "sha512-bpJGjuKmFr0rA6UKUCmN8D19HQFMLXMx5hKBXqBlPFdalMhxJSjcxzX9DbQh0Fn6bJtxCguFmRGOBdQqNOt49g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.969.0", + "@smithy/types": "^4.12.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.971.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.971.0.tgz", + "integrity": "sha512-Eygjo9mFzQYjbGY3MYO6CsIhnTwAMd3WmuFalCykqEmj2r5zf0leWrhPaqvA5P68V5JdGfPYgj7vhNOd6CtRBQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.970.0", + "@aws-sdk/types": "3.969.0", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.969.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.969.0.tgz", + "integrity": "sha512-BSe4Lx/qdRQQdX8cSSI7Et20vqBspzAjBy8ZmXVoyLkol3y4sXBXzn+BiLtR+oh60ExQn6o2DU4QjdOZbXaKIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", + "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", + "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/console/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/console/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/console/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/console/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/console/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/core": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", + "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.2.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.2.0", + "jest-config": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-resolve-dependencies": "30.2.0", + "jest-runner": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "jest-watcher": "30.2.0", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/core/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/core/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/core/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/core/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/core/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/core/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.2.0", + "jest-snapshot": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", + "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/types": "30.2.0", + "jest-mock": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", + "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/reporters/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/reporters/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/reporters/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/reporters/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/reporters/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", + "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/snapshot-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/snapshot-utils/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/snapshot-utils/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/snapshot-utils/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/snapshot-utils/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", + "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.2.0", + "@jest/types": "30.2.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", + "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", + "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "micromatch": "^4.0.8", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/transform/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/transform/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/transform/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/transform/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/transform/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/types/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/types/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@jest/types/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/types/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/types/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.47", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz", + "integrity": "sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", + "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz", + "integrity": "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz", + "integrity": "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz", + "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.22.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.22.1.tgz", + "integrity": "sha512-x3ie6Crr58MWrm4viHqqy2Du2rHYZjwu8BekasrQx4ca+Y24dzVAwq3yErdqIbc2G3I0kLQA13PQ+/rde+u65g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.9", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.11", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz", + "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.8.tgz", + "integrity": "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.8.tgz", + "integrity": "sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.8.tgz", + "integrity": "sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.8.tgz", + "integrity": "sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.8.tgz", + "integrity": "sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.9", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz", + "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.9.tgz", + "integrity": "sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.0", + "@smithy/chunked-blob-reader-native": "^4.2.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.8.tgz", + "integrity": "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.8.tgz", + "integrity": "sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz", + "integrity": "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.8.tgz", + "integrity": "sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz", + "integrity": "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.13.tgz", + "integrity": "sha512-x6vn0PjYmGdNuKh/juUJJewZh7MoQ46jYaJ2mvekF4EesMuFfrl4LaW/k97Zjf8PTCPQmPgMvwewg7eNoH9n5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.22.1", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.30", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.30.tgz", + "integrity": "sha512-CBGyFvN0f8hlnqKH/jckRDz78Snrp345+PVk8Ux7pnkUCW97Iinse59lY78hBt04h1GZ6hjBN94BRwZy1xC8Bg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/service-error-classification": "^4.2.8", + "@smithy/smithy-client": "^4.11.2", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz", + "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz", + "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz", + "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.9.tgz", + "integrity": "sha512-KX5Wml5mF+luxm1szW4QDz32e3NObgJ4Fyw+irhph4I/2geXwUy4jkIMUs5ZPGflRBeR6BUkC2wqIab4Llgm3w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz", + "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz", + "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz", + "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz", + "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz", + "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz", + "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz", + "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.11.2.tgz", + "integrity": "sha512-SCkGmFak/xC1n7hKRsUr6wOnBTJ3L22Qd4e8H1fQIuKTAjntwgU8lrdMe7uHdiT2mJAOWA/60qaW9tiMu69n1A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.22.1", + "@smithy/middleware-endpoint": "^4.4.13", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", + "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz", + "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.29", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.29.tgz", + "integrity": "sha512-nIGy3DNRmOjaYaaKcQDzmWsro9uxlaqUOhZDHQed9MW/GmkBZPtnU70Pu1+GT9IBmUXwRdDuiyaeiy9Xtpn3+Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.11.2", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.32", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.32.tgz", + "integrity": "sha512-7dtFff6pu5fsjqrVve0YMhrnzJtccCWDacNKOkiZjJ++fmjGExmmSu341x+WU6Oc1IccL7lDuaUj7SfrHpWc5Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.6", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.11.2", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz", + "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz", + "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz", + "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.11", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.11.tgz", + "integrity": "sha512-lKmZ0S/3Qj2OF5H1+VzvDLb6kRxGzZHq6f3rAsoSu5cTLGsn3v3VQBA8czkNNXlLjoFEtVu3OQT2jEeOtOE2CA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.9", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.8.tgz", + "integrity": "sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aws-lambda": { + "version": "8.10.159", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.159.tgz", + "integrity": "sha512-SAP22WSGNN12OQ8PlCzGzRCZ7QDCwI85dQZbmpz7+mAk+L7j+wI7qnvmdKh+o7A5LaOp6QnOZ2NJphAZQTTHQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.0.tgz", + "integrity": "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", + "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.2.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-jest/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/babel-jest/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/babel-jest/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/babel-jest/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-jest/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-jest/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", + "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", + "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.16.tgz", + "integrity": "sha512-KeUZdBuxngy825i8xvzaK1Ncnkx0tBmb3k8DkEuqjKRkmtvNTjey2ZsNeh8Dw4lfKvbCOu9oeNx2TKm2vHqcRw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bowser": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", + "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001765", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", + "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/cliui/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/execa/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/execa/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/execa/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/execa/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/foreground-child/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", + "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.2.0", + "@jest/types": "30.2.0", + "import-local": "^3.2.0", + "jest-cli": "30.2.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", + "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.2.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", + "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "p-limit": "^3.1.0", + "pretty-format": "30.2.0", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-circus/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-circus/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-circus/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-circus/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-circus/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-cli": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", + "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-cli/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-cli/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-cli/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-cli/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-cli/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-cli/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-config": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", + "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.2.0", + "@jest/types": "30.2.0", + "babel-jest": "30.2.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-circus": "30.2.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-runner": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "micromatch": "^4.0.8", + "parse-json": "^5.2.0", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-config/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-config/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-config/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-config/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-config/node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-config/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-diff": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-diff/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-diff/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-diff/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-diff/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-docblock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", + "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "jest-util": "30.2.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-each/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-each/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-each/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-each/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-each/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-node": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", + "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", + "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "micromatch": "^4.0.8", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", + "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-matcher-utils/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-matcher-utils/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-message-util/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-message-util/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-message-util/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", + "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", + "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-resolve/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-resolve/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-resolve/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-resolve/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-resolve/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runner": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", + "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.2.0", + "@jest/environment": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-leak-detector": "30.2.0", + "jest-message-util": "30.2.0", + "jest-resolve": "30.2.0", + "jest-runtime": "30.2.0", + "jest-util": "30.2.0", + "jest-watcher": "30.2.0", + "jest-worker": "30.2.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-runner/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-runner/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-runner/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-runner/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runner/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runtime": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", + "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/globals": "30.2.0", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-runtime/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-runtime/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-runtime/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-runtime/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runtime/node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-runtime/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-snapshot": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", + "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-diff": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "pretty-format": "30.2.0", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-snapshot/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-snapshot/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-snapshot/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-snapshot/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-util/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-util/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-util/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-util/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-validate": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", + "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-validate/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-validate/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-validate/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-validate/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-validate/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watcher": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", + "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.2.0", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-watcher/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-watcher/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-watcher/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-watcher/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-watcher/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watcher/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", + "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.2.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/package-lock.json": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-lock.json/-/package-lock.json-1.0.0.tgz", + "integrity": "sha512-+yEXtNdlCs5N0Zy/9uvkifgf/RqnGu0WqP4j9Wu1Us4YReFe1YNBh2Krmf8B1xGxjpYnta63K55QP8bkafnOzA==" + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.22", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", + "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.padend": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", + "integrity": "sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-jest": { + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/image-embedder-lambda/package.json b/image-embedder-lambda/package.json new file mode 100644 index 00000000000..16cf41f2448 --- /dev/null +++ b/image-embedder-lambda/package.json @@ -0,0 +1,36 @@ +{ + "name": "image-embedder-lambda", + "version": "1.0.0", + "description": "This is a service responsible for embedding uploaded images to the Grid and writing those embeddings to the S3 Vector Store.", + "main": "index.js", + "scripts": { + "test": "jest", + "test:integration": "AWS_PROFILE=media-service NODE_OPTIONS='--experimental-vm-modules' jest --config jest.integration.config.ts", + "type-check": "tsc --noEmit", + "bundle": "esbuild src/index.ts --bundle --platform=node --target=node24 --format=cjs --external:sharp --outfile=dist/index.js", + "build": "run-p --print-label type-check bundle", + "local": "ts-node localRun.ts" + }, + "author": "", + "license": "ISC", + "devDependencies": { + "@aws-sdk/client-cloudformation": "^3.981.0", + "@types/aws-lambda": "^8.10.159", + "@types/jest": "^30.0.0", + "@types/node": "^25.0.0", + "esbuild": "0.27.0", + "jest": "^30.2.0", + "npm-run-all": "^4.1.5", + "ts-jest": "^29.4.6", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" + }, + "dependencies": { + "@aws-sdk/client-bedrock-runtime": "^3.971.0", + "@aws-sdk/client-s3": "^3.971.0", + "@aws-sdk/client-s3vectors": "^3.971.0", + "@aws-sdk/client-sqs": "^3.948.0", + "package-lock.json": "^1.0.0", + "sharp": "^0.34.5" + } +} diff --git a/image-embedder-lambda/src/constants.ts b/image-embedder-lambda/src/constants.ts new file mode 100644 index 00000000000..2b5aa730e89 --- /dev/null +++ b/image-embedder-lambda/src/constants.ts @@ -0,0 +1,10 @@ +// Bedrock Cohere v3 and v4 models have a 5 MiB limit for images +// https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-embed-v3.html#:~:text=and%20has%20a-,maximum%20size%20of%205MB +// The docs above say 5 MB, but we've proven with our integration tests that it is 5 MiB +export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; + +// Cohere v3 doesn't specify a pixel size limit, +// but Cohere v4 downsamples images above this pixel count: +// https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-embed-v4.html#:~:text=Image%20sizing%3A-,Images%20%3E%202%2C458%2C624,-pixels%20are%20downsampled +// We use the lower bound of the models we can immediately foresee using. +export const MAX_PIXELS_COHERE_V4 = 2_458_624; diff --git a/image-embedder-lambda/src/index.ts b/image-embedder-lambda/src/index.ts new file mode 100644 index 00000000000..5fdcc267a46 --- /dev/null +++ b/image-embedder-lambda/src/index.ts @@ -0,0 +1,406 @@ +import { + Context, + SQSBatchItemFailure, + SQSBatchResponse, + SQSEvent, + SQSRecord, +} from "aws-lambda"; +import { + BedrockRuntimeClient, + ImageBlock$, + InvokeModelCommand, + InvokeModelCommandInput, + InvokeModelCommandOutput, +} from "@aws-sdk/client-bedrock-runtime"; +import sharp from "sharp"; +import { + GetObjectCommand, + GetObjectCommandOutput, + GetObjectRequest, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { + PutInputVector, + PutVectorsCommand, + PutVectorsCommandInput, + S3VectorsClient, +} from "@aws-sdk/client-s3vectors"; +import { + MAX_IMAGE_SIZE_BYTES, + MAX_PIXELS_COHERE_V4, +} from "./constants"; + +// Initialise clients at module level (cold start only) +const LOCALSTACK_ENDPOINT = + process.env.LOCALSTACK_ENDPOINT || "http://localhost:4566"; +const isLocal = process.env.IS_LOCAL === "true"; + +// Determine stage: dev for local, otherwise from environment (test or prod) +const STAGE = isLocal ? "dev" : process.env.STAGE; + +// Set in TEST/PROD by CDK to the appropriate AWS bucket, +// or locally by `localRun.ts` to the localstack bucket. +// If not set, caching of downscaled images will be disabled. +const DOWNSCALED_IMAGE_BUCKET = process.env.DOWNSCALED_IMAGE_BUCKET; + +if (!DOWNSCALED_IMAGE_BUCKET && !isLocal) { + console.error("DOWNSCALED_IMAGE_BUCKET not set, caching is disabled"); +} + +const s3Config = { + region: "eu-west-1", + ...(isLocal && { + endpoint: LOCALSTACK_ENDPOINT, + forcePathStyle: true, + credentials: { + accessKeyId: "test", + secretAccessKey: "test", + }, + }), +}; + +const s3Client = new S3Client(s3Config); +const bedrockClient = new BedrockRuntimeClient({ region: "eu-west-1" }); +const s3VectorsClient = new S3VectorsClient({ region: "eu-central-1" }); + +interface SQSMessageBody { + imageId: string; + s3Bucket: string; + s3Key: string; + fileType: string; +} + +export async function getImageFromS3( + s3Bucket: string, + s3Key: string, + client: S3Client, +): Promise { + const input: GetObjectRequest = { + Bucket: s3Bucket, + Key: s3Key, + }; + + console.log(`Fetching image from S3: bucket=${s3Bucket}, key=${s3Key}`); + try { + const command = new GetObjectCommand(input); + const response: GetObjectCommandOutput = await client.send(command); + + console.log(`S3 response metadata: ${JSON.stringify(response.$metadata)}`); + + if (!response.Body) { + console.log(`Returning undefined: response.Body is falsy`); + return undefined; + } + + if (response.ContentLength === 0) { + console.log(`Warning: ContentLength is 0, file may be empty`); + } + + const bytes = await response.Body.transformToByteArray(); + console.log(`Successfully fetched image of size ${bytes.length.toLocaleString()} bytes from S3`); + return bytes; + } catch (error) { + console.error(`Error fetching from S3:`, error); + throw error; + } +} + +export async function downscaleImageIfNeeded( + imageBytes: Uint8Array, + mimeType: string, + maxImageSizeBytes: number, + maxPixels: number, +): Promise { + const start = performance.now(); + let sharpImage = sharp(imageBytes); + const { width, height } = await sharpImage.metadata(); + const pixels = width * height; + const bytesExceedsLimit = imageBytes.length > maxImageSizeBytes; + const pixelsExceedsLimit = pixels > maxPixels; + const needsDownscale = bytesExceedsLimit || pixelsExceedsLimit; + console.log( + `Image has ${imageBytes.length.toLocaleString()} bytes (${bytesExceedsLimit ? "over" : "within"} limit of ${maxImageSizeBytes.toLocaleString()} bytes), ${pixels.toLocaleString()} px (${pixelsExceedsLimit ? "over" : "within"} limit of ${maxPixels.toLocaleString()} px) → ${needsDownscale ? "downscaling" : "no resize needed"}`, + ); + if (!needsDownscale) { + console.log(`Downscale check took ${(performance.now() - start).toFixed(0)}ms (no resize)`); + return imageBytes; + } + + const pixelRatio = maxPixels / pixels; + + // Why square root? Because the ratio comes from the multiplied width * height, + // but we want to use it to scale just the width. + const scaleFactor = Math.sqrt(pixelRatio); + // Floor because we want to make sure we're under the limit afterwards + const newWidth = Math.floor(width * scaleFactor); + + sharpImage = sharpImage.resize(newWidth); + + if (mimeType === "image/jpeg") { + // JPEG compression is lossy, so let's be conservative here. + // Also, 95 matches what we do for master crops: + // https://github.com/guardian/grid/blob/40be8f93f8a6da61c8188332c8e98796dc351ecd/cropper/app/lib/Crops.scala#L24 + sharpImage = sharpImage.jpeg({ quality: 95 }); + } else if (mimeType === "image/png") { + // PNG compression is lossless, so let's crank it to the max + sharpImage = sharpImage.png({ compressionLevel: 9 }); + } + + const buffer = await sharpImage.toBuffer(); + const result = new Uint8Array(buffer); + + // Q. Why not calculate height ourselves? + // A. We want the same rounding that sharp uses when it auto-resizes + // Q. Why not read the new height from the existing `sharpImage` object? + // A. Surprisingly, metadata doesn't get updated on calling resize. We need to output to buffer first. + const { height: newHeight } = await sharp(buffer).metadata(); + const newPixels = newWidth * newHeight; + if (result.byteLength > maxImageSizeBytes) { + throw new Error( + `Image has ${result.byteLength.toLocaleString()} bytes (over limit of ${maxImageSizeBytes.toLocaleString()} bytes) after downscaling from ${width}x${height} (${pixels.toLocaleString()} px) to ${newWidth}x${newHeight} (${newPixels.toLocaleString()} px)`, + ); + } + console.log( + `Image has ${result.byteLength.toLocaleString()} bytes after downscaling from ${width}x${height} (${pixels.toLocaleString()} px) to ${newWidth}x${newHeight} (${newPixels.toLocaleString()} px), took ${(performance.now() - start).toFixed(0)}ms`, + ); + + return result; +} + +// Matches the partitioned key structure used by Grid's image bucket for S3 performance at scale +// e.g. imageId "51bfb4107d1640aa74c45aaa51985e4e03852440" → "5/1/b/f/b/4/51bfb4107d1640aa74c45aaa51985e4e03852440" +function toPartitionedKey(imageId: string): string { + const prefix = imageId.slice(0, 6).split("").join("/"); + return `${prefix}/${imageId}`; +} + +async function getCachedDownscaledImage( + imageId: string, + client: S3Client, +): Promise { + if (!DOWNSCALED_IMAGE_BUCKET) { + console.log(`No downscaled image bucket configured, skipping cache lookup`); + return undefined; + } + + const key = toPartitionedKey(imageId); + try { + const command = new GetObjectCommand({ + Bucket: DOWNSCALED_IMAGE_BUCKET, + Key: key, + }); + const response = await client.send(command); + + if (!response.Body) { + return undefined; + } + + const bytes = await response.Body.transformToByteArray(); + console.log(`Cache hit: found downscaled image for ${imageId} (${bytes.length.toLocaleString()} bytes)`); + return bytes; + } catch (error: unknown) { + if (error instanceof Error && error.name === "NoSuchKey") { + console.log(`Cache miss: no downscaled image for ${imageId}`); + return undefined; + } + // Log but don't throw - cache failures shouldn't break the pipeline + console.warn(`Error checking cache for ${imageId}:`, error); + return undefined; + } +} + +async function cacheDownscaledImage( + imageId: string, + imageBytes: Uint8Array, + mimeType: string, + client: S3Client, +): Promise { + if (!DOWNSCALED_IMAGE_BUCKET) { + console.log("No DOWNSCALED_IMAGE_BUCKET set, will not cache downscaled image"); + return; + } + + const key = toPartitionedKey(imageId); + try { + const command = new PutObjectCommand({ + Bucket: DOWNSCALED_IMAGE_BUCKET, + Key: key, + Body: imageBytes, + ContentType: mimeType, + }); + await client.send(command); + console.log(`Cached downscaled image for ${imageId} (${imageBytes.length.toLocaleString()} bytes)`); + } catch (error) { + // Log but don't throw - cache failures shouldn't break the pipeline + console.warn(`Failed to cache downscaled image for ${imageId}:`, error); + } +} + +export async function embedImage( + imageId: string, + imageBytes: Uint8Array, + imageMimeType: string, + bedrockClient: BedrockRuntimeClient, + s3Client: S3Client, +): Promise { + // Try to get cached downscaled image first + let processedBytes = await getCachedDownscaledImage(imageId, s3Client); + + if (!processedBytes) { + processedBytes = await downscaleImageIfNeeded( + imageBytes, + imageMimeType, + MAX_IMAGE_SIZE_BYTES, + MAX_PIXELS_COHERE_V4, + ); + + // Cache if we actually downscaled (bytes changed) + if (processedBytes !== imageBytes) { + await cacheDownscaledImage(imageId, processedBytes, imageMimeType, s3Client); + } + } + + const base64Image = Buffer.from(processedBytes).toString("base64"); + const model = "cohere.embed-english-v3"; + const body = { + input_type: "image", + embedding_types: ["float"], + images: [`data:${imageMimeType};base64,${base64Image}`], + }; + + const input: InvokeModelCommandInput = { + modelId: model, + body: JSON.stringify(body), + accept: "*/*", + contentType: "application/json", + }; + console.log(`Invoking Bedrock model: ${model}`); + + try { + const embedStart = performance.now(); + const command = new InvokeModelCommand(input); + const response = await bedrockClient.send(command); + const embedMs = performance.now() - embedStart; + + console.log( + `Embedding took ${embedMs.toFixed(0)}ms, response ${response.body?.length.toLocaleString()} bytes, metadata: ${JSON.stringify(response.$metadata)}`, + ); + + return response; + } catch (error) { + console.error(`Bedrock invocation error:`, error); + throw error; + } +} + +async function storeEmbeddings( + vectors: PutInputVector[], + client: S3VectorsClient, +) { + console.log(`Storing ${vectors.length} embeddings to vector store`); + + const input: PutVectorsCommandInput = { + vectorBucketName: `image-embeddings-${STAGE}`.toLowerCase(), + indexName: "cohere-embed-english-v3", + vectors: vectors, + }; + + console.log( + `PutVectorsCommand input: vectorBucketName=${input.vectorBucketName}, indexName=${input.indexName}, vectorCount=${vectors.length}`, + ); + + try { + const command = new PutVectorsCommand(input); + const response = await client.send(command); + + console.log( + `S3 Vectors response metadata: ${JSON.stringify(response.$metadata)}`, + ); + console.log(`Successfully stored ${vectors.length} embeddings`); + + return response; + } catch (error) { + console.error(`Error storing embeddings:`, error); + throw error; + } +} + +export const handler = async ( + event: SQSEvent, + context: Context, +): Promise => { + console.log(`Starting handler embedding pipeline`); + const records: SQSRecord[] = event.Records; + console.log(`Processing ${records.length} SQS records`); + + const vectors: PutInputVector[] = []; + const batchItemFailures: SQSBatchItemFailure[] = []; + + for (const [i, record] of records.entries()) { + try { + console.log(`Parsing record ${i+1} of ${records.length}: ${record.body}`); + const recordBody: SQSMessageBody = JSON.parse(record.body); + + // If it's a Tiff then we should log an error + // And add it to the BatchItemFailures + // So that it ends on the DLQ for processing when we add tiff handling + if (recordBody.fileType === "image/tiff") { + throw new Error( + `Unsupported file type: ${recordBody.fileType} for image ${recordBody.imageId}`, + ); + } + + const gridImageBytes = await getImageFromS3( + recordBody.s3Bucket, + recordBody.s3Key, + s3Client, + ); + + if (!gridImageBytes) { + throw new Error( + `Failed to retrieve image from S3 for image ${recordBody.imageId}`, + ); + } + + const embeddingResponse = await embedImage( + recordBody.imageId, + gridImageBytes, + recordBody.fileType, + bedrockClient, + s3Client, + ); + const responseBody = JSON.parse( + new TextDecoder().decode(embeddingResponse.body), + ); + // Extract the embedding array (first element since we only sent one image) + const embedding: number[] = responseBody.embeddings.float[0]; + + console.log( + `Generated embedding for ${recordBody.imageId}, first 5 values: ${embedding.slice(0, 5)}`, + ); + + vectors.push({ + key: recordBody.imageId, + data: { + float32: embedding, + }, + }); + } catch (error) { + console.error(`Error processing record ${record.messageId}:`, error); + batchItemFailures.push({ itemIdentifier: record.messageId }); + } + } + + console.log( + `Processed ${records.length} records, ${vectors.length} images successfully embedded, ${batchItemFailures.length} failed`, + ); + + if (vectors.length > 0) { + await storeEmbeddings(vectors, s3VectorsClient); + } else { + console.log(`No vectors to store`); + } + + return { batchItemFailures }; +}; diff --git a/image-embedder-lambda/tsconfig.json b/image-embedder-lambda/tsconfig.json new file mode 100644 index 00000000000..9325957b344 --- /dev/null +++ b/image-embedder-lambda/tsconfig.json @@ -0,0 +1,19 @@ +{ + "exclude": ["**/*.js*"], + "compilerOptions": { + /* Basic Options */ + "target": "esnext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, + "module": "nodenext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, + "isolatedModules": true, /* Required by ts-jest with nodenext module resolution. */ + "lib": [ + "esnext" + ] /* Specify library files to be included in the compilation. */, + "strict": true, + /* Module Resolution Options */ + "moduleResolution": "nodenext" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, + /* Advanced Options */ + "skipLibCheck": true /* Skip type checking of declaration files. */, + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ + } +} diff --git a/image-loader/app/ImageLoaderComponents.scala b/image-loader/app/ImageLoaderComponents.scala index ebe3ef69ee4..d1ca1c39364 100644 --- a/image-loader/app/ImageLoaderComponents.scala +++ b/image-loader/app/ImageLoaderComponents.scala @@ -1,13 +1,13 @@ +import app.photofox.vipsffm.{Vips, VipsHelper} import com.gu.mediaservice.GridClient -import com.gu.mediaservice.lib.aws.SimpleSqsMessageConsumer -import com.gu.mediaservice.lib.config.Services +import com.gu.mediaservice.lib.aws.{Bedrock, S3, S3Vectors, SimpleSqsMessageConsumer, Embedder} import com.gu.mediaservice.lib.imaging.ImageOperations import com.gu.mediaservice.lib.logging.GridLogging -import com.gu.mediaservice.lib.management.InnerServiceStatusCheckController import com.gu.mediaservice.lib.play.GridComponents import controllers.{ImageLoaderController, ImageLoaderManagement, UploadStatusController} import lib._ import lib.storage.{ImageLoaderStore, QuarantineStore} +import model.upload.OptimiseWithPngQuant import model.{Projector, QuarantineUploader, Uploader} import play.api.ApplicationLoader.Context import router.Routes @@ -24,30 +24,41 @@ class ImageLoaderComponents(context: Context) extends GridComponents(context, ne val store = new ImageLoaderStore(config) val maybeIngestQueue = config.maybeIngestSqsQueueUrl.map(queueUrl => new SimpleSqsMessageConsumer(queueUrl, config)) val uploadStatusTable = new UploadStatusTable(config) - val imageOperations = new ImageOperations(context.environment.rootPath.getAbsolutePath) + val imageOperations = { + Vips.init() + VipsHelper.cache_set_max(0) + new ImageOperations(context.environment.rootPath.getAbsolutePath) + } val notifications = new Notifications(config) val downloader = new Downloader()(ec,wsClient) - val uploader = new Uploader(store, config, imageOperations, notifications, imageProcessor) - val projector = Projector(config, imageOperations, imageProcessor, auth) - val quarantineUploader: Option[QuarantineUploader] = (config.uploadToQuarantineEnabled, config.quarantineBucket) match { - case (true, Some(bucketName)) =>{ - val quarantineStore = new QuarantineStore(config) - Some(new QuarantineUploader(quarantineStore, config)) - } - case (true, None) => throw new IllegalArgumentException(s"Quarantining is enabled. upload.quarantine.enabled = ${config.uploadToQuarantineEnabled} but no bucket is configured. s3.quarantine.bucket isn't configured.") - case (false, _) => None + + val maybeEmbedder: Option[Embedder] = for { + queueUrl <- config.maybeImageEmbedderQueueUrl + if config.shouldEmbed + } yield { + new Embedder( + new S3Vectors(config), + new Bedrock(config), + new SimpleSqsMessageConsumer(queueUrl, config) + ) } - val services = new Services(config.domainRoot, config.serviceHosts, Set.empty) - private val gridClient = GridClient(services)(wsClient) + val optimiseOps = new OptimiseWithPngQuant(imageOperations) + val uploader = new Uploader(store, config, imageOperations, notifications, maybeEmbedder, imageProcessor, optimiseOps) + val s3 = new S3(config) + val projector = Projector(config, imageOperations, imageProcessor, auth, maybeEmbedder, s3, optimiseOps) + val quarantineUploader: Option[QuarantineUploader] = config.maybeQuarantineBucket.map(_ => + new QuarantineUploader(new QuarantineStore(config), config) + ) + + private val gridClient = GridClient(config.services)(wsClient) val metrics = new ImageLoaderMetrics(config, actorSystem, applicationLifecycle) val controller = new ImageLoaderController( - auth, downloader, store, maybeIngestQueue, uploadStatusTable, notifications, config, uploader, quarantineUploader, projector, controllerComponents, gridClient, authorisation, metrics) + auth, downloader, store, maybeIngestQueue, uploadStatusTable, config, uploader, quarantineUploader, projector, controllerComponents, gridClient, authorisation, metrics, events, wsClient, applicationLifecycle) val uploadStatusController = new UploadStatusController(auth, uploadStatusTable, config, controllerComponents, authorisation) - val InnerServiceStatusCheckController = new InnerServiceStatusCheckController(auth, controllerComponents, config.services, wsClient) val imageLoaderManagement = new ImageLoaderManagement(controllerComponents, buildInfo, controller.maybeIngestQueueAndProcessor) - override lazy val router = new Routes(httpErrorHandler, controller, uploadStatusController, imageLoaderManagement, InnerServiceStatusCheckController) + override lazy val router = new Routes(httpErrorHandler, controller, uploadStatusController, imageLoaderManagement) } diff --git a/image-loader/app/controllers/ImageLoaderController.scala b/image-loader/app/controllers/ImageLoaderController.scala index 035fe6c38bc..a15f04242e2 100644 --- a/image-loader/app/controllers/ImageLoaderController.scala +++ b/image-loader/app/controllers/ImageLoaderController.scala @@ -1,11 +1,7 @@ package controllers -import org.apache.pekko.Done -import org.apache.pekko.stream.Materializer -import org.apache.pekko.stream.scaladsl.Source import com.amazonaws.services.cloudwatch.model.Dimension import com.amazonaws.services.s3.AmazonS3 -import com.amazonaws.services.s3.model.AmazonS3Exception import com.amazonaws.services.sqs.model.{Message => SQSMessage} import com.amazonaws.util.IOUtils import com.drew.imaging.ImageProcessingException @@ -13,29 +9,40 @@ import com.gu.mediaservice.GridClient import com.gu.mediaservice.lib.ImageIngestOperations.fileKeyFromId import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.argo.model.Link -import com.gu.mediaservice.lib.auth.Authentication.OnBehalfOfPrincipal +import com.gu.mediaservice.lib.auth.Authentication.{MachinePrincipal, OnBehalfOfPrincipal, UserPrincipal} import com.gu.mediaservice.lib.auth._ +import com.gu.mediaservice.lib.auth.provider.ApiKeyAuthenticationProvider import com.gu.mediaservice.lib.aws.{S3Ops, SimpleSqsMessageConsumer, SqsHelpers} +import com.gu.mediaservice.lib.config.InstanceForRequest +import com.gu.mediaservice.lib.events.UsageEvents import com.gu.mediaservice.lib.formatting.printDateTime +import com.gu.mediaservice.lib.instances.Instances import com.gu.mediaservice.lib.logging.{FALLBACK, LogMarker, MarkerMap} import com.gu.mediaservice.lib.play.RequestLoggingFilter import com.gu.mediaservice.lib.{DateTimeUtils, ImageIngestOperations} -import com.gu.mediaservice.model.{UnsupportedMimeTypeException, UploadInfo} -import org.scanamo.{ConditionNotMet, ScanamoError} +import com.gu.mediaservice.model.{Instance, UnsupportedMimeTypeException, UploadInfo} import lib.FailureResponse.Response +import lib._ import lib.imaging.{MimeTypeDetection, NoSuchImageExistsInS3, UserImageLoaderException} import lib.storage.{ImageLoaderStore, S3FileDoesNotExistException} -import lib._ import model.upload.UploadRequest import model.{Projector, QuarantineUploader, S3FileExtractedMetadata, S3IngestObject, StatusType, UploadStatus, UploadStatusRecord, UploadStatusUri, Uploader} +import org.apache.pekko.Done +import org.apache.pekko.stream.Materializer +import org.apache.pekko.stream.scaladsl.Source +import org.joda.time.{DateTime, Duration} +import org.scanamo.{ConditionNotMet, ScanamoError} import play.api.data.Form import play.api.data.Forms._ +import play.api.inject.ApplicationLifecycle import play.api.libs.json.Json +import play.api.libs.ws.WSClient import play.api.mvc._ import java.io.{File, FileOutputStream} import java.net.URI import java.time.Instant +import java.util.concurrent.atomic.AtomicReference import scala.concurrent.{ExecutionContext, Future} import scala.util.control.NonFatal import scala.util.{Failure, Success, Try} @@ -45,27 +52,43 @@ class ImageLoaderController(auth: Authentication, store: ImageLoaderStore, maybeIngestQueue: Option[SimpleSqsMessageConsumer], uploadStatusTable: UploadStatusTable, - notifications: Notifications, - config: ImageLoaderConfig, + val config: ImageLoaderConfig, uploader: Uploader, quarantineUploader: Option[QuarantineUploader], projector: Projector, override val controllerComponents: ControllerComponents, gridClient: GridClient, authorisation: Authorisation, - metrics: ImageLoaderMetrics) + metrics: ImageLoaderMetrics, + events: UsageEvents, + val wsClient: WSClient, + applicationLifecycle: ApplicationLifecycle) (implicit val ec: ExecutionContext, materializer: Materializer) - extends BaseController with ArgoHelpers with SqsHelpers { + extends BaseController with ArgoHelpers with SqsHelpers with InstanceForRequest with Instances { private val AuthenticatedAndAuthorised = auth andThen authorisation.CommonActionFilters.authorisedForUpload + private val activeProcessingMessage = new AtomicReference[Option[SQSMessage]](None) + + // when shutting down, make best efforts to make currently processing image available on the queue again + applicationLifecycle.addStopHook(() => { + (activeProcessingMessage.get(), maybeIngestQueue) match { + case (Some(message), Some(ingestQueue)) => + Future { ingestQueue.makeMessageVisible(message) } + case _ => + Future.successful(()) + } + }) + val maybeIngestQueueAndProcessor: Option[(SimpleSqsMessageConsumer, Future[Done])] = maybeIngestQueue.map { ingestQueue => val processor = Source.repeat(()) .mapAsyncUnordered(parallelism=1)(_ => { ingestQueue.getNextMessage(attrApproximateReceiveCount) match { case None => + activeProcessingMessage.set(None) Future.successful(logger.debug(s"No message at ${DateTimeUtils.now()}")) case Some(sqsMessage) => + activeProcessingMessage.set(Some(sqsMessage)) val logMarker: LogMarker = MarkerMap( "requestType" -> "handleMessageFromIngestBucket", "requestId" -> sqsMessage.getMessageId, @@ -77,10 +100,12 @@ class ImageLoaderController(auth: Authentication, .map { _ => logger.info(logMarker, "Deleting message") ingestQueue.deleteMessage(sqsMessage) + activeProcessingMessage.set(None) } .recover { case t: Throwable => metrics.failedIngestsFromQueue.increment() + activeProcessingMessage.set(None) logger.error(logMarker, s"Failed to process message", t) } } @@ -95,95 +120,118 @@ class ImageLoaderController(auth: Authentication, (ingestQueue, processor) } - private lazy val indexResponse: Result = { + private def indexResponse(instance: Instance): Result = { val indexData = Map("description" -> "This is the Loader Service") val indexLinks = List( - Link("prepare", s"${config.rootUri}/prepare"), - Link("uploadStatus", s"${config.rootUri}/uploadStatus/{id}"), - Link("uploadStatuses", s"${config.rootUri}/uploadStatuses"), - Link("load", s"${config.rootUri}/images{?uploadedBy,identifiers,uploadTime,filename}"), - Link("import", s"${config.rootUri}/imports{?uri,uploadedBy,identifiers,uploadTime,filename}") + Link("prepare", s"${config.rootUri(instance)}/prepare"), + Link("uploadStatus", s"${config.rootUri(instance)}/uploadStatus/{id}"), + Link("uploadStatuses", s"${config.rootUri(instance)}/uploadStatuses"), + Link("load", s"${config.rootUri(instance)}/images{?uploadedBy,identifiers,uploadTime,filename}"), + Link("import", s"${config.rootUri(instance)}/imports{?uri,uploadedBy,identifiers,uploadTime,filename}") ) respond(indexData, indexLinks) } - def index: Action[AnyContent] = AuthenticatedAndAuthorised { indexResponse } - - private def quarantineOrStoreImage(uploadRequest: UploadRequest)(implicit logMarker: LogMarker) = { - quarantineUploader.map(_.quarantineFile(uploadRequest)).getOrElse(for { uploadStatusUri <- uploader.storeFile(uploadRequest)} yield{uploadStatusUri.toJsObject}) + def index: Action[AnyContent] = AuthenticatedAndAuthorised { request => + indexResponse(instanceOf(request)) } - private def handleMessageFromIngestBucket(sqsMessage:SQSMessage)(basicLogMarker: LogMarker): Future[Unit] = Future[Future[Unit]]{ - - logger.info(basicLogMarker, sqsMessage.toString) + private def quarantineOrStoreImage(uploadRequest: UploadRequest)(implicit logMarker: LogMarker, instance: Instance) = { + quarantineUploader.map(_.quarantineFile(uploadRequest)(instance)).getOrElse(for { uploadStatusUri <- uploader.storeFile(uploadRequest)} yield{uploadStatusUri.toJsObject}) + } + private def handleMessageFromIngestBucket(sqsMessage: SQSMessage)(basicLogMarker: LogMarker): Future[Unit] = { extractS3KeyFromSqsMessage(sqsMessage) match { case Failure(exception) => metrics.failedIngestsFromQueue.increment() logger.error(basicLogMarker, s"Failed to parse s3 data from SQS message", exception) Future.unit case Success(key) => - val s3IngestObject = S3IngestObject(key, store)(basicLogMarker) - - val isUiUpload = s3IngestObject.maybeMediaIdFromUiUpload.isDefined - - implicit val logMarker: LogMarker = basicLogMarker ++ Map( - "uploadedBy" -> s3IngestObject.uploadedBy, - "uploadedTime" -> s3IngestObject.uploadTime, - "contentLength" -> s3IngestObject.contentLength, - "filename" -> s3IngestObject.filename, - "isUiUpload" -> isUiUpload, - ) ++ s3IngestObject.maybeMediaIdFromUiUpload.map("mediaId" -> _).toMap - val metricDimensions = List( - new Dimension().withName("UploadedBy").withValue(s3IngestObject.uploadedBy), - new Dimension().withName("IsUiUpload").withValue(isUiUpload.toString), - ) - - val approximateReceiveCount = getApproximateReceiveCount(sqsMessage) + val pathComponents = key.split("/") + val instanceId = pathComponents.head + val path = pathComponents.drop(1).mkString("/") + logger.info(s"Instance and key: $instanceId / $path") - if(config.maybeUploadLimitInBytes.exists(_ < s3IngestObject.contentLength)){ - val errorMessage = s"File size exceeds the maximum allowed size (${config.maybeUploadLimitInBytes.get / 1_000_000}MB). Moving to fail bucket." - logger.warn(logMarker, errorMessage) - store.moveObjectToFailedBucket(s3IngestObject.key) - s3IngestObject.maybeMediaIdFromUiUpload foreach { imageId => - uploadStatusTable.updateStatus( // fire & forget, since there's nothing else we can do - imageId, UploadStatus(StatusType.Failed, Some(errorMessage)) - ) - } - metrics.failedIngestsFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) - Future.unit + val eventualIsValidInstanceId = getInstances().map { instances => + instances.map(_.id).contains(instanceId) } - else if (approximateReceiveCount > 2) { - metrics.abandonedMessagesFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) - val errorMessage = s"File processing has been attempted $approximateReceiveCount times. Moving to fail bucket." - logger.warn(logMarker, errorMessage) - store.moveObjectToFailedBucket(s3IngestObject.key) - s3IngestObject.maybeMediaIdFromUiUpload foreach { imageId => - uploadStatusTable.updateStatus( // fire & forget, since there's nothing else we can do - imageId, UploadStatus(StatusType.Failed, Some(errorMessage)) - ) - } - Future.unit - } else { - attemptToProcessIngestedFile(s3IngestObject, isUiUpload)(logMarker) map { digestedFile => - metrics.successfulIngestsFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) - logger.info(logMarker, s"Successfully processed image ${digestedFile.file.getName}") - store.deleteObjectFromIngestBucket(s3IngestObject.key) - } recover { - case _: UnsupportedMimeTypeException => - metrics.failedIngestsFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) - logger.info(logMarker, s"Unsupported mime type. Moving straight to fail bucket.") - store.moveObjectToFailedBucket(s3IngestObject.key) - case t: Throwable => - metrics.failedIngestsFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) - logger.error(logMarker, s"Failed to process file. Moving to fail bucket.", t) - store.moveObjectToFailedBucket(s3IngestObject.key) + + eventualIsValidInstanceId.flatMap { isValidInstanceSpecificKey => + if (isValidInstanceSpecificKey) { + try { + implicit val instance: Instance = Instance(id = instanceId) + + val s3IngestObject = S3IngestObject(key, store)(basicLogMarker) + val isUiUpload = s3IngestObject.maybeMediaIdFromUiUpload.isDefined + + implicit val logMarker: LogMarker = basicLogMarker ++ Map( + "uploadedBy" -> s3IngestObject.uploadedBy, + "uploadedTime" -> s3IngestObject.uploadTime, + "contentLength" -> s3IngestObject.contentLength, + "filename" -> s3IngestObject.filename, + "isUiUpload" -> isUiUpload, + ) ++ s3IngestObject.maybeMediaIdFromUiUpload.map("mediaId" -> _).toMap + val metricDimensions = List( + new Dimension().withName("UploadedBy").withValue(s3IngestObject.uploadedBy), + new Dimension().withName("IsUiUpload").withValue(isUiUpload.toString), + ) + + val approximateReceiveCount = getApproximateReceiveCount(sqsMessage) + + if (config.maybeUploadLimitInBytes.exists(_ < s3IngestObject.contentLength)) { + val errorMessage = s"File size exceeds the maximum allowed size (${config.maybeUploadLimitInBytes.get / 1_000_000}MB). Moving to fail bucket." + logger.warn(logMarker, errorMessage) + store.moveObjectToFailedBucket(s3IngestObject.key) + s3IngestObject.maybeMediaIdFromUiUpload foreach { imageId => + uploadStatusTable.updateStatus( // fire & forget, since there's nothing else we can do + imageId, UploadStatus(StatusType.Failed, Some(errorMessage)) + ) + } + metrics.failedIngestsFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) + Future.unit + } + else if (approximateReceiveCount > 2) { + metrics.abandonedMessagesFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) + val errorMessage = s"File processing has been attempted $approximateReceiveCount times. Moving to fail bucket." + logger.warn(logMarker, errorMessage) + store.moveObjectToFailedBucket(s3IngestObject.key) + s3IngestObject.maybeMediaIdFromUiUpload foreach { imageId => + uploadStatusTable.updateStatus( // fire & forget, since there's nothing else we can do + imageId, UploadStatus(StatusType.Failed, Some(errorMessage)) + ) + } + Future.unit + } else { + attemptToProcessIngestedFile(s3IngestObject, isUiUpload)(logMarker)(instance) map { digestedFile => + metrics.successfulIngestsFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) + events.successfulIngestFromQueue(instance = instance, image = digestedFile.digest, filesize = s3IngestObject.contentLength ) + logger.info(logMarker, s"Successfully processed image ${digestedFile.file.getName}") + store.deleteObjectFromIngestBucket(s3IngestObject.key) + } recover { + case _: UnsupportedMimeTypeException => + metrics.failedIngestsFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) + logger.info(logMarker, s"Unsupported mime type. Moving straight to fail bucket.") + store.moveObjectToFailedBucket(s3IngestObject.key) + case t: Throwable => + metrics.failedIngestsFromQueue.incrementBothWithAndWithoutDimensions(metricDimensions) + logger.error(logMarker, s"Failed to process file. Moving to fail bucket.", t) + store.moveObjectToFailedBucket(s3IngestObject.key) + } + } + } + catch { + case t: Throwable => + logger.error("Uncaught throw:", t) + Future.unit + } + } else { + Future.unit } } } - }.flatten + } - private def attemptToProcessIngestedFile(s3IngestObject:S3IngestObject, isUiUpload: Boolean)(initialLogMarker:LogMarker): Future[DigestedFile] = { + private def attemptToProcessIngestedFile(s3IngestObject:S3IngestObject, isUiUpload: Boolean)(initialLogMarker:LogMarker)(implicit instance: Instance): Future[DigestedFile] = { logger.info(initialLogMarker, "Attempting to process file") val tempFile = createTempFile("s3IngestBucketFile")(initialLogMarker) @@ -197,12 +245,14 @@ class ImageLoaderController(auth: Authentication, "mediaId" -> digestedFile.digest ) + val filename = s3IngestObject.filename val futureUploadStatusUri = uploadDigestedFileToStore( digestedFileFuture = Future(digestedFile), uploadedBy = s3IngestObject.uploadedBy, identifiers = None, uploadTime = Some(s3IngestObject.uploadTime.toString) , // upload time as iso string - uploader uses DateTimeUtils.fromValueOrNow - filename = Some(s3IngestObject.filename) + filename = Some(filename), + isFeedUpload = s3IngestObject.isFeedUpload, ) // under all circumstances, remove the temp files @@ -218,6 +268,8 @@ class ImageLoaderController(auth: Authentication, } def getPreSignedUploadUrlsAndTrack: Action[AnyContent] = AuthenticatedAndAuthorised.async { request => + implicit val instance: Instance = instanceOf(request) + val expiration = DateTimeUtils.now().plusHours(1) val mediaIdToFilenameMap = request.body.asJson.get.as[Map[String, String]] @@ -227,6 +279,7 @@ class ImageLoaderController(auth: Authentication, Future.sequence( mediaIdToFilenameMap.map{case (mediaId, filename) => + logger.info(s"Preparing file upload for instance $instance: $mediaId / $filename") val preSignedUrl = store.generatePreSignedUploadUrl(filename, expiration, uploadedBy, mediaId) @@ -239,9 +292,19 @@ class ImageLoaderController(auth: Authentication, StatusType.Prepared, errorMessage = None, expires = expiration.toEpochSecond, // TTL in case upload is never completed by client - )).map(_ => + instance = instance.id + )).map { _ => + val user = request.user match { + case u: UserPrincipal => u.attributes.get(ApiKeyAuthenticationProvider.KindeIdKey) + case _ => None + } + val apiKey = request.user match { + case m: MachinePrincipal => Some(m.accessor.identity) + case _ => None + } + events.prepareUpload(instance = instance, image = mediaId, user = user, apiKey = apiKey) mediaId -> preSignedUrl - ) + } } ) .map(_.toMap) @@ -249,7 +312,7 @@ class ImageLoaderController(auth: Authentication, .map(Ok(_)) } - def loadImage(uploadedBy: Option[String], identifiers: Option[String], uploadTime: Option[String], filename: Option[String]): Action[DigestedFile] = { + def loadImage(uploadedBy: Option[String], identifiers: Option[String], uploadTime: Option[String], filename: Option[String]): Action[DigestedFile] = { val uploadTimeToRecord = DateTimeUtils.fromValueOrNow(uploadTime) val initialContext = MarkerMap( @@ -266,7 +329,8 @@ class ImageLoaderController(auth: Authentication, logger.info(initialContext, "body parsed") val parsedBody = DigestBodyParser.create(tempFile) - AuthenticatedAndAuthorised.async(parsedBody) { req => + AuthenticatedAndAuthorised.async(parsedBody) { req: Authentication.Request[DigestedFile] => + implicit val instance: Instance = instanceOf(req) val uploadedByToRecord = uploadedBy.getOrElse(Authentication.getIdentity(req.user)) implicit val context: LogMarker = @@ -275,25 +339,40 @@ class ImageLoaderController(auth: Authentication, "requestId" -> RequestLoggingFilter.getRequestId(req) ) - val uploadStatus = if(config.uploadToQuarantineEnabled) StatusType.Pending else StatusType.Completed + val uploadStatus = if(config.maybeQuarantineBucket.isDefined) StatusType.Pending else StatusType.Completed val uploadExpiry = Instant.now.getEpochSecond + config.uploadStatusExpiry.toSeconds - val record = UploadStatusRecord(req.body.digest, filename, uploadedByToRecord, printDateTime(uploadTimeToRecord), identifiers, uploadStatus, None, uploadExpiry) + val record = UploadStatusRecord(req.body.digest, filename, uploadedByToRecord, printDateTime(uploadTimeToRecord), identifiers, uploadStatus, None, uploadExpiry, instance.id) + logger.info(s"Loading image for instance ${instance.id}: record ${record.id} / $filename") + val result = for { uploadRequest <- uploader.loadFile( req.body, uploadedByToRecord, identifiers, uploadTimeToRecord, - filename.flatMap(_.trim.nonEmptyOpt) + filename.flatMap(_.trim.nonEmptyOpt), + instance, + isFeedUpload = false, ) _ <- uploadStatusTable.setStatus(record) - result <- quarantineOrStoreImage(uploadRequest) + + result <- quarantineOrStoreImage(uploadRequest)(context, instance) + } yield result result.onComplete( _ => Try { deleteTempFile(tempFile) } ) result map { r => val result = Accepted(r).as(ArgoMediaType) logger.info(context, "loadImage request end") + val user = req.user match { + case u: UserPrincipal => u.attributes.get(ApiKeyAuthenticationProvider.KindeIdKey) + case _ => None + } + val apiKey = req.user match { + case m: MachinePrincipal => Some(m.accessor.identity) + case _ => None + } + events.uploadImage(instance = instance, image = req.body.digest, filesize = req.body.file.length(), apiKey = apiKey, user = user) result } recover { case NonFatal(e) => @@ -311,12 +390,14 @@ class ImageLoaderController(auth: Authentication, // Fetch def projectImageBy(imageId: String): Action[AnyContent] = { + val initialContext = MarkerMap( "imageId" -> imageId, "requestType" -> "image-projection" ) val tempFile = createTempFile(s"projection-$imageId")(initialContext) auth.async { req => + implicit val instance: Instance = instanceOf(req) implicit val context: LogMarker = initialContext ++ Map( "requestId" -> RequestLoggingFilter.getRequestId(req) ) @@ -347,9 +428,11 @@ class ImageLoaderController(auth: Authentication, uploadedBy: Option[String], identifiers: Option[String], uploadTime: Option[String], - filename: Option[String] + filename: Option[String], ): Action[AnyContent] = { AuthenticatedAndAuthorised.async { request => + implicit val instance: Instance = instanceOf(request) + implicit val context: MarkerMap = MarkerMap( "requestType" -> "import-image", "key-tier" -> request.user.accessor.tier.toString, @@ -357,7 +440,7 @@ class ImageLoaderController(auth: Authentication, "requestId" -> RequestLoggingFilter.getRequestId(request) ) - logger.info(context, "importImage request start") + logger.info(context, "importImage request start for $uri into instance $instance") val tempFile = createTempFile("download") val digestedFileFuture = for { @@ -370,7 +453,8 @@ class ImageLoaderController(auth: Authentication, uploadedBy.getOrElse(Authentication.getIdentity(request.user)), identifiers, uploadTime, - filename + filename, + isFeedUpload = false, ) // under all circumstances, remove the temp files @@ -397,9 +481,10 @@ class ImageLoaderController(auth: Authentication, uploadedBy: String, identifiers: Option[String], uploadTime: Option[String], - filename: Option[String] - )(implicit logMarker:LogMarker): Future[UploadStatusUri] = { - + filename: Option[String], + isFeedUpload: Boolean + )(implicit logMarker:LogMarker, instance: Instance): Future[UploadStatusUri] = { + val start = DateTime.now() for { digestedFile <- digestedFileFuture uploadStatusResult <- uploadStatusTable.getStatus(digestedFile.digest) @@ -410,10 +495,13 @@ class ImageLoaderController(auth: Authentication, identifiers = maybeStatus.flatMap(_.identifiers).orElse(identifiers), uploadTime = DateTimeUtils.fromValueOrNow(maybeStatus.map(_.uploadTime).orElse(uploadTime)), filename = maybeStatus.flatMap(_.fileName).orElse(filename).flatMap(_.trim.nonEmptyOpt), + instance, + isFeedUpload = isFeedUpload ) result <- uploader.storeFile(uploadRequest) } yield { - logger.info(logMarker, "importImage request end") + val duration = new Duration(start, DateTime.now()) + logger.info(logMarker, s"importImage request end; took ${duration.getMillis} ms") result } } @@ -421,7 +509,7 @@ class ImageLoaderController(auth: Authentication, private def resolveUploadAndUpdateStatus ( uploadResultFuture: Future[UploadStatusUri], digestedFileFuture: Future[DigestedFile], - )(implicit logMarker:LogMarker):Future[Either[Response,UploadStatusUri]] = { + )(implicit logMarker:LogMarker, instance: Instance):Future[Either[Response,UploadStatusUri]] = { // combine the import result and digest file together into a single future uploadResultFuture.transformWith { // note that we use transformWith instead of zip here as we are still interested in value of digestedFile even if the import fails maybeImportResult => @@ -460,7 +548,7 @@ class ImageLoaderController(auth: Authentication, private def updateUploadStatusTable( uploadAttempt: Future[UploadStatusUri], digestedFile: DigestedFile - )(implicit logMarker: LogMarker): Future[Unit] = { + )(implicit logMarker: LogMarker, instance: Instance): Future[Unit] = { def reportFailure(error: Throwable): Unit = { val errorMessage = s"an error occurred while updating image upload status, error:$error" @@ -503,6 +591,7 @@ class ImageLoaderController(auth: Authentication, private case class RestoreFromReplicaForm(imageId: String) def restoreFromReplica: Action[AnyContent] = AuthenticatedAndAuthorised.async { implicit request => + implicit val instance: Instance = instanceOf(request) val imageId = Form( mapping( @@ -549,7 +638,8 @@ class ImageLoaderController(auth: Authentication, metadata.uploadTime, metadata.uploadedBy, metadata.identifiers, - UploadInfo(metadata.uploadFileName) + UploadInfo(metadata.uploadFileName, metadata.isFeedUpload), + instance, ), gridClient, auth.getOnBehalfOfPrincipal(request.user) @@ -561,7 +651,7 @@ class ImageLoaderController(auth: Authentication, future.map { _ => logger.info(logMarker, s"Restored image $imageId from replica bucket $replicaBucket (key: $s3Key)") - Redirect(s"${config.kahunaUri}/images/$imageId") + Redirect(s"${config.kahunaUri(instance)}/images/$imageId") } case _ => Future.successful(NotFound("Image not found in replica bucket")) diff --git a/image-loader/app/controllers/UploadStatusController.scala b/image-loader/app/controllers/UploadStatusController.scala index 4bde0c8b3be..c449f8d2ffb 100644 --- a/image-loader/app/controllers/UploadStatusController.scala +++ b/image-loader/app/controllers/UploadStatusController.scala @@ -3,8 +3,10 @@ package controllers import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.auth._ +import com.gu.mediaservice.lib.config.InstanceForRequest import lib._ import model.{StatusType, UploadStatus} +import com.gu.mediaservice.model.Instance import org.scanamo.{ConditionNotMet, ScanamoError} import play.api.mvc._ @@ -18,13 +20,14 @@ class UploadStatusController(auth: Authentication, authorisation: Authorisation ) (implicit val ec: ExecutionContext) - extends BaseController with ArgoHelpers { + extends BaseController with ArgoHelpers with InstanceForRequest { - def getUploadStatus(imageId: String) = auth.async { + def getUploadStatus(imageId: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) store.getStatus(imageId) .map { case Some(Right(record)) => respond(UploadStatus(record.status, record.errorMessage), - uri = Some(URI.create(s"${config.apiUri}/images/${imageId}"))) + uri = Some(URI.create(s"${config.apiUri(instance)}/images/$imageId"))) case Some(Left(error)) => respondError(BadRequest, "cannot-get", s"Cannot get upload status ${error}") case None => respondNotFound(s"No upload status found for image id: ${imageId}") } @@ -32,6 +35,7 @@ class UploadStatusController(auth: Authentication, } def updateUploadStatus(imageId: String) = (auth andThen authorisation.CommonActionFilters.authorisedForUpload).async(parse.json[UploadStatus]) { request => + implicit val instance: Instance = instanceOf(request) request.body match { case UploadStatus(StatusType.Failed, None) => Future.successful(respondError( @@ -55,6 +59,7 @@ class UploadStatusController(auth: Authentication, def getUploadsBy(user:String): Action[AnyContent] = getUploads(Some(user)) private def getUploads(maybeUser: Option[String]): Action[AnyContent] = auth.async { req => + implicit val instance: Instance = instanceOf(req) store.queryByUser(maybeUser.getOrElse(req.user.accessor.identity)) .map(list => respond(list)) } diff --git a/image-loader/app/lib/ImageLoaderConfig.scala b/image-loader/app/lib/ImageLoaderConfig.scala index 8c85037fe90..10b4fe1c861 100644 --- a/image-loader/app/lib/ImageLoaderConfig.scala +++ b/image-loader/app/lib/ImageLoaderConfig.scala @@ -1,5 +1,7 @@ package lib +import com.gu.mediaservice.lib.aws.{S3, S3Bucket} + import java.io.File import com.gu.mediaservice.lib.cleanup.{ComposedImageProcessor, ImageProcessor, ImageProcessorResources} import com.gu.mediaservice.lib.config.{CommonConfig, GridConfigResources, ImageProcessorLoader} @@ -10,29 +12,31 @@ import play.api.inject.ApplicationLifecycle import scala.concurrent.duration.FiniteDuration class ImageLoaderConfig(resources: GridConfigResources) extends CommonConfig(resources) with StrictLogging { - val imageBucket: String = string("s3.image.bucket") val maybeImageReplicaBucket: Option[String] = stringOpt("s3.image.replicaBucket") - val thumbnailBucket: String = string("s3.thumb.bucket") - val quarantineBucket: Option[String] = stringOpt("s3.quarantine.bucket") - val uploadToQuarantineEnabled: Boolean = boolean("upload.quarantine.enabled") + private val quarantineBucketEndpoint = S3.AmazonAwsS3Endpoint + val quarantineBucket: Option[S3Bucket] = stringOpt("s3.quarantine.bucket").map { bucket => + S3Bucket(bucket, quarantineBucketEndpoint, usesPathStyleURLs = false, clientFor(quarantineBucketEndpoint)) + } val tempDir: File = new File(stringDefault("upload.tmp.dir", "/tmp")) val thumbWidth: Int = 256 val thumbQuality: Double = 85d // out of 100 - val rootUri: String = services.loaderBaseUri - val apiUri: String = services.apiBaseUri - val kahunaUri: String = services.kahunaBaseUri + val rootUri: Instance => String = services.loaderBaseUri + val apiUri: Instance => String = services.apiBaseUri + val kahunaUri: Instance => String = services.kahunaBaseUri - val transcodedMimeTypes: List[MimeType] = getStringSet("transcoded.mime.types").toList.map(MimeType(_)) - val supportedMimeTypes: List[MimeType] = List(Jpeg, Png) ::: transcodedMimeTypes + val supportedMimeTypes: List[MimeType] = List(Jpeg, Png) val uploadStatusTable: String = string("dynamo.table.upload.status") val uploadStatusExpiry: FiniteDuration = configuration.get[FiniteDuration]("uploadStatus.recordExpiry") + val shouldEmbed: Boolean = boolean("s3.vectors.shouldEmbed") + val maybeImageEmbedderQueueUrl: Option[String] = stringOpt("sqs.image.embedder.queue.url") + /** * Load in the chain of image processors from config. This can be a list of * companion objects, class names, both with and without config. diff --git a/image-loader/app/lib/ImageLoaderStore.scala b/image-loader/app/lib/ImageLoaderStore.scala index 1c509f3712a..38a75139739 100644 --- a/image-loader/app/lib/ImageLoaderStore.scala +++ b/image-loader/app/lib/ImageLoaderStore.scala @@ -4,36 +4,39 @@ import lib.ImageLoaderConfig import com.gu.mediaservice.lib import com.amazonaws.HttpMethod import com.amazonaws.services.s3.model.{AmazonS3Exception, GeneratePresignedUrlRequest, S3Object} -import com.gu.mediaservice.lib.logging.LogMarker +import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker} +import com.gu.mediaservice.model.Instance import java.time.ZonedDateTime import java.util.Date class S3FileDoesNotExistException extends Exception() -class ImageLoaderStore(config: ImageLoaderConfig) extends lib.ImageIngestOperations(config.imageBucket, config.thumbnailBucket, config) { +class ImageLoaderStore(config: ImageLoaderConfig) extends lib.ImageIngestOperations(config.imageBucket, config.thumbnailBucket, config) with GridLogging { private def handleNotFound[T](key: String)(doWork: => T)(loggingIfNotFound: => Unit): T = { try { doWork } catch { - case e: AmazonS3Exception if e.getStatusCode == 404 || e.getStatusCode == 403 => + case e: AmazonS3Exception if e.getStatusCode == 404 || e.getStatusCode == 403 => { + logger.warn(s"AmazonS3Exception ${e.getStatusCode} for key '$key'") loggingIfNotFound throw new S3FileDoesNotExistException + } case other: Throwable => throw other } } def getS3Object(key: String)(implicit logMarker: LogMarker): S3Object = handleNotFound(key) { - client.getObject(config.maybeIngestBucket.get, key) + getObject(config.maybeIngestBucket.get, key) } { logger.error(logMarker, s"Attempted to read $key from ingest bucket, but it does not exist.") } - def generatePreSignedUploadUrl(filename: String, expiration: ZonedDateTime, uploadedBy: String, mediaId: String): String = { + def generatePreSignedUploadUrl(filename: String, expiration: ZonedDateTime, uploadedBy: String, mediaId: String)(implicit instance: Instance): String = { val request = new GeneratePresignedUrlRequest( - config.maybeIngestBucket.get, // bucket - s"$uploadedBy/$filename", // key + config.maybeBucketForUIUploads.get.bucket, // bucket + s"${instance.id}/$uploadedBy/$filename", // key ) .withMethod(HttpMethod.PUT) .withExpiration(Date.from(expiration.toInstant)); @@ -41,18 +44,18 @@ class ImageLoaderStore(config: ImageLoaderConfig) extends lib.ImageIngestOperati // sent by the client in manager.js request.putCustomRequestHeader("x-amz-meta-media-id", mediaId) - client.generatePresignedUrl(request).toString + generatePresignedRequest(request, config.maybeIngestBucket.get).toString } def moveObjectToFailedBucket(key: String)(implicit logMarker: LogMarker) = handleNotFound(key){ - client.copyObject(config.maybeIngestBucket.get, key, config.maybeFailBucket.get, key) + copyObject(config.maybeIngestBucket.get, config.maybeFailBucket.get, key) // TODO Naked get - make optional deleteObjectFromIngestBucket(key) } { logger.warn(logMarker, s"Attempted to copy $key from ingest bucket to fail bucket, but it does not exist.") } def deleteObjectFromIngestBucket(key: String)(implicit logMarker: LogMarker) = handleNotFound(key) { - client.deleteObject(config.maybeIngestBucket.get,key) + deleteObject(config.maybeIngestBucket.get, key) } { logger.warn(logMarker, s"Attempted to delete $key from ingest bucket, but it does not exist.") } diff --git a/image-loader/app/lib/QuarantineStore.scala b/image-loader/app/lib/QuarantineStore.scala index 1c750bb7c17..0c7e5f515f3 100644 --- a/image-loader/app/lib/QuarantineStore.scala +++ b/image-loader/app/lib/QuarantineStore.scala @@ -3,4 +3,4 @@ package lib.storage import lib.ImageLoaderConfig import com.gu.mediaservice.lib -class QuarantineStore(config: ImageLoaderConfig) extends lib.ImageQuarantineOperations(config.quarantineBucket.get, config) \ No newline at end of file +class QuarantineStore(config: ImageLoaderConfig) extends lib.ImageQuarantineOperations(config.quarantineBucket.get, config) diff --git a/image-loader/app/lib/UploadStatusTable.scala b/image-loader/app/lib/UploadStatusTable.scala index c545eb045a9..008f42e8139 100644 --- a/image-loader/app/lib/UploadStatusTable.scala +++ b/image-loader/app/lib/UploadStatusTable.scala @@ -1,51 +1,56 @@ package lib +import com.gu.mediaservice.lib.logging.GridLogging +import com.gu.mediaservice.model.Instance import org.scanamo._ import org.scanamo.syntax._ import org.scanamo.generic.auto._ import model.StatusType.{Prepared, Queued} -import model.{UploadStatus, UploadStatusRecord} +import model.{StatusType, UploadStatus, UploadStatusRecord} import software.amazon.awssdk.services.dynamodb.{DynamoDbAsyncClient, DynamoDbAsyncClientBuilder} import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future -class UploadStatusTable(config: ImageLoaderConfig) { +class UploadStatusTable(config: ImageLoaderConfig) extends GridLogging { + + implicit val statusTypeFormat: Typeclass[StatusType] = + DynamoFormat.coercedXmap[StatusType, String, IllegalArgumentException](StatusType.apply, _.name) val client = config.withAWSCredentialsV2(DynamoDbAsyncClient.builder()).build() val scanamo = ScanamoAsync(client) private val uploadStatusTable = Table[UploadStatusRecord](config.uploadStatusTable) - def getStatus(imageId: String) = { - scanamo.exec(uploadStatusTable.get("id" === imageId)) + def getStatus(imageId: String)(implicit instance: Instance) = { + scanamo.exec(uploadStatusTable.get("instance" === instance.id and "id" === imageId)) } def setStatus(uploadStatus: UploadStatusRecord) = { scanamo.exec(uploadStatusTable.put(uploadStatus)) } - def updateStatus(imageId: String, updateRequest: UploadStatus) = { - val updateExpression = updateRequest.errorMessage match { - case Some(error) => set("status", updateRequest.status) and set("errorMessage", error) - case None => set("status", updateRequest.status) + def updateStatus(imageId: String, updateStatus: UploadStatus)(implicit instance: Instance) = { + val updateExpression = updateStatus.errorMessage match { + case Some(error) => set("status", updateStatus.status) and set("errorMessage", error) + case None => set("status", updateStatus.status) } val uploadStatusTableWithCondition = - if(updateRequest.status == Queued) // can only transition to Queued status from Prepared status - uploadStatusTable.when(attributeExists("id") and ("status" === Prepared.toString)) + if(updateStatus.status == Queued) // can only transition to Queued status from Prepared status + uploadStatusTable.when(attributeExists("id") and attributeExists("instance") and "status" === Prepared.name) else - uploadStatusTable.when(attributeExists("id")) + uploadStatusTable.when(attributeExists("id") and attributeExists("instance")) scanamo.exec( uploadStatusTableWithCondition .update( - key = "id" === imageId, + "id" === imageId and "instance" === instance.id, update = updateExpression ) ) } - def queryByUser(user: String): Future[List[UploadStatusRecord]] = { - scanamo.exec(uploadStatusTable.scan()).map { + def queryByUser(user: String)(implicit instance: Instance): Future[List[UploadStatusRecord]] = { + scanamo.exec(uploadStatusTable.query("instance" === instance.id)).map { case Nil => List.empty[UploadStatusRecord] case recordsAndErrors => { recordsAndErrors diff --git a/image-loader/app/lib/imaging/FileMetadataReader.scala b/image-loader/app/lib/imaging/FileMetadataReader.scala index f81b9ce3a7f..ca2c78d712e 100644 --- a/image-loader/app/lib/imaging/FileMetadataReader.scala +++ b/image-loader/app/lib/imaging/FileMetadataReader.scala @@ -13,7 +13,7 @@ import com.drew.metadata.xmp.XmpDirectory import com.drew.metadata.{Directory, Metadata} import com.gu.mediaservice.lib.{ImageWrapper, StorableImage} import com.gu.mediaservice.lib.imaging.im4jwrapper.ImageMagick._ -import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker} +import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker, Stopwatch, addLogMarkers} import com.gu.mediaservice.lib.metadata.ImageMetadataConverter import com.gu.mediaservice.model._ import model.upload.UploadRequest @@ -53,7 +53,7 @@ object FileMetadataReader extends GridLogging { private implicit val ctx: ExecutionContext = ExecutionContext.fromExecutor(Executors.newCachedThreadPool) - def fromIPTCHeaders(image: File, imageId:String): Future[FileMetadata] = + def fromIPTCHeaders(image: File, imageId:String)(implicit logMarker: LogMarker): Future[FileMetadata] = for { metadata <- readMetadata(image) } @@ -125,7 +125,7 @@ object FileMetadataReader extends GridLogging { val redactionReplacementValue = s"REDACTED (value longer than $redactionThreshold characters, please refer to the metadata stored in the file itself)" private def redactLongFieldValues(imageId: String, metadataType: String, exceptions: List[String] = Nil)(props: Map[String, String]) = props.map { case (fieldName, value) if value.length > redactionThreshold && !exceptions.exists(fieldName.contains) => - logger.warn(s"Redacting '$fieldName' $metadataType field for image $imageId, as it's problematically long (longer than $redactionThreshold characters") + logger.debug(s"Redacting '$fieldName' $metadataType field for image $imageId, as it's problematically long (longer than $redactionThreshold characters") fieldName -> redactionReplacementValue case keyValuePair => keyValuePair } @@ -183,67 +183,17 @@ object FileMetadataReader extends GridLogging { private def dateToUTCString(date: DateTime): String = ISODateTimeFormat.dateTime.print(date.withZone(DateTimeZone.UTC)) - - def orientation(image: File): Future[Option[OrientationMetadata]] = { - for { - metadata <- readMetadata(image) - } yield { - - for { - exifDirectory <- Option(metadata.getFirstDirectoryOfType(classOf[ExifIFD0Directory])) - exifOrientation <- Option(exifDirectory.getInteger(ExifDirectoryBase.TAG_ORIENTATION)) - orientation = OrientationMetadata(exifOrientation = Some(exifOrientation)) - orientationWhichTransformsImage <- Seq(orientation).find(_.transformsImage()) - } yield { - orientationWhichTransformsImage - } - } - } - - def dimensions(image: File, mimeType: Option[MimeType]): Future[Option[Dimensions]] = - for { - metadata <- readMetadata(image) - } - yield { - - mimeType match { - - case Some(Jpeg) => for { - jpegDir <- Option(metadata.getFirstDirectoryOfType(classOf[JpegDirectory])) - - } yield Dimensions(jpegDir.getImageWidth, jpegDir.getImageHeight) - - case Some(Png) => for { - pngDir <- Option(metadata.getFirstDirectoryOfType(classOf[PngDirectory])) - - } yield { - val width = pngDir.getInt(PngDirectory.TAG_IMAGE_WIDTH) - val height = pngDir.getInt(PngDirectory.TAG_IMAGE_HEIGHT) - Dimensions(width, height) - } - - case Some(Tiff) => for { - exifDir <- Option(metadata.getFirstDirectoryOfType(classOf[ExifIFD0Directory])) - - } yield { - val width = exifDir.getInt(ExifDirectoryBase.TAG_IMAGE_WIDTH) - val height = exifDir.getInt(ExifDirectoryBase.TAG_IMAGE_HEIGHT) - Dimensions(width, height) - } - - case _ => None - - } - } - def getColorModelInformation(image: File, metadata: Metadata, mimeType: MimeType)(implicit logMarker: LogMarker): Future[Map[String, String]] = { - + val stopWatch = Stopwatch.start val source = addImage(image) val formatter = format(source)("%r") - runIdentifyCmd(formatter, useImageMagick = false).map{ imageType => getColourInformation(metadata, imageType.headOption, mimeType) } - .recover { case _ => getColourInformation(metadata, None, mimeType) } + runIdentifyCmd(formatter, useImageMagick = false).map { imageType => getColourInformation(metadata, imageType.headOption, mimeType) } + .recover { case _ => getColourInformation(metadata, None, mimeType) }.map { result => + logger.info(addLogMarkers(stopWatch.elapsed), "Finished getColorModelInformation") + result + } } // bits per sample might be a useful value, eg. "1", "8"; or it might be annoying like "1 bits/component/pixel", "8 8 8 bits/component/pixel" @@ -287,14 +237,26 @@ object FileMetadataReader extends GridLogging { "photometricInterpretation" -> photometricInterpretation, "bitsPerSample" -> getFromExifDirectory(ExifDirectoryBase.TAG_BITS_PER_SAMPLE).flatMap(extractBitsPerSample) ).flattenOptions + case Heif => + Map ( + "hasAlpha" -> hasAlpha, + "colorType" -> maybeImageType, + "photometricInterpretation" -> photometricInterpretation, + ).flattenOptions } } private def nonEmptyTrimmed(nullableStr: String): Option[String] = Option(nullableStr) map (_.trim) filter (_.nonEmpty) - private def readMetadata(file: File): Future[Metadata] = Future { - ImageMetadataReader.readMetadata(file) + private def readMetadata(file: File)(implicit logMarker: LogMarker): Future[Metadata] = { + val stopwatch = Stopwatch.start + Future { + ImageMetadataReader.readMetadata(file) + }.map { result => + logger.info(addLogMarkers(stopwatch.elapsed),"Finished readMetadata") + result + } } // Helper to flatten maps of options diff --git a/image-loader/app/model/Projector.scala b/image-loader/app/model/Projector.scala index c776d664f35..8ff106bf2a5 100644 --- a/image-loader/app/model/Projector.scala +++ b/image-loader/app/model/Projector.scala @@ -1,45 +1,43 @@ package model -import java.io.{File, FileOutputStream} -import java.util.UUID -import com.amazonaws.services.s3.AmazonS3 -import com.gu.mediaservice.{GridClient, ImageDataMerger} -import com.gu.mediaservice.lib.auth.Authentication -import com.amazonaws.services.s3.model.{GetObjectRequest, ObjectMetadata, S3Object => AwsS3Object} +import _root_.play.api.libs.ws.WSRequest +import com.amazonaws.services.s3.model.{ObjectMetadata, S3Object => AwsS3Object} import com.gu.mediaservice.lib.ImageIngestOperations.{fileKeyFromId, optimisedPngKeyFromId} -import com.gu.mediaservice.lib.{ImageIngestOperations, ImageStorageProps, StorableOptimisedImage, StorableOriginalImage, StorableThumbImage} -import com.gu.mediaservice.lib.aws.S3Ops -import com.gu.mediaservice.lib.aws.S3Object +import com.gu.mediaservice.lib._ +import com.gu.mediaservice.lib.auth.Authentication +import com.gu.mediaservice.lib.aws.{Embedder, EmbedderMessage, S3, S3Bucket} import com.gu.mediaservice.lib.cleanup.ImageProcessor +import com.gu.mediaservice.lib.config.InstanceForRequest import com.gu.mediaservice.lib.imaging.ImageOperations import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker, Stopwatch} import com.gu.mediaservice.lib.net.URI -import com.gu.mediaservice.model.{Image, MimeType, UploadInfo} +import com.gu.mediaservice.model.{Image, Instance, MimeType, UploadInfo} +import com.gu.mediaservice.{GridClient, ImageDataMerger} import lib.imaging.{MimeTypeDetection, NoSuchImageExistsInS3} import lib.{DigestedFile, ImageLoaderConfig} -import model.upload.UploadRequest -import org.apache.tika.io.IOUtils +import model.upload.{OptimiseOps, UploadRequest} +import org.apache.commons.io.IOUtils import org.joda.time.{DateTime, DateTimeZone} -import play.api.Logger -import play.api.libs.ws.WSRequest -import scala.jdk.CollectionConverters._ +import java.io.{File, FileOutputStream} import scala.concurrent.duration.Duration import scala.concurrent.{Await, ExecutionContext, Future} +import scala.jdk.CollectionConverters._ object Projector { import Uploader.toImageUploadOpsCfg - def apply(config: ImageLoaderConfig, imageOps: ImageOperations, processor: ImageProcessor, auth: Authentication)(implicit ec: ExecutionContext): Projector - = new Projector(toImageUploadOpsCfg(config), S3Ops.buildS3Client(config), imageOps, processor, auth) + def apply(config: ImageLoaderConfig, imageOps: ImageOperations, processor: ImageProcessor, auth: Authentication, maybeEmbedder: Option[Embedder], s3: S3, optimiseOps: OptimiseOps)(implicit ec: ExecutionContext): Projector + = new Projector(toImageUploadOpsCfg(config), s3, imageOps, processor, auth, maybeEmbedder, optimiseOps) } case class S3FileExtractedMetadata( uploadedBy: String, uploadTime: DateTime, uploadFileName: Option[String], - identifiers: Map[String, String] + identifiers: Map[String, String], + isFeedUpload: Option[Boolean], ) object S3FileExtractedMetadata { @@ -69,6 +67,7 @@ object S3FileExtractedMetadata { }.map{ case (key, value) => key.stripPrefix(ImageStorageProps.identifierMetadataKeyPrefix) -> value } + val isFeedUpload = fileUserMetadata.get(ImageStorageProps.isFeedUploadMetadataKey).map(_.toBoolean) val uploadFileName = fileUserMetadata.get(ImageStorageProps.filenameMetadataKey) @@ -77,26 +76,29 @@ object S3FileExtractedMetadata { uploadTime = uploadTime, uploadFileName = uploadFileName, identifiers = identifiers, + isFeedUpload = isFeedUpload, ) } } class Projector(config: ImageUploadOpsCfg, - s3: AmazonS3, + s3: S3, imageOps: ImageOperations, processor: ImageProcessor, - auth: Authentication) extends GridLogging { + auth: Authentication, + maybeEmbedder: Option[Embedder], + optimiseOps: OptimiseOps) extends GridLogging with InstanceForRequest { - private val imageUploadProjectionOps = new ImageUploadProjectionOps(config, imageOps, processor, s3) + private val imageUploadProjectionOps = new ImageUploadProjectionOps(config, imageOps, processor, s3, maybeEmbedder, optimiseOps) def projectS3ImageById(imageId: String, tempFile: File, gridClient: GridClient, onBehalfOfFn: WSRequest => WSRequest) - (implicit ec: ExecutionContext, logMarker: LogMarker): Future[Option[Image]] = { + (implicit ec: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[Option[Image]] = { Future { import ImageIngestOperations.fileKeyFromId val s3Key = fileKeyFromId(imageId) if (!s3.doesObjectExist(config.originalFileBucket, s3Key)) - throw new NoSuchImageExistsInS3(config.originalFileBucket, s3Key) + throw new NoSuchImageExistsInS3(config.originalFileBucket.bucket, s3Key) val s3Source = Stopwatch(s"object exists, getting s3 object at s3://${config.originalFileBucket}/$s3Key to perform Image projection"){ s3.getObject(config.originalFileBucket, s3Key) @@ -130,11 +132,11 @@ class Projector(config: ImageUploadOpsCfg, extractedS3Meta: S3FileExtractedMetadata, gridClient: GridClient, onBehalfOfFn: WSRequest => WSRequest) - (implicit ec: ExecutionContext, logMarker: LogMarker): Future[Image] = { + (implicit ec: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[Image] = { val DigestedFile(tempFile_, id_) = srcFileDigest val identifiers_ = extractedS3Meta.identifiers - val uploadInfo_ = UploadInfo(filename = extractedS3Meta.uploadFileName) + val uploadInfo_ = UploadInfo(filename = extractedS3Meta.uploadFileName, isFeedUpload = extractedS3Meta.isFeedUpload) MimeTypeDetection.guessMimeType(tempFile_) match { case util.Left(unsupported) => Future.failed(unsupported) @@ -146,7 +148,8 @@ class Projector(config: ImageUploadOpsCfg, uploadTime = extractedS3Meta.uploadTime, uploadedBy = extractedS3Meta.uploadedBy, identifiers = identifiers_, - uploadInfo = uploadInfo_ + uploadInfo = uploadInfo_, + instance = instance, // TODO careful with this one! ) imageUploadProjectionOps.projectImageFromUploadRequest(uploadRequest) flatMap ( @@ -159,15 +162,17 @@ class Projector(config: ImageUploadOpsCfg, class ImageUploadProjectionOps(config: ImageUploadOpsCfg, imageOps: ImageOperations, processor: ImageProcessor, - s3: AmazonS3 + s3: S3, + maybeEmbedder: Option[Embedder], + optimiseOps: OptimiseOps ) extends GridLogging { - import Uploader.{fromUploadRequestShared, toMetaMap} + import Uploader.fromUploadRequestShared def projectImageFromUploadRequest(uploadRequest: UploadRequest) (implicit ec: ExecutionContext, logMarker: LogMarker): Future[Image] = { - val dependenciesWithProjectionsOnly = ImageUploadOpsDependencies( + val dependenciesWithProjectionsOnly: ImageUploadOpsDependencies = ImageUploadOpsDependencies( config, imageOps, projectOriginalFileAsS3Model, @@ -175,9 +180,18 @@ class ImageUploadProjectionOps(config: ImageUploadOpsCfg, projectOptimisedPNGFileAsS3Model, tryFetchThumbFile = fetchThumbFile, tryFetchOptimisedFile = fetchOptimisedFile, + queueImageToEmbed = queueImageToEmbed ) - fromUploadRequestShared(uploadRequest, dependenciesWithProjectionsOnly, processor) + fromUploadRequestShared(uploadRequest, dependenciesWithProjectionsOnly, processor, optimiseOps) + } + + private def queueImageToEmbed(message: EmbedderMessage)(implicit logMarker: LogMarker): Unit = { + maybeEmbedder match { + case Some(embedder) => + embedder.queueImageToEmbed(message) + case None => () + } } private def projectOriginalFileAsS3Model(storableOriginalImage: StorableOriginalImage) = @@ -190,23 +204,21 @@ class ImageUploadProjectionOps(config: ImageUploadOpsCfg, Future.successful(storableOptimisedImage.toProjectedS3Object(config.originalFileBucket)) private def fetchThumbFile( - imageId: String, outFile: File - )(implicit ec: ExecutionContext, logMarker: LogMarker): Future[Option[(File, MimeType)]] = { - val key = fileKeyFromId(imageId) - + imageId: String, outFile: File, instance: Instance)(implicit ec: ExecutionContext, logMarker: LogMarker): Future[Option[(File, MimeType)]] = { + val key = fileKeyFromId(imageId)(instance) fetchFile(config.thumbBucket, key, outFile) } private def fetchOptimisedFile( - imageId: String, outFile: File + imageId: String, outFile: File, instance: Instance )(implicit ec: ExecutionContext, logMarker: LogMarker): Future[Option[(File, MimeType)]] = { - val key = optimisedPngKeyFromId(imageId) + val key = optimisedPngKeyFromId(imageId)(instance) fetchFile(config.originalFileBucket, key, outFile) } private def fetchFile( - bucket: String, key: String, outFile: File + bucket: S3Bucket, key: String, outFile: File )(implicit ec: ExecutionContext, logMarker: LogMarker): Future[Option[(File, MimeType)]] = { logger.info(logMarker, s"Trying fetch existing image from S3 bucket - $bucket at key $key") val doesFileExist = Future { s3.doesObjectExist(bucket, key) } recover { case _ => false } @@ -215,7 +227,7 @@ class ImageUploadProjectionOps(config: ImageUploadOpsCfg, logger.warn(logMarker, s"image did not exist in bucket $bucket at key $key") Future.successful(None) // falls back to creating from original file case true => - val obj = s3.getObject(new GetObjectRequest(bucket, key)) + val obj = s3.getObject(bucket, key) val fos = new FileOutputStream(outFile) try { IOUtils.copy(obj.getObjectContent, fos) diff --git a/image-loader/app/model/QuarantineUploader.scala b/image-loader/app/model/QuarantineUploader.scala index af75e44a73c..b18079981ee 100644 --- a/image-loader/app/model/QuarantineUploader.scala +++ b/image-loader/app/model/QuarantineUploader.scala @@ -18,6 +18,8 @@ import play.api.Logger import play.api.libs.json.{JsObject, Json} import com.gu.mediaservice.lib.formatting._ import model.upload.UploadRequest +import play.api.mvc.{AnyContent, Request} + import java.net.URLEncoder import java.nio.charset.StandardCharsets import scala.concurrent.{ExecutionContext, Future} @@ -28,6 +30,7 @@ class QuarantineUploader(val store: QuarantineStore, private def storeQuarantineFile(uploadRequest: UploadRequest) (implicit logMarker: LogMarker) = { + implicit val instance: Instance = uploadRequest.instance val meta = Uploader.toMetaMap(uploadRequest) store.storeQuarantineImage( uploadRequest.imageId, @@ -37,7 +40,7 @@ class QuarantineUploader(val store: QuarantineStore, ) } - def quarantineFile(uploadRequest: UploadRequest)( + def quarantineFile(uploadRequest: UploadRequest)(instance: Instance) ( implicit ec: ExecutionContext, logMarker: LogMarker): Future[JsObject] = { @@ -45,7 +48,7 @@ class QuarantineUploader(val store: QuarantineStore, for { _ <- storeQuarantineFile(uploadRequest) - uri = s"${config.rootUri}/uploadStatus/${uploadRequest.imageId}" + uri = s"${config.rootUri(instance)}/uploadStatus/${uploadRequest.imageId}" } yield { Json.obj("uri" -> uri) } diff --git a/image-loader/app/model/S3IngestObject.scala b/image-loader/app/model/S3IngestObject.scala index baa0528b236..0dcfeed0fa7 100644 --- a/image-loader/app/model/S3IngestObject.scala +++ b/image-loader/app/model/S3IngestObject.scala @@ -12,7 +12,8 @@ case class S3IngestObject ( maybeMediaIdFromUiUpload: Option[String], uploadTime: java.util.Date, contentLength: Long, - getInputStream: () => java.io.InputStream + getInputStream: () => java.io.InputStream, + isFeedUpload: Boolean, ) object S3IngestObject { @@ -24,15 +25,29 @@ object S3IngestObject { val s3Object = store.getS3Object(key) val metadata = s3Object.getObjectMetadata + val mediaIdFromUiUpload = metadata.getUserMetadata.asScala.get("media-id") + val isFeedUpload = mediaIdFromUiUpload.isEmpty // TODO Not concise + S3IngestObject( key, - uploadedBy = keyParts.head, + uploadedBy = uploadedFromPath(keyParts), filename = keyParts.last, - maybeMediaIdFromUiUpload = metadata.getUserMetadata.asScala.get("media-id"), // set by the client in upload in manager.js + maybeMediaIdFromUiUpload = mediaIdFromUiUpload, // set by the client in upload in manager.js uploadTime = metadata.getLastModified, contentLength = metadata.getContentLength, - getInputStream = () => s3Object.getObjectContent + getInputStream = () => s3Object.getObjectContent, + isFeedUpload = isFeedUpload ) } + + def uploadedFromPath(keyParts: Array[String]): String = { + val indexOfFeedProviderName = if (keyParts.contains("feeds")) { + keyParts.indexOf("feeds") + 1 + } else { + 0 + } + + keyParts(Seq(indexOfFeedProviderName, 0).max) + } } diff --git a/image-loader/app/model/UploadStatus.scala b/image-loader/app/model/UploadStatus.scala index 044acacf713..af28678e23f 100644 --- a/image-loader/app/model/UploadStatus.scala +++ b/image-loader/app/model/UploadStatus.scala @@ -10,7 +10,8 @@ case class UploadStatusRecord( identifiers: Option[String], status: StatusType, errorMessage: Option[String], - expires: Long + expires: Long, + instance: String ) object UploadStatusRecord { diff --git a/image-loader/app/model/Uploader.scala b/image-loader/app/model/Uploader.scala index 38e15744356..e1026967233 100644 --- a/image-loader/app/model/Uploader.scala +++ b/image-loader/app/model/Uploader.scala @@ -1,37 +1,30 @@ package model -import com.gu.mediaservice.{GridClient, ImageDataMerger} +import _root_.play.api.libs.json.Json +import _root_.play.api.libs.ws.WSRequest import com.gu.mediaservice.lib.Files.createTempFile - -import java.io.File -import java.net.URLEncoder -import java.nio.charset.StandardCharsets -import java.nio.file.{Files, Paths} -import java.util.UUID +import com.gu.mediaservice.lib._ import com.gu.mediaservice.lib.argo.ArgoHelpers -import com.gu.mediaservice.lib.auth.Authentication -import com.gu.mediaservice.lib.auth.Authentication.Principal -import com.gu.mediaservice.lib.{BrowserViewableImage, ImageStorageProps, StorableOptimisedImage, StorableOriginalImage, StorableThumbImage} -import com.gu.mediaservice.lib.aws.{S3Object, UpdateMessage} +import com.gu.mediaservice.lib.aws._ import com.gu.mediaservice.lib.cleanup.ImageProcessor import com.gu.mediaservice.lib.formatting._ import com.gu.mediaservice.lib.imaging.ImageOperations import com.gu.mediaservice.lib.imaging.ImageOperations.{optimisedMimeType, thumbMimeType} import com.gu.mediaservice.lib.logging._ -import com.gu.mediaservice.lib.metadata.{FileMetadataHelper, ImageMetadataConverter} +import com.gu.mediaservice.lib.metadata.ImageMetadataConverter import com.gu.mediaservice.lib.net.URI import com.gu.mediaservice.model._ import com.gu.mediaservice.syntax.MessageSubjects -import lib.{DigestedFile, ImageLoaderConfig, Notifications} +import com.gu.mediaservice.{GridClient, ImageDataMerger} import lib.imaging.{FileMetadataReader, MimeTypeDetection} import lib.storage.ImageLoaderStore +import lib.{DigestedFile, ImageLoaderConfig, Notifications} import model.Uploader.{fromUploadRequestShared, toImageUploadOpsCfg} -import model.upload.{OptimiseOps, OptimiseWithPngQuant, UploadRequest} +import model.upload.{OptimiseOps, UploadRequest} import org.joda.time.DateTime -import play.api.libs.json.{JsObject, Json} -import play.api.libs.ws.WSRequest -import scala.collection.compat._ +import java.io.File +import java.nio.file.Files import scala.concurrent.{ExecutionContext, Future} case class ImageUpload(uploadRequest: UploadRequest, image: Image) @@ -68,9 +61,8 @@ case class ImageUploadOpsCfg( tempDir: File, thumbWidth: Int, thumbQuality: Double, - transcodedMimeTypes: List[MimeType], - originalFileBucket: String, - thumbBucket: String + originalFileBucket: S3Bucket, + thumbBucket: S3Bucket, ) case class ImageUploadOpsDependencies( @@ -79,10 +71,12 @@ case class ImageUploadOpsDependencies( storeOrProjectOriginalFile: StorableOriginalImage => Future[S3Object], storeOrProjectThumbFile: StorableThumbImage => Future[S3Object], storeOrProjectOptimisedImage: StorableOptimisedImage => Future[S3Object], - tryFetchThumbFile: (String, File) => Future[Option[(File, MimeType)]] = (_, _) => Future.successful(None), - tryFetchOptimisedFile: (String, File) => Future[Option[(File, MimeType)]] = (_, _) => Future.successful(None), + tryFetchThumbFile: (String, File, Instance) => Future[Option[(File, MimeType)]] = (_, _, _) => Future.successful(None), + tryFetchOptimisedFile: (String, File, Instance) => Future[Option[(File, MimeType)]] = (_, _, _) => Future.successful(None), + queueImageToEmbed: (EmbedderMessage) => Unit ) + case class UploadStatusUri (uri: String) extends AnyVal { def toJsObject = Json.obj("uri" -> uri) } @@ -94,44 +88,37 @@ object Uploader extends GridLogging { config.tempDir, config.thumbWidth, config.thumbQuality, - config.transcodedMimeTypes, config.imageBucket, - config.thumbnailBucket + config.thumbnailBucket, ) } - def fromUploadRequestShared(uploadRequest: UploadRequest, deps: ImageUploadOpsDependencies, processor: ImageProcessor) + def fromUploadRequestShared(uploadRequest: UploadRequest, deps: ImageUploadOpsDependencies, processor: ImageProcessor, optimiseOps: OptimiseOps) (implicit ec: ExecutionContext, logMarker: LogMarker): Future[Image] = { import deps._ logger.info(logMarker, "Starting image ops") - val fileMetadataFuture = toFileMetadata(uploadRequest.tempFile, uploadRequest.imageId, uploadRequest.mimeType) - - logger.info(logMarker, "Have read file headers") - - fileMetadataFuture.flatMap(fileMetadata => { - uploadAndStoreImage( - storeOrProjectOriginalFile, - storeOrProjectThumbFile, - storeOrProjectOptimisedImage, - OptimiseWithPngQuant, - uploadRequest, - deps, - fileMetadata, - processor)(ec, addLogMarkers(fileMetadata.toLogMarker)) - }) + uploadAndStoreImage( + storeOrProjectOriginalFile, + storeOrProjectThumbFile, + storeOrProjectOptimisedImage, + queueImageToEmbed, + uploadRequest, + deps, + processor, + optimiseOps) } private[model] def uploadAndStoreImage(storeOrProjectOriginalFile: StorableOriginalImage => Future[S3Object], storeOrProjectThumbFile: StorableThumbImage => Future[S3Object], storeOrProjectOptimisedFile: StorableOptimisedImage => Future[S3Object], - optimiseOps: OptimiseOps, + queueImageToEmbed: (EmbedderMessage) => Unit, uploadRequest: UploadRequest, deps: ImageUploadOpsDependencies, - fileMetadata: FileMetadata, - processor: ImageProcessor) + processor: ImageProcessor, + optimiseOps: OptimiseOps) (implicit ec: ExecutionContext, logMarker: LogMarker) = { val originalMimeType = uploadRequest.mimeType .orElse(MimeTypeDetection.guessMimeType(uploadRequest.tempFile).toOption) @@ -139,44 +126,52 @@ object Uploader extends GridLogging { case Some(a) => a case None => throw new Exception("File of unknown and undetectable mime type") } + logger.info("Original Mime type: " + originalMimeType) val tempDirForRequest: File = Files.createTempDirectory(deps.config.tempDir.toPath, "upload").toFile - val colourModelFuture = ImageOperations.identifyColourModel(uploadRequest.tempFile, originalMimeType) - val sourceDimensionsFuture = FileMetadataReader.dimensions(uploadRequest.tempFile, Some(originalMimeType)) - val sourceOrientationMetadataFuture = FileMetadataReader.orientation(uploadRequest.tempFile) - val storableOriginalImage = StorableOriginalImage( uploadRequest.imageId, uploadRequest.tempFile, originalMimeType, uploadRequest.uploadTime, - toMetaMap(uploadRequest) + toMetaMap(uploadRequest), + uploadRequest.instance ) val sourceStoreFuture = storeOrProjectOriginalFile(storableOriginalImage) - val eventualBrowserViewableImage = createBrowserViewableFileFuture(uploadRequest, tempDirForRequest, deps) + val eventualBrowserViewableImage = createBrowserViewableFileFuture(uploadRequest) val eventualImage = for { browserViewableImage <- eventualBrowserViewableImage s3Source <- sourceStoreFuture mergedUploadRequest = patchUploadRequestWithS3Metadata(uploadRequest, s3Source) - optimisedFileMetadata <- FileMetadataReader.fromIPTCHeadersWithColorInfo(browserViewableImage) - sourceDimensions <- sourceDimensionsFuture - sourceOrientationMetadata <- sourceOrientationMetadataFuture - thumbViewableImage <- createThumbFuture(optimisedFileMetadata, colourModelFuture, browserViewableImage, deps, tempDirForRequest, orientationMetadata = sourceOrientationMetadata) + imageInformation <- ImageOperations.getImageInformation(uploadRequest.tempFile) + sourceDimensions = imageInformation._1 + sourceOrientationMetadata = imageInformation._2 + colourModel = imageInformation._3 + colourModelInformation = imageInformation._4 + fileMetadata <- toFileMetadata(uploadRequest.tempFile, uploadRequest.imageId, uploadRequest.mimeType) + thumbViewableImageAndDimensions <- createThumbFuture(browserViewableImage, deps, tempDirForRequest, uploadRequest.instance, orientationMetadata = sourceOrientationMetadata) + thumbViewableImage = thumbViewableImageAndDimensions._1 + maybeThumbDimensions = thumbViewableImageAndDimensions._2 + thumbDimensions <- { + maybeThumbDimensions.map { dimensions => + Future.successful(Some(dimensions)) + }.getOrElse { + ImageOperations.getImageInformation(thumbViewableImage.file).map(_._1) + } + } s3Thumb <- storeOrProjectThumbFile(thumbViewableImage) maybeStorableOptimisedImage <- getStorableOptimisedImage( - tempDirForRequest, optimiseOps, browserViewableImage, optimisedFileMetadata, deps.tryFetchOptimisedFile) + tempDirForRequest, browserViewableImage, deps.tryFetchOptimisedFile, optimiseOps, uploadRequest.instance) s3PngOption <- maybeStorableOptimisedImage match { case Some(storableOptimisedImage) => storeOrProjectOptimisedFile(storableOptimisedImage).map(a=>Some(a)) case None => Future.successful(None) } - thumbDimensions <- FileMetadataReader.dimensions(thumbViewableImage.file, Some(thumbViewableImage.mimeType)) - colourModel <- colourModelFuture } yield { - val fullFileMetadata = fileMetadata.copy(colourModel = colourModel) + val fullFileMetadata = fileMetadata.copy(colourModel = colourModel).copy(colourModelInformation = colourModelInformation) val metadata = ImageMetadataConverter.fromFileMetadata(fullFileMetadata, s3Source.metadata.objectMetadata.lastModified) - + val sourceAsset = Asset.fromS3Object(s3Source, sourceDimensions, sourceOrientationMetadata) val thumbAsset = Asset.fromS3Object(s3Thumb, thumbDimensions) @@ -185,31 +180,49 @@ object Uploader extends GridLogging { val processedImage = processor(baseImage) - logger.info(logMarker, s"Ending image ops") + logger.info(addLogMarkers(fileMetadata.toLogMarker), s"Ending image ops") // FIXME: dirty hack to sync the originalUsageRights and originalMetadata as well - processedImage.copy( + val finalImage = processedImage.copy( originalMetadata = processedImage.metadata, originalUsageRights = processedImage.usageRights ) + + val s3Bucket = s3Source.uri.getHost.split('.').head + val s3Key = s3Source.uri.getPath.stripPrefix("/") + + // Return both the image and the S3 path needed for embedding + (finalImage, s3Bucket, s3Key) } - eventualImage.onComplete{ _ => + eventualImage.onComplete{ imageFuture => tempDirForRequest.listFiles().map(f => f.delete()) tempDirForRequest.delete() + + imageFuture match { + case scala.util.Success((_, s3Bucket, s3Key)) => + queueImageToEmbed( + EmbedderMessage(uploadRequest.imageId, originalMimeType.name, s3Bucket, s3Key) + ) + case scala.util.Failure(exception) => + logger.error( + logMarker, s"Image upload failed, not queueing for embedding: ${exception.getMessage}" + ) + } } - eventualImage + // Map to return just the finalImage + eventualImage.map(_._1) } private def getStorableOptimisedImage( tempDir: File, - optimiseOps: OptimiseOps, browserViewableImage: BrowserViewableImage, - optimisedFileMetadata: FileMetadata, - tryFetchOptimisedFile: (String, File) => Future[Option[(File, MimeType)]] + tryFetchOptimisedFile: (String, File, Instance) => Future[Option[(File, MimeType)]], + optimiseOps: OptimiseOps, + instance: Instance )(implicit ec: ExecutionContext, logMarker: LogMarker): Future[Option[StorableOptimisedImage]] = { - if (optimiseOps.shouldOptimise(Some(browserViewableImage.mimeType), optimisedFileMetadata)) { + if (optimiseOps.shouldOptimise(Some(browserViewableImage.mimeType))) { for { tempFile <- createTempFile("optimisedpng-", optimisedMimeType.fileExtension, tempDir) - maybeDownloadedOptimisedFile <- tryFetchOptimisedFile(browserViewableImage.id, tempFile) + maybeDownloadedOptimisedFile <- tryFetchOptimisedFile(browserViewableImage.id, tempFile, instance) (optimisedFile, optimisedMimeType) <- { maybeDownloadedOptimisedFile match { case Some(optData) => Future.successful(optData) @@ -231,41 +244,41 @@ object Uploader extends GridLogging { def toMetaMap(uploadRequest: UploadRequest): Map[String, String] = { val baseMeta = Map( ImageStorageProps.uploadedByMetadataKey -> uploadRequest.uploadedBy, - ImageStorageProps.uploadTimeMetadataKey -> printDateTime(uploadRequest.uploadTime) + ImageStorageProps.uploadTimeMetadataKey -> printDateTime(uploadRequest.uploadTime), ) ++ uploadRequest.identifiersMeta ++ - uploadRequest.uploadInfo.filename.map(ImageStorageProps.filenameMetadataKey -> _) + uploadRequest.uploadInfo.filename.map(ImageStorageProps.filenameMetadataKey -> _) ++ + uploadRequest.uploadInfo.isFeedUpload.map(ImageStorageProps.isFeedUploadMetadataKey -> _.toString) baseMeta.view.mapValues(URI.encode).toMap } - private def toFileMetadata(f: File, imageId: String, mimeType: Option[MimeType])(implicit logMarker: LogMarker): Future[FileMetadata] = { - mimeType match { - case Some(Png | Tiff | Jpeg) => FileMetadataReader.fromIPTCHeadersWithColorInfo(f, imageId, mimeType.get) + private def toFileMetadata(f: File, imageId: String, mimeType: Option[MimeType])(implicit ec: ExecutionContext, logMarker: LogMarker): Future[FileMetadata] = { + val stopwatch = Stopwatch.start + (mimeType match { + //case Some(Png | Tiff | Jpeg) => FileMetadataReader.fromIPTCHeadersWithColorInfo(f, imageId, mimeType.get) case _ => FileMetadataReader.fromIPTCHeaders(f, imageId) + }).map { result => + logger.info(addLogMarkers(stopwatch.elapsed), "Finished toFileMetadata") + result } } - private def createThumbFuture(fileMetadata: FileMetadata, - colourModelFuture: Future[Option[String]], - browserViewableImage: BrowserViewableImage, + private def createThumbFuture(browserViewableImage: BrowserViewableImage, deps: ImageUploadOpsDependencies, tempDir: File, - orientationMetadata: Option[OrientationMetadata], - )(implicit ec: ExecutionContext, logMarker: LogMarker) = { + instance: Instance, + orientationMetadata: Option[OrientationMetadata] + )(implicit ec: ExecutionContext, logMarker: LogMarker): Future[(StorableThumbImage, Option[Dimensions])] = { import deps._ def generateThumbnail(tempFile: File) = { for { - colourModel <- colourModelFuture - iccColourSpace = FileMetadataHelper.normalisedIccColourSpace(fileMetadata) - thumbData <- imageOps.createThumbnail( + thumbData <- imageOps.createThumbnailVips( browserViewableImage, config.thumbWidth, config.thumbQuality, tempFile, - iccColourSpace, - colourModel, orientationMetadata, ) } yield thumbData @@ -273,40 +286,31 @@ object Uploader extends GridLogging { for { tempFile <- createTempFile(s"thumb-", thumbMimeType.fileExtension, tempDir) - maybeThumbFile <- deps.tryFetchThumbFile(browserViewableImage.id, tempFile) - (thumb, thumbMimeType) <- { + maybeThumbFile <- deps.tryFetchThumbFile(browserViewableImage.id, tempFile, instance) + (thumb, thumbMimeType, thumbDimensions) <- { maybeThumbFile match { - case Some(thumbData) => Future.successful(thumbData) + case Some(thumbData) => Future.successful((thumbData._1, thumbData._2, None)) case None => generateThumbnail(tempFile) } } - } yield browserViewableImage - .copy(file = thumb, mimeType = thumbMimeType) - .asStorableThumbImage + } yield { + (browserViewableImage + .copy(file = thumb, mimeType = thumbMimeType) + .asStorableThumbImage, thumbDimensions) + } } private def createBrowserViewableFileFuture( - uploadRequest: UploadRequest, - tempDir: File, - deps: ImageUploadOpsDependencies + uploadRequest: UploadRequest )(implicit ec: ExecutionContext, logMarker: LogMarker): Future[BrowserViewableImage] = { - import deps._ uploadRequest.mimeType match { - case Some(mime) if config.transcodedMimeTypes.contains(mime) => - for { - (file, mimeType) <- imageOps.transformImage(uploadRequest.tempFile, uploadRequest.mimeType, tempDir) - } yield BrowserViewableImage( - uploadRequest.imageId, - file = file, - mimeType = mimeType, - isTransformedFromSource = true - ) case Some(mimeType) => Future.successful( BrowserViewableImage( uploadRequest.imageId, file = uploadRequest.tempFile, - mimeType = mimeType) + mimeType = mimeType, + instance = uploadRequest.instance) ) case None => Future.failed(new Exception("This file is not an image with an identifiable mime type")) } @@ -327,19 +331,30 @@ class Uploader(val store: ImageLoaderStore, val config: ImageLoaderConfig, val imageOps: ImageOperations, val notifications: Notifications, - imageProcessor: ImageProcessor) + val maybeEmbedder: Option[Embedder], + imageProcessor: ImageProcessor, + optimiseOps: OptimiseOps) (implicit val ec: ExecutionContext) extends MessageSubjects with ArgoHelpers { def fromUploadRequest(uploadRequest: UploadRequest) (implicit logMarker: LogMarker): Future[ImageUpload] = { val sideEffectDependencies = ImageUploadOpsDependencies(toImageUploadOpsCfg(config), imageOps, - storeSource, storeThumbnail, storeOptimisedImage) + storeSource, storeThumbnail, storeOptimisedImage, queueImageToEmbed = queueImageToEmbed) Stopwatch.async("finalImage") { - val finalImage = fromUploadRequestShared(uploadRequest, sideEffectDependencies, imageProcessor) + val finalImage = fromUploadRequestShared(uploadRequest, sideEffectDependencies, imageProcessor, optimiseOps) finalImage.map(img => ImageUpload(uploadRequest, img)) } } + private def queueImageToEmbed(message: EmbedderMessage)(implicit logMarker: LogMarker): Unit = { + maybeEmbedder match { + case Some(embedder) => + logger.info(logMarker, s"Queueing image ${message.imageId} for embedding") + embedder.queueImageToEmbed(message) + case None => () + } + } + private def storeSource(storableOriginalImage: StorableOriginalImage) (implicit logMarker: LogMarker) = store.store(storableOriginalImage) @@ -353,7 +368,9 @@ class Uploader(val store: ImageLoaderStore, uploadedBy: String, identifiers: Option[String], uploadTime: DateTime, - filename: Option[String]) + filename: Option[String], + instance: Instance, + isFeedUpload: Boolean) (implicit ec:ExecutionContext, logMarker: LogMarker): Future[UploadRequest] = Future { val DigestedFile(tempFile, id) = digestedFile @@ -379,23 +396,24 @@ class Uploader(val store: ImageLoaderStore, uploadTime = uploadTime, uploadedBy = uploadedBy, identifiers = identifiersMap, - uploadInfo = UploadInfo(filename) + uploadInfo = UploadInfo(filename, Some(isFeedUpload)), + instance = instance ) } } def storeFile(uploadRequest: UploadRequest) (implicit ec:ExecutionContext, - logMarker: LogMarker): Future[UploadStatusUri] = { + logMarker: LogMarker, instance: Instance): Future[UploadStatusUri] = { logger.info(logMarker, "Storing file") for { imageUpload <- fromUploadRequest(uploadRequest) - updateMessage = UpdateMessage(subject = Image, image = Some(imageUpload.image)) + updateMessage = UpdateMessage(subject = Image, image = Some(imageUpload.image), instance = uploadRequest.instance) _ <- Future { notifications.publish(updateMessage) } // TODO: centralise where all these URLs are constructed - } yield UploadStatusUri(s"${config.rootUri}/uploadStatus/${uploadRequest.imageId}") + } yield UploadStatusUri(s"${config.rootUri(instance)}/uploadStatus/${uploadRequest.imageId}") } @@ -403,13 +421,14 @@ class Uploader(val store: ImageLoaderStore, gridClient: GridClient, onBehalfOfFn: WSRequest => WSRequest) (implicit ec: ExecutionContext, - logMarker: LogMarker): Future[Unit] = for { + logMarker: LogMarker, + instance: Instance): Future[Unit] = for { imageUpload <- fromUploadRequest(uploadRequest) imageWithoutUserEdits = imageUpload.image imageWithUserEditsApplied <- ImageDataMerger.aggregate(imageWithoutUserEdits, gridClient, onBehalfOfFn) _ <- Future { notifications.publish( - UpdateMessage(subject = Image, image = Some(imageWithUserEditsApplied)) + UpdateMessage(subject = Image, image = Some(imageWithUserEditsApplied), instance = uploadRequest.instance) ) } } yield () diff --git a/image-loader/app/model/upload/OptimiseOps.scala b/image-loader/app/model/upload/OptimiseOps.scala index f618b47249e..887c20b96fd 100644 --- a/image-loader/app/model/upload/OptimiseOps.scala +++ b/image-loader/app/model/upload/OptimiseOps.scala @@ -1,25 +1,24 @@ package model.upload -import java.io.File - -import com.gu.mediaservice.lib.{ImageWrapper, StorableImage} -import com.gu.mediaservice.lib.logging.{LogMarker, Stopwatch} -import com.gu.mediaservice.model.{FileMetadata, MimeType, Png, Tiff} -import com.gu.mediaservice.lib.logging.MarkerMap +import app.photofox.vipsffm.enums.VipsIntent +import app.photofox.vipsffm.{VImage, VipsHelper, VipsOption} +import com.gu.mediaservice.lib.ImageWrapper +import com.gu.mediaservice.lib.imaging.ImageOperations +import com.gu.mediaservice.lib.logging.{LogMarker, MarkerMap, Stopwatch} +import com.gu.mediaservice.model.{MimeType, Png, Tiff} +import java.io.File +import java.lang.foreign.Arena import scala.concurrent.{ExecutionContext, Future} -import scala.sys.process._ -import scala.jdk.CollectionConverters._ trait OptimiseOps { def toOptimisedFile(file: File, imageWrapper: ImageWrapper, tempDir: File) (implicit ec: ExecutionContext, logMarker: LogMarker): Future[(File, MimeType)] - def isTransformedFilePath(filePath: String): Boolean - def shouldOptimise(mimeType: Option[MimeType], fileMetadata: FileMetadata): Boolean + def shouldOptimise(mimeType: Option[MimeType]): Boolean def optimiseMimeType: MimeType } -object OptimiseWithPngQuant extends OptimiseOps { +class OptimiseWithPngQuant(imageOperations: ImageOperations) extends OptimiseOps { override def optimiseMimeType: MimeType = Png @@ -27,35 +26,40 @@ object OptimiseWithPngQuant extends OptimiseOps { (implicit ec: ExecutionContext, logMarker: LogMarker): Future[(File, MimeType)] = Future { val marker = MarkerMap( - "fileName" -> file.getName() + "fileName" -> file.getName ) - Stopwatch("pngquant") { - val result = Seq("pngquant", "-s10", "--quality", "1-85", file.getAbsolutePath, - "--force", "--output", optimisedFile.getAbsolutePath - ).! - if (result > 0) - throw new Exception(s"pngquant failed to convert to optimised png file (rc = $result)") - }(marker) + // Given a source file on any valid upload type, return a file of the optimised type + Stopwatch("toOptimisedFile") { + try { + val arena = Arena.ofConfined - if (optimisedFile.exists()) { - (optimisedFile, optimiseMimeType) - } else { - throw new Exception(s"Attempted to optimise PNG file ${optimisedFile.getPath}") - } - } + val image = VImage.newFromFile(arena, file.getAbsolutePath) - def isTransformedFilePath(filePath: String): Boolean = filePath.contains("transformed-") + // If we saw and ICC profile than we will need to transform + val needsICCTransform = VipsHelper.image_get_typeof(arena, image.getUnsafeStructAddress, "icc-profile-data") != 0 + val correctedForICCProfile = if (needsICCTransform) { + image.iccTransform("srgb", + VipsOption.Enum("intent", VipsIntent.INTENT_PERCEPTUAL), // Helps with CMYK; see https://github.com/libvips/libvips/issues/1110 + ) + } else { + // LAB gets corrupted by a needless icc_transform + image + } + + imageOperations.saveImageToFile(correctedForICCProfile: VImage, optimiseMimeType, 85, optimisedFile, quantise = true) + (optimisedFile, optimiseMimeType) + } catch { + case _: Exception => + throw new Exception(s"Failed to optimise PNG file ${file.getAbsolutePath}") + } + }(marker) + } - def shouldOptimise(mimeType: Option[MimeType], fileMetadata: FileMetadata): Boolean = + def shouldOptimise(mimeType: Option[MimeType]): Boolean = { mimeType match { - case Some(Png) => - fileMetadata.colourModelInformation.get("colorType") match { - case Some("True Color") => true - case Some("True Color with Alpha") => true - case _ => false - } - case Some(Tiff) => true // TODO This should be done better, it could be better optimised into a jpeg if there is no transparency. + case Some(Tiff) => true case _ => false } + } } diff --git a/image-loader/app/model/upload/UploadRequest.scala b/image-loader/app/model/upload/UploadRequest.scala index efe3f381e3f..57b83e72c0c 100644 --- a/image-loader/app/model/upload/UploadRequest.scala +++ b/image-loader/app/model/upload/UploadRequest.scala @@ -4,7 +4,7 @@ import com.gu.mediaservice.lib.ImageStorageProps import java.io.File import java.util.UUID -import com.gu.mediaservice.model.{MimeType, UploadInfo} +import com.gu.mediaservice.model.{Instance, MimeType, UploadInfo} import net.logstash.logback.marker.{LogstashMarker, Markers} import org.joda.time.format.ISODateTimeFormat import org.joda.time.{DateTime, DateTimeZone} @@ -18,7 +18,8 @@ case class UploadRequest( uploadTime: DateTime, uploadedBy: String, identifiers: Map[String, String], - uploadInfo: UploadInfo + uploadInfo: UploadInfo, + instance: Instance ) { val identifiersMeta: Map[String, String] = identifiers.map { case (k, v) => diff --git a/image-loader/conf/org/apache/tika/mime/custom-mimetypes.xml b/image-loader/conf/org/apache/tika/mime/custom-mimetypes.xml deleted file mode 100644 index 3f92a6ff6d7..00000000000 --- a/image-loader/conf/org/apache/tika/mime/custom-mimetypes.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - <_comment>Canon raw image - - - - - - - diff --git a/image-loader/conf/routes b/image-loader/conf/routes index 9987bc648c4..7b7b7758709 100644 --- a/image-loader/conf/routes +++ b/image-loader/conf/routes @@ -15,7 +15,6 @@ GET /uploadStatuses/:userId controllers.UploadStatusCo # Management GET /management/healthcheck com.gu.mediaservice.lib.management.Management.healthCheck GET /management/manifest com.gu.mediaservice.lib.management.Management.manifest -GET /management/whoAmI com.gu.mediaservice.lib.management.InnerServiceStatusCheckController.whoAmI(depth: Int) # Shoo robots away GET /robots.txt com.gu.mediaservice.lib.management.Management.disallowRobots diff --git a/image-loader/test/scala/lib/imaging/FileMetadataReaderTest.scala b/image-loader/test/scala/lib/imaging/FileMetadataReaderTest.scala index d9a570e2243..de0434db75f 100644 --- a/image-loader/test/scala/lib/imaging/FileMetadataReaderTest.scala +++ b/image-loader/test/scala/lib/imaging/FileMetadataReaderTest.scala @@ -23,63 +23,6 @@ class FileMetadataReaderTest extends AnyFunSpec with Matchers with ScalaFutures implicit override val patienceConfig: PatienceConfig = PatienceConfig(timeout = Span(1000, Millis), interval = Span(25, Millis)) implicit val logMarker: LogMarker = MarkerMap() - it("should read the correct dimensions for a JPG image") { - val image = fileAt("getty.jpg") - val dimsFuture = FileMetadataReader.dimensions(image, Some(Jpeg)) - whenReady(dimsFuture) { dimOpt => - dimOpt should be(Symbol("defined")) - dimOpt.get.width should be(100) - dimOpt.get.height should be(60) - } - } - - it("should capture exif orientation tag in JPG images") { - val image = fileAt("exif-orientated.jpg") - val orientationFuture = FileMetadataReader.orientation(image) - whenReady(orientationFuture) { orientationOpt => - orientationOpt should be(Symbol("defined")) - orientationOpt.get.exifOrientation should be(Some(6)) - } - } - - it("should ignore 0 degree exif orientation tag as it has no material effect") { - val image = fileAt("exif-orientated-no-rotation.jpg") - val orientationFuture = FileMetadataReader.orientation(image) - whenReady(orientationFuture) { orientationOpt => - orientationOpt should be(None) - } - } - - it("should use uncorrected width and height as dimensions for exif 90 rotations") { - val image = fileAt("exif-orientated.jpg") - val dimsFuture = FileMetadataReader.dimensions(image, Some(Jpeg)) - whenReady(dimsFuture) { dimOpt => - dimOpt should be(Symbol("defined")) - dimOpt.get.width should be(3456) - dimOpt.get.height should be(2304) - } - } - - it("should read the correct dimensions for a tiff image") { - val image = fileAt("flower.tif") - val dimsFuture = FileMetadataReader.dimensions(image, Some(Tiff)) - whenReady(dimsFuture) { dimOpt => - dimOpt should be(Symbol("defined")) - dimOpt.get.width should be(73) - dimOpt.get.height should be(43) - } - } - - it("should read the correct dimensions for a png image") { - val image = fileAt("schaik.com_pngsuite/basn0g08.png") - val dimsFuture = FileMetadataReader.dimensions(image, Some(Png)) - whenReady(dimsFuture) { dimOpt => - dimOpt should be(Symbol("defined")) - dimOpt.get.width should be(32) - dimOpt.get.height should be(32) - } - } - it("should read the correct metadata for Getty JPG images") { val image = fileAt("getty.jpg") val metadataFuture = FileMetadataReader.fromIPTCHeaders(image, "dummy") diff --git a/image-loader/test/scala/model/ImageUploadTest.scala b/image-loader/test/scala/model/ImageUploadTest.scala index 35859abf4bf..59c3737f6be 100644 --- a/image-loader/test/scala/model/ImageUploadTest.scala +++ b/image-loader/test/scala/model/ImageUploadTest.scala @@ -1,15 +1,18 @@ package model +import com.amazonaws.services.s3.AmazonS3 + import java.io.File import java.net.URI import java.util.UUID import com.drew.imaging.ImageProcessingException import com.gu.mediaservice.lib.{StorableImage, StorableOptimisedImage, StorableOriginalImage, StorableThumbImage} -import com.gu.mediaservice.lib.aws.{S3Metadata, S3Object, S3ObjectMetadata, S3Ops} +import com.gu.mediaservice.lib.aws.{EmbedderMessage, S3Metadata, S3Object, S3ObjectMetadata, S3Ops} +import com.gu.mediaservice.lib.aws.{S3, S3Bucket, S3Metadata, S3Object, S3ObjectMetadata, S3Ops} import com.gu.mediaservice.lib.cleanup.ImageProcessor import com.gu.mediaservice.lib.imaging.ImageOperations import com.gu.mediaservice.lib.logging.LogMarker -import com.gu.mediaservice.model.{FileMetadata, Jpeg, MimeType, Png, Tiff, UploadInfo} +import com.gu.mediaservice.model.{FileMetadata, Instance, Jpeg, MimeType, Png, Tiff, UploadInfo} import lib.imaging.MimeTypeDetection import model.upload.{OptimiseWithPngQuant, UploadRequest} import org.joda.time.DateTime @@ -17,8 +20,10 @@ import org.scalatest.Assertion import org.scalatest.funsuite.AsyncFunSuite import org.scalatest.matchers.should.Matchers import org.scalatestplus.mockito.MockitoSugar +import software.amazon.awssdk.services.s3vectors.model.PutVectorsResponse import test.lib.ResourceHelpers +import java.nio.file.Path import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success} @@ -29,10 +34,12 @@ class ImageUploadTest extends AsyncFunSuite with Matchers with MockitoSugar { override def markerContents: Map[String, Any] = Map() } + private val mockS3Client = mock[AmazonS3] + private implicit val logMarker: MockLogMarker = new MockLogMarker() // For mime type info, see https://github.com/guardian/grid/pull/2568 val tempDir = new File("/tmp") - val mockConfig: ImageUploadOpsCfg = ImageUploadOpsCfg(tempDir, 256, 85d, List(Tiff), "img-bucket", "thumb-bucket") + val mockConfig: ImageUploadOpsCfg = ImageUploadOpsCfg(tempDir, 256, 85d, S3Bucket("img-bucket", S3.AmazonAwsS3Endpoint, usesPathStyleURLs = false, mockS3Client), S3Bucket("thumb-bucket", S3.AmazonAwsS3Endpoint, usesPathStyleURLs = false, mockS3Client)) /** * @todo: I flailed about until I found a path that worked, but @@ -48,28 +55,27 @@ class ImageUploadTest extends AsyncFunSuite with Matchers with MockitoSugar { val randomId = UUID.randomUUID().toString + fileName - val mockS3Meta = S3Metadata(Map.empty, S3ObjectMetadata(None, None, None)) - val mockS3Object = S3Object(new URI("innernets.com"), 12345, mockS3Meta) - def mockStore = (a: StorableImage) => Future.successful( - S3Object("madeupname", "madeupkey", a.file, Some(a.mimeType), None, a.meta, None) + S3Object(S3Bucket("madeupname", S3.AmazonAwsS3Endpoint, usesPathStyleURLs = false, mockS3Client), "madeupkey", a.file, Some(a.mimeType), None, a.meta, None) ) def storeOrProjectOriginalFile: StorableOriginalImage => Future[S3Object] = mockStore def storeOrProjectThumbFile: StorableThumbImage => Future[S3Object] = mockStore def storeOrProjectOptimisedPNG: StorableOptimisedImage => Future[S3Object] = mockStore + def queueImageToEmbed: EmbedderMessage => Unit = (message: EmbedderMessage) => () val mockDependencies = ImageUploadOpsDependencies( mockConfig, imageOps, storeOrProjectOriginalFile, storeOrProjectThumbFile, - storeOrProjectOptimisedPNG + storeOrProjectOptimisedPNG, + queueImageToEmbed = queueImageToEmbed, ) val tempFile = ResourceHelpers.fileAt(fileName) - val ul = UploadInfo(None) + val ul = UploadInfo(None, None) val uploadRequest = UploadRequest( randomId, @@ -78,18 +84,19 @@ class ImageUploadTest extends AsyncFunSuite with Matchers with MockitoSugar { DateTime.now(), "uploadedBy", Map(), - ul + ul, + Instance("an-instance"), ) val futureImage = Uploader.uploadAndStoreImage( mockDependencies.storeOrProjectOriginalFile, mockDependencies.storeOrProjectThumbFile, mockDependencies.storeOrProjectOptimisedImage, - OptimiseWithPngQuant, + queueImageToEmbed, uploadRequest, mockDependencies, - FileMetadata(), - ImageProcessor.identity + ImageProcessor.identity, + new OptimiseWithPngQuant(imageOps) ) // Assertions; Failure will auto-fail diff --git a/image-loader/test/scala/model/ProjectorTest.scala b/image-loader/test/scala/model/ProjectorTest.scala index 3779c6ff905..8a1879bf408 100644 --- a/image-loader/test/scala/model/ProjectorTest.scala +++ b/image-loader/test/scala/model/ProjectorTest.scala @@ -1,19 +1,23 @@ package model +import com.amazonaws.services.s3.AmazonS3 + import java.io.File import java.net.URI -import java.util.{Date, UUID} -import com.amazonaws.services.s3.AmazonS3 +import java.util.Date import com.amazonaws.services.s3.model.ObjectMetadata import com.gu.mediaservice.GridClient import com.gu.mediaservice.lib.auth.Authentication +import com.gu.mediaservice.lib.aws.{Embedder, S3, S3Bucket, S3Vectors} import com.gu.mediaservice.lib.cleanup.ImageProcessor import com.gu.mediaservice.lib.imaging.ImageOperations import com.gu.mediaservice.lib.logging.{LogMarker, MarkerMap} -import com.gu.mediaservice.model._ +import com.gu.mediaservice.model.{Instance, _} import com.gu.mediaservice.model.leases.LeasesByMedia import lib.DigestedFile +import model.upload.OptimiseWithPngQuant import org.joda.time.{DateTime, DateTimeZone} +import org.mockito.ArgumentMatchers.any import org.mockito.Mockito.{times, verify, when} import org.scalatest.concurrent.ScalaFutures import org.scalatest.freespec.AnyFreeSpec @@ -21,11 +25,13 @@ import org.scalatest.matchers.should.Matchers import org.scalatest.time.{Millis, Span} import org.scalatestplus.mockito.MockitoSugar import play.api.libs.json.{JsArray, JsString} +import software.amazon.awssdk.services.s3vectors.model.PutVectorsResponse import test.lib.ResourceHelpers +import java.nio.file.Path import scala.concurrent.ExecutionContext.Implicits.global import scala.jdk.CollectionConverters._ -import scala.concurrent.Future +import scala.concurrent.{ExecutionContext, Future} class ProjectorTest extends AnyFreeSpec with Matchers with ScalaFutures with MockitoSugar { @@ -37,11 +43,16 @@ class ProjectorTest extends AnyFreeSpec with Matchers with ScalaFutures with Moc private val imageOperations = new ImageOperations(ctxPath) - private val config = ImageUploadOpsCfg(new File("/tmp"), 256, 85d, Nil, "img-bucket", "thumb-bucket") + private val mockS3Client = mock[AmazonS3] + private val config = ImageUploadOpsCfg(new File("/tmp"), 256, 85d, S3Bucket("img-bucket", S3.AmazonAwsS3Endpoint, usesPathStyleURLs = false, mockS3Client), S3Bucket("thumb-bucket", S3.AmazonAwsS3Endpoint, usesPathStyleURLs = false, mockS3Client)) - private val s3 = mock[AmazonS3] + private val maybeEmbedder = None + + private val s3 = mock[S3] private val auth = mock[Authentication] - private val projector = new Projector(config, s3, imageOperations, ImageProcessor.identity, auth) + private val projector = new Projector(config, s3, imageOperations, ImageProcessor.identity, auth, maybeEmbedder, new OptimiseWithPngQuant(imageOperations)) + + private implicit val instance: Instance = Instance("an-instance") // FIXME temporary ignored as test is not executable in CI/CD machine // because graphic lib files like srgb.icc, cmyk.icc are in root directory instead of resources @@ -130,7 +141,7 @@ class ProjectorTest extends AnyFreeSpec with Matchers with ScalaFutures with Moc softDeletedMetadata = None, lastModified = Some(new DateTime("2020-01-24T17:36:08.456Z").withZone(DateTimeZone.UTC)), identifiers = Map(), - uploadInfo = UploadInfo(Some("getty.jpg")), + uploadInfo = UploadInfo(Some("getty.jpg"), Some(true)), source = Asset(new URI("http://img-bucket.s3.amazonaws.com/i/d/1/2/3/" + id), Some(12666), Some(Jpeg), @@ -194,9 +205,11 @@ class ProjectorTest extends AnyFreeSpec with Matchers with ScalaFutures with Moc uploadTime = uploadTime, uploadFileName = uploadFileName, identifiers = Map.empty, + isFeedUpload = Some(true), ) implicit val logMarker: LogMarker = MarkerMap() + implicit val instance: Instance = Instance("an-instance") val gridClient = mock[GridClient] when(gridClient.getUsages(id, identity)).thenReturn(Future.successful(Nil)) diff --git a/image-loader/test/scala/model/S3IngestObjectTest.scala b/image-loader/test/scala/model/S3IngestObjectTest.scala new file mode 100644 index 00000000000..ff0512ef379 --- /dev/null +++ b/image-loader/test/scala/model/S3IngestObjectTest.scala @@ -0,0 +1,14 @@ +package scala.model + +import model.S3IngestObject +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers.convertToAnyShouldWrapper + +class S3IngestObjectTest extends AnyFlatSpec { + + it should "infer uploader from folder after feeds" in { + val keyParts = "fingerpost/feeds/20250226/PA/205350.SOCCER-Kilmarnock--20514432_PICTURES_PRI13.jpg".split("/") + S3IngestObject.uploadedFromPath(keyParts) shouldBe "20250226" + } + +} diff --git a/kahuna/app/InstanceSpecificSecurityHeaderFilter.scala b/kahuna/app/InstanceSpecificSecurityHeaderFilter.scala new file mode 100644 index 00000000000..3a28c8b1ec9 --- /dev/null +++ b/kahuna/app/InstanceSpecificSecurityHeaderFilter.scala @@ -0,0 +1,17 @@ +import com.gu.mediaservice.lib.config.InstanceForRequest +import lib.KahunaConfig +import play.api.Configuration +import play.api.mvc.{EssentialAction, EssentialFilter, RequestHeader} +import play.filters.headers._ + +class InstanceSpecificSecurityHeaderFilter(config: KahunaConfig, playConfig: Configuration) + extends EssentialFilter with InstanceForRequest { + + override def apply(next: EssentialAction): EssentialAction = (req: RequestHeader) => { + val instance = instanceOf(req) + val kahunaSecurityConfig = KahunaSecurityConfig.apply(config, playConfig, instance) + val instanceSpecificSecurityHeadersFilter = SecurityHeadersFilter(kahunaSecurityConfig: SecurityHeadersConfig).apply(next) + instanceSpecificSecurityHeadersFilter.apply(req) + } + +} diff --git a/kahuna/app/KahunaComponents.scala b/kahuna/app/KahunaComponents.scala index 12ad0041325..523baccdb2a 100644 --- a/kahuna/app/KahunaComponents.scala +++ b/kahuna/app/KahunaComponents.scala @@ -1,57 +1,66 @@ -import com.gu.mediaservice.lib.management.InnerServiceStatusCheckController import com.gu.mediaservice.lib.net.URI -import com.gu.mediaservice.lib.play.GridComponents +import com.gu.mediaservice.lib.play.{ConnectionBrokenFilter, GridComponents, RequestLoggingFilter, RequestMetricFilter} +import com.gu.mediaservice.model.Instance import controllers.{AssetsComponents, KahunaController} import lib.KahunaConfig import play.api.ApplicationLoader.Context import play.api.Configuration +import play.api.mvc.EssentialFilter import play.filters.headers.SecurityHeadersConfig import router.Routes class KahunaComponents(context: Context) extends GridComponents(context, new KahunaConfig(_)) with AssetsComponents { - final override lazy val securityHeadersConfig: SecurityHeadersConfig = KahunaSecurityConfig(config, context.initialConfiguration) - final override val buildInfo = utils.buildinfo.BuildInfo + override def httpFilters: Seq[EssentialFilter] = Seq( + instanceSpecificCorsFilter, + // csrfFilter, TODO Ineffective as gateway is not setting correct hostname headers! + new InstanceSpecificSecurityHeaderFilter(config, context.initialConfiguration), + gzipFilter, + new RequestLoggingFilter(materializer), + new ConnectionBrokenFilter(materializer), + new RequestMetricFilter(config, materializer, actorSystem, applicationLifecycle) + ) + val controller = new KahunaController(auth, config, controllerComponents, authorisation) - val InnerServiceStatusCheckController = new InnerServiceStatusCheckController(auth, controllerComponents, config.services, wsClient) - final override val router = new Routes(httpErrorHandler, controller, assets, management, InnerServiceStatusCheckController) + final override val router = new Routes(httpErrorHandler, controller, assets, management) } object KahunaSecurityConfig { - def apply(config: KahunaConfig, playConfig: Configuration): SecurityHeadersConfig = { + def apply(config: KahunaConfig, playConfig: Configuration, instance: Instance): SecurityHeadersConfig = { val base = SecurityHeadersConfig.fromConfiguration(playConfig) val services = List( - config.services.apiBaseUri, - config.services.loaderBaseUri, - config.services.cropperBaseUri, - config.services.metadataBaseUri, - config.services.imgopsBaseUri, - config.services.usageBaseUri, - config.services.collectionsBaseUri, - config.services.leasesBaseUri, - config.services.authBaseUri, + config.services.apiBaseUri(instance), + config.services.loaderBaseUri(instance), + config.services.cropperBaseUri(instance), + config.services.metadataBaseUri(instance), + config.services.imgopsBaseUri(instance), + config.services.usageBaseUri(instance), + config.services.collectionsBaseUri(instance), + config.services.leasesBaseUri(instance), + config.services.authBaseUri(instance), config.services.guardianWitnessBaseUri ) - val frameSources = s"frame-src ${config.services.authBaseUri} ${config.services.kahunaBaseUri} https://accounts.google.com https://www.youtube.com ${config.scriptsToLoad.map(_.host).mkString(" ")}" + val frameSources = s"frame-src https://accounts.google.com https://www.youtube.com ${config.scriptsToLoad.map(_.host).mkString(" ")}" val frameAncestors = s"frame-ancestors ${config.frameAncestors.mkString(" ")}" - val connectSources = s"connect-src 'self' ${(services :+ config.imageOrigin).mkString(" ")} ${config.connectSources.mkString(" ")}" + val connectSources = s"connect-src 'self' ${config.connectSources.mkString(" ")}" - val imageSources = s"img-src ${List( + val str = List( "data:", "blob:", - URI.ensureSecure(config.services.imgopsBaseUri).toString, - URI.ensureSecure(config.fullOrigin).toString, + URI.ensureSecure(config.services.imgopsBaseUri(instance)).toString, URI.ensureSecure(config.thumbOrigin).toString, URI.ensureSecure(config.cropOrigin).toString, URI.ensureSecure("app.getsentry.com").toString, "https://*.googleusercontent.com", "'self'" - ).mkString(" ")} ${config.imageSources.mkString(" ")}" + ).mkString(" ") + + val imageSources = s"img-src $str ${config.imageSources.mkString(" ")}" val fontSources = s"font-src data: 'self' ${config.fontSources.mkString(" ")}" diff --git a/kahuna/app/controllers/KahunaController.scala b/kahuna/app/controllers/KahunaController.scala index 494cdf1cde6..cb2a8aa8118 100644 --- a/kahuna/app/controllers/KahunaController.scala +++ b/kahuna/app/controllers/KahunaController.scala @@ -3,13 +3,14 @@ package controllers import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.auth.Authentication.Principal import com.gu.mediaservice.lib.auth.{Authentication, Authorisation, BaseControllerWithLoginRedirects} -import lib.{ExampleSwitch, UseCqlChips, FeatureSwitches, KahunaConfig} +import lib.{EnableAISearch, ExampleSwitch, FeatureSwitches, KahunaConfig, KahunaClientServiceUrls, UseCqlChips} import play.api.mvc.ControllerComponents import play.api.libs.json._ import scala.concurrent.ExecutionContext import com.gu.mediaservice.lib.config.FieldAlias._ -import com.gu.mediaservice.lib.config.Services +import com.gu.mediaservice.lib.config.{InstanceForRequest, Services} +import com.gu.mediaservice.model.Instance import play.api.mvc.Security.AuthenticatedRequest import play.twirl.api.Html @@ -20,13 +21,14 @@ class KahunaController( authorisation: Authorisation )( implicit val ec: ExecutionContext -) extends BaseControllerWithLoginRedirects with ArgoHelpers { +) extends BaseControllerWithLoginRedirects with ArgoHelpers with InstanceForRequest { override def auth: Authentication = authentication override def services: Services = config.services def index(ignored: String) = withOptionalLoginRedirect { request => + implicit val instance: Instance = instanceOf(request) val maybeUser: Option[Authentication.Principal] = request match { case authedRequest: AuthenticatedRequest[_, _] => authedRequest.user match { @@ -38,7 +40,7 @@ class KahunaController( val isIFramed = request.headers.get("Sec-Fetch-Dest").contains("iframe") val featureSwitches = new FeatureSwitches( - List(ExampleSwitch, UseCqlChips) + List(ExampleSwitch, UseCqlChips, EnableAISearch) ) val featureSwitchesWithClientValues = featureSwitches.getClientSwitchValues(featureSwitches.getFeatureSwitchCookies(request.cookies.get)) val featureSwitchesJson = Json.stringify(Json.toJson(featureSwitches.getFeatureSwitchesToStringify(featureSwitchesWithClientValues))) @@ -55,18 +57,27 @@ class KahunaController( val metadataTemplates: String = Json.toJson(config.metadataTemplates).toString() val announcements: String = Json.toJson(config.announcements).toString() val interimFilterOptions: String = Json.toJson(config.interimFilterOptions).toString() - val returnUri = config.rootUri + okPath + val returnUri = config.rootUri(instance) + okPath val costFilterLabel = config.costFilterLabel.getOrElse("Free to use only") val costFilterChargeable = config.costFilterChargeable.getOrElse(false) val maybeOrgOwnedValue = if(config.shouldDisplayOrgOwnedCountAndFilterCheckbox) - Html(s""""${config.staffPhotographerOrganisation}-owned"""") + Html(""""owned"""") else Html("undefined") val imageTypes = Json.toJson(config.imageTypes).toString() + val agencyPicksIngredients = Json.toJson(config.agencyPicksIngredients).toString() + + val rootUri = config.rootUri(instance) + + val kahunaClientServiceUrls = KahunaClientServiceUrls( + rootUri = rootUri, + mediaApiUri = config.mediaApiUri(instance), + authUri = config.authUri(instance) + ) Ok(views.html.main( - s"${config.authUri}/login?redirectUri=$returnUri", + s"${config.authUri(instance)}/login?redirectUri=$returnUri", fieldAliases, scriptsToLoad, domainMetadataSpecs, @@ -79,12 +90,14 @@ class KahunaController( maybeOrgOwnedValue, config, featureSwitchesJson, - imageTypes + imageTypes, + agencyPicksIngredients, + kahunaClientServiceUrls )) } def quotas = authentication { req => - Ok(views.html.quotas(config.mediaApiUri)) + Ok(views.html.quotas(config.mediaApiUri(instanceOf(req)))) } def notifications = authentication { req => diff --git a/kahuna/app/lib/FeatureSwitch.scala b/kahuna/app/lib/FeatureSwitch.scala index 3177f07bdbd..ea6cecc41e3 100644 --- a/kahuna/app/lib/FeatureSwitch.scala +++ b/kahuna/app/lib/FeatureSwitch.scala @@ -16,6 +16,12 @@ object UseCqlChips extends FeatureSwitch( default = false ) +object EnableAISearch extends FeatureSwitch( + key = "enable-ai-search", + title = "Enable the use of AI search", + default = false +) + class FeatureSwitches(featureSwitches: List[FeatureSwitch]){ // Feature switches are defined here, but updated by setting a cookie following the pattern e.g. "feature-switch-my-key" // for a switch called "my-key". diff --git a/kahuna/app/lib/KahunaClientServiceUrls.scala b/kahuna/app/lib/KahunaClientServiceUrls.scala new file mode 100644 index 00000000000..d2a2ec0c223 --- /dev/null +++ b/kahuna/app/lib/KahunaClientServiceUrls.scala @@ -0,0 +1,3 @@ +package lib + +case class KahunaClientServiceUrls(rootUri: String, mediaApiUri: String, authUri: String) diff --git a/kahuna/app/lib/KahunaConfig.scala b/kahuna/app/lib/KahunaConfig.scala index 338ce3f8a17..8be882a61af 100644 --- a/kahuna/app/lib/KahunaConfig.scala +++ b/kahuna/app/lib/KahunaConfig.scala @@ -2,7 +2,9 @@ package lib import com.gu.mediaservice.lib.auth.Permissions.Pinboard import com.gu.mediaservice.lib.auth.SimplePermission +import com.gu.mediaservice.lib.aws.S3Bucket import com.gu.mediaservice.lib.config.{CommonConfig, GridConfigResources} +import com.gu.mediaservice.model.Instance import play.api.libs.json._ case class ScriptToLoad( @@ -14,16 +16,14 @@ case class ScriptToLoad( ) class KahunaConfig(resources: GridConfigResources) extends CommonConfig(resources) { - val rootUri: String = services.kahunaBaseUri - val mediaApiUri: String = services.apiBaseUri - val authUri: String = services.authBaseUri + val rootUri: Instance => String = services.kahunaBaseUri + def mediaApiUri: Instance => String = services.apiBaseUri + val authUri: Instance => String = services.authBaseUri val sentryDsn: Option[String] = stringOpt("sentry.dsn").filterNot(_.isEmpty) val thumbOrigin: String = string("origin.thumb") - val fullOrigin: String = string("origin.full") val cropOrigin: String = string("origin.crops") - val imageOrigin: String = string("origin.images") val costFilterLabel: Option[String] = stringOpt("costFilter.label") val costFilterChargeable: Option[Boolean] = booleanOpt("costFilter.chargeable") @@ -51,8 +51,7 @@ class KahunaConfig(resources: GridConfigResources) extends CommonConfig(resource val frameAncestors: Set[String] = getStringSet("security.frameAncestors") val connectSources: Set[String] = getStringSet("security.connectSources") ++ maybeIngestBucket.map { ingestBucket => - if (isDev) "https://localstack.media.local.dev-gutools.co.uk" - else s"https://$ingestBucket.s3.$awsRegion.amazonaws.com" + ingestBucket.bucketURL().toURL.toExternalForm } ++ telemetryUri val fontSources: Set[String] = getStringSet("security.fontSources") val imageSources: Set[String] = getStringSet("security.imageSources") @@ -91,4 +90,3 @@ class KahunaConfig(resources: GridConfigResources) extends CommonConfig(resource val shouldUploadStraightToBucket: Boolean = maybeIngestBucket.isDefined } - diff --git a/kahuna/app/views/main.scala.html b/kahuna/app/views/main.scala.html index b7af9e1af93..8a9a9e8e259 100644 --- a/kahuna/app/views/main.scala.html +++ b/kahuna/app/views/main.scala.html @@ -1,6 +1,7 @@ @import lib.ScriptToLoad @import lib.KahunaConfig +@import lib.KahunaClientServiceUrls @( reauthUri: String, fieldAliases: String, @@ -16,6 +17,8 @@ kahunaConfig: KahunaConfig, featureSwitches: String, imageTypes: String, + agencyPicksIngredients: String, + kahunaClientServiceUrls: KahunaClientServiceUrls ) @@ -28,26 +31,31 @@ - - + + - + - + @kahunaConfig.sentryDsn.map { dsn => } diff --git a/kahuna/conf/routes b/kahuna/conf/routes index b8ad420d3c4..3003723ff03 100644 --- a/kahuna/conf/routes +++ b/kahuna/conf/routes @@ -18,7 +18,6 @@ GET /assets/*file controllers.Assets.version # Management GET /management/healthcheck com.gu.mediaservice.lib.management.Management.healthCheck GET /management/manifest com.gu.mediaservice.lib.management.Management.manifest -GET /management/whoAmI com.gu.mediaservice.lib.management.InnerServiceStatusCheckController.whoAmI(depth: Int) # Shoo robots away GET /robots.txt com.gu.mediaservice.lib.management.Management.disallowRobots diff --git a/kahuna/package-lock.json b/kahuna/package-lock.json index 9a6893d467f..ef381a8ab36 100644 --- a/kahuna/package-lock.json +++ b/kahuna/package-lock.json @@ -6,7 +6,7 @@ "": { "dependencies": { "@babel/polyfill": "7.8.7", - "@guardian/cql": "1.8.2", + "@guardian/cql": "1.8.4", "@guardian/user-telemetry-client": "1.1.0", "@sentry/browser": "6.11.0", "@sentry/integrations": "6.11.0", @@ -32,7 +32,7 @@ "pandular": "0.1.6", "pikaday": "1.8.2", "pikaday-time": "1.6.1", - "preact": "10.26.9", + "preact": "10.28.2", "react": "17.0.2", "react-dom": "17.0.2", "react2angular": "4.0.6", @@ -2103,6 +2103,64 @@ "node": ">=10.0.0" } }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/@eslint/eslintrc": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.0.5.tgz", @@ -2145,10 +2203,11 @@ } }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -2182,9 +2241,9 @@ "license": "MIT" }, "node_modules/@guardian/cql": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/@guardian/cql/-/cql-1.8.2.tgz", - "integrity": "sha512-SnWJ7zmUZgOHt2+MSQcN/md1XlGU8x4XUmIG6ZOZtcg96LOvTEu858VEjaL9bV7A325WxJISkjGK5S2yrkdmEQ==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/@guardian/cql/-/cql-1.8.4.tgz", + "integrity": "sha512-cDhPd63XcAs6Xj4Rerstjbo9uO0aJL8DEyrZ6FX2tmlpm0WY6vVzQ6aKmXpej9hw9wka8jHE02ZvoHY0yfhbdQ==", "dependencies": { "@floating-ui/dom": "^1.6.11", "preact": "^10.25.4", @@ -3235,6 +3294,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3425,6 +3497,25 @@ "node": ">=10.13.0" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tybys/wasm-util/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/@types/angular": { "version": "1.8.4", "resolved": "https://registry.npmjs.org/@types/angular/-/angular-1.8.4.tgz", @@ -3873,6 +3964,34 @@ "dev": true, "license": "ISC" }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, "node_modules/@unrs/resolver-binding-darwin-arm64": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", @@ -3887,6 +4006,233 @@ "darwin" ] }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@webassemblyjs/ast": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", @@ -5575,9 +5921,9 @@ "license": "Python-2.0" }, "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -6500,9 +6846,9 @@ } }, "node_modules/eslint/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -6512,20 +6858,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/eslint/node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/eslint/node_modules/semver": { "version": "7.6.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", @@ -6551,19 +6883,6 @@ "node": ">=8" } }, - "node_modules/eslint/node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/espree": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.0.tgz", @@ -11094,10 +11413,11 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -11434,9 +11754,10 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" }, "node_modules/lodash.debounce": { "version": "4.0.8", @@ -13061,9 +13382,9 @@ "dev": true }, "node_modules/preact": { - "version": "10.26.9", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.26.9.tgz", - "integrity": "sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==", + "version": "10.28.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz", + "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==", "license": "MIT", "funding": { "type": "opencollective", diff --git a/kahuna/package.json b/kahuna/package.json index 72cafb3f836..86d5db191ca 100644 --- a/kahuna/package.json +++ b/kahuna/package.json @@ -1,7 +1,7 @@ { "dependencies": { "@babel/polyfill": "7.8.7", - "@guardian/cql": "1.8.2", + "@guardian/cql": "1.8.4", "@guardian/user-telemetry-client": "1.1.0", "@sentry/browser": "6.11.0", "@sentry/integrations": "6.11.0", @@ -27,7 +27,7 @@ "pandular": "0.1.6", "pikaday": "1.8.2", "pikaday-time": "1.6.1", - "preact": "10.26.9", + "preact": "10.28.2", "react": "17.0.2", "react-dom": "17.0.2", "react2angular": "4.0.6", diff --git a/kahuna/public/js/common/blurring/GNM-explainer.html b/kahuna/public/js/common/blurring/explainer.html similarity index 70% rename from kahuna/public/js/common/blurring/GNM-explainer.html rename to kahuna/public/js/common/blurring/explainer.html index d752991119c..4255ca2d250 100644 --- a/kahuna/public/js/common/blurring/GNM-explainer.html +++ b/kahuna/public/js/common/blurring/explainer.html @@ -6,10 +6,8 @@ before deciding whether to reveal the image.
    This feature is now on by default, but can be turned off/on at any time via the top-right menu.
    Flagging relies on agency metadata, checked against a - list of criteria, - and may not catch images that are not appropriately described. For more information, please see the - - original comms email on 1 Nov 2023. + list of criteria, + and may not catch images that are not appropriately described.

  1. - local_library - phonelink + local_library + phonelink {{usage.title}} diff --git a/kahuna/public/js/components/gr-image-usage/gr-image-usage.js b/kahuna/public/js/components/gr-image-usage/gr-image-usage.js index ff7b23b7789..5aea5049de7 100644 --- a/kahuna/public/js/components/gr-image-usage/gr-image-usage.js +++ b/kahuna/public/js/components/gr-image-usage/gr-image-usage.js @@ -108,10 +108,12 @@ module.controller('grImageUsageListCtrl', [ return moment(timestamp).fromNow(); }; - ctrl.isRecent = (timestamp) => { + ctrl.isRecent = (usage) => { const nowtime = new Date(); - return moment(timestamp) + let recent = moment(usage.dateAdded) .isAfter(moment(nowtime).subtract(imageUsagesService.recentTime, 'days')); + let ignored = usage.platform === "print" && usage.status !== "published"; + return recent && !ignored; }; }]); diff --git a/kahuna/public/js/components/gr-notifications-banner/gr-notifications-banner.tsx b/kahuna/public/js/components/gr-notifications-banner/gr-notifications-banner.tsx index d8b81d406cd..f61de4ba2e2 100644 --- a/kahuna/public/js/components/gr-notifications-banner/gr-notifications-banner.tsx +++ b/kahuna/public/js/components/gr-notifications-banner/gr-notifications-banner.tsx @@ -12,7 +12,7 @@ const TRANSIENT = "transient"; const NOTIFICATION_COOKIE = "notification_cookie"; const cookie_age = 31536000; const checkNotificationsUri = window._clientConfig.rootUri + "/notifications"; -const checkNotificationsInterval = 60000; // in ms +const checkNotificationsInterval = 600000; // in ms const tickIcon = () => diff --git a/kahuna/public/js/components/gu-lazy-preview-shortcuts/gu-lazy-preview-shortcuts.js b/kahuna/public/js/components/gu-lazy-preview-shortcuts/gu-lazy-preview-shortcuts.js new file mode 100644 index 00000000000..d54820d6bbd --- /dev/null +++ b/kahuna/public/js/components/gu-lazy-preview-shortcuts/gu-lazy-preview-shortcuts.js @@ -0,0 +1,39 @@ +import angular from 'angular'; + +import '../gr-keyboard-shortcut/gr-keyboard-shortcut'; + +export var lazyPreviewShortcuts = angular.module('gu.lazyPreviewShortcuts', [ + 'gr.keyboardShortcut' +]); + +lazyPreviewShortcuts.directive('guLazyPreviewShortcuts', + ['keyboardShortcut', + function(keyboardShortcut) { + return { + restrict: 'EA', + require: '^guLazyPreview', + link: function (scope, element, attrs, lazyGalleryCtrl) { + function invoke(fnName) { + return (event) => { + // Must cancel any scrolling caused by the key + event.preventDefault(); + lazyGalleryCtrl[fnName](); + }; + } + + keyboardShortcut.bindTo(scope) + .add({ + combo: 'left', + description: 'Go to the previous image', + allowIn: ['INPUT'], + callback: invoke('prevItem') + }) + .add({ + combo: 'right', + description: 'Go to the next image', + allowIn: ['INPUT'], + callback: invoke('nextItem') + }); + } + }; +}]); diff --git a/kahuna/public/js/components/gu-lazy-preview/gu-lazy-preview.css b/kahuna/public/js/components/gu-lazy-preview/gu-lazy-preview.css new file mode 100644 index 00000000000..52cf28d1d9d --- /dev/null +++ b/kahuna/public/js/components/gu-lazy-preview/gu-lazy-preview.css @@ -0,0 +1,171 @@ +/* ========================================================================== + preview-view + ========================================================================== */ + +.preview-view { + position: relative; + height: calc(100vh - 100px); /* screen - top bar */ + top: 35px; /* results bar height */ + background-color: #333333; +} + +.preview-view-item { + width: 100%; + height: 100%; + position: relative; + box-sizing: border-box; + display: flex; + align-items: baseline; + align-content: center; + padding: 10px; +} + +.preview-view-item__preview { + width: 100%; + box-sizing: border-box; + border: 5px solid transparent; +} + +.preview-view-item__preview--selected { + border-color: #00adee; +} + +.preview-view__controls { + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + opacity: .5; + transition: opacity .1s; +} + +.preview-view__controls:hover { + opacity: 1; +} + +.preview-view__control { + padding: 10px; + border: 1px solid #565656; + background: #333333; + transition: opacity .1s; +} + +/* TODO: abstract repeated results styles */ +.preview-view-item__select input[type=checkbox] { + visibility: hidden; +} + +.preview-view-item__select { + position: absolute; + display: none; + + /* above thumbnail */ + z-index: 1; +} + +.preview-view-item__select--no-pointer-events { + pointer-events: none; +} + +.preview-view-item__select__checkbox__label { + position: absolute; + top: 30px; + left: 30px; + cursor: pointer; +} + +.preview-view-item__select .preview-view-item__select__checkbox__label gr-icon { + font-size: 25px; + color: white; +} + +.preview-view-item__select--selected .preview-view-item__select__checkbox__label gr-icon { + color: #00adee; +} + +.preview-view-item--selected, +.preview-view-item--selected:hover { + border-color: #00adee; +} + +/* Hacky: Hide gr-archiver-status "Add to Library" (unarchived state) unless hovering */ +.preview-view-item .gr-archiver-status--unarchived { + display: none; +} + +.preview-view-item:hover .preview-view-item__select, +.preview-view-item:hover .preview__fade, +.preview-view-item:hover .image-actions-container, +.preview-view-item:hover .gr-archiver-status--unarchived { + display: block; +} + +.preview-view-item:hover .gr-add-label--inactive { + display: inline-block; +} + +.preview-view-item--selected:hover .preview__fade { + display: none; +} + +.preview-view-item .preview__image { + max-height: calc(100vh - 190px); +} + +.preview-view-item .loading .preview__image { + display: none; +} + +.preview-view-item .preview__loading { + height: calc(100vh - 190px); + position:relative; + display: none; +} + +.preview-view-item .loading .preview__loading { + display: block; +} + +.preview-view-item .preview__loading__icon { + height: 30px; + width: 30px; + display: block; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + max-width: 100%; + margin: 0 auto; + animation: spin 1000ms infinite linear; + font-size: 30px; +} + +@keyframes spin { + from {transform:rotate(0deg);} + to {transform:rotate(360deg);} +} + +.preview-view-item__select--selected { + display: initial; +} + +.preview-view-item__preview .preview__info-container { + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 100%; + background: rgba(0, 0, 0, 0.5); + padding: 20px; + box-sizing: border-box; + overflow: auto; +} + +.preview-view-item__preview .preview__info-container, +.preview-view-item__preview .loading .preview__info-container { + display: none; +} + +.preview-view-item__preview:hover .preview__info-container { + display: block; +} diff --git a/kahuna/public/js/components/gu-lazy-preview/gu-lazy-preview.html b/kahuna/public/js/components/gu-lazy-preview/gu-lazy-preview.html new file mode 100644 index 00000000000..d327fd896a2 --- /dev/null +++ b/kahuna/public/js/components/gu-lazy-preview/gu-lazy-preview.html @@ -0,0 +1,58 @@ +
    + +
    + +
    + + +
    +
    + + +
    + + + + + +
    + +
    + + + + +
    + +
    diff --git a/kahuna/public/js/components/gu-lazy-preview/gu-lazy-preview.js b/kahuna/public/js/components/gu-lazy-preview/gu-lazy-preview.js new file mode 100644 index 00000000000..215ba495760 --- /dev/null +++ b/kahuna/public/js/components/gu-lazy-preview/gu-lazy-preview.js @@ -0,0 +1,142 @@ +import angular from 'angular'; +import Rx from 'rx'; +import 'rx-dom'; + +import template from './gu-lazy-preview.html'; + +import './gu-lazy-preview.css'; + +import '../../util/rx'; + +export var lazyPreview = angular.module('gu.lazyPreview', ['util.rx']); + +function asInt(string) { + return parseInt(string, 10); +} + +lazyPreview.controller('GuLazyPreviewCtrl', [function() { + let ctrl = this; + + ctrl.init = function({items$, totalItems$, preloadedItems$, currentIndex$}) { + const itemsCount$ = items$.map(items => items.length).distinctUntilChanged(); + const totalItemsCount$ = totalItems$.map(totalItems => { + return totalItems.length; + }).distinctUntilChanged(); + + const buttonCommands$ = Rx.Observable.create(observer => { + ctrl.prevItem = () => observer.onNext('prevItem'); + ctrl.nextItem = () => observer.onNext('nextItem'); + + // Make sure we start at the beginning + observer.onNext('previewStart'); + }); + + + const itemsOffset$ = buttonCommands$.combineLatest( + itemsCount$, + (command, itemsCount) => { + return {command, itemsCount}; + }).withLatestFrom( + currentIndex$, + ({command}, currentIndex) => { + return { + prevItem: -1, + nextItem: +1, + previewStart: currentIndex * -1 + }[command] || 0; + } + ); + + + + + const updatedIndex$ = itemsOffset$.withLatestFrom( + currentIndex$, itemsCount$, totalItemsCount$, + (itemsOffset, currentIndex, itemsCount, totalItemsCount) => { + const updatedIndex = currentIndex + itemsOffset; + // Update the index if it's in the range of items + if (updatedIndex >= 0 && updatedIndex < totalItemsCount) { + return updatedIndex; + } else { + return currentIndex; + } + }); + + const item$ = updatedIndex$.withLatestFrom( + totalItemsCount$, items$, + (updatedIndex, totalItemsCount, items) => { + currentIndex$.onNext(updatedIndex); + return items[updatedIndex]; + }); + + const currentPage$ = currentIndex$.withLatestFrom( + preloadedItems$, + (currentIndex, preloadedItems) => { + return Math.floor(currentIndex / preloadedItems); + }); + + const rangeToLoad$ = currentPage$.withLatestFrom( + preloadedItems$, + (currentPage, preloadedItems) => { + const start = currentPage * preloadedItems; + const end = ((currentPage + 1) * preloadedItems) - 1; + return {start, end}; + }). + // Debounce range loading, which also helps discard + // erroneous large ranges while combining + // loadedRangeStart$ and loadedRangeEnd$ changes (one after the other) + debounce(10). + // Ignore if either end isn't set (whole range already loaded) + filter(({start, end}) => start !== -1 && end !== -1). + // Ignore if $start after $end (incomplete combine$ state) + filter(({start, end}) => start <= end). + distinctUntilChanged(({start, end}) => `${start}-${end}`); + + return { + item$, + rangeToLoad$ + }; + }; + +}]); + +lazyPreview.directive('guLazyPreview', ['observe$', 'observeCollection$', 'subscribe$', 'inject$', + function( + observe$, observeCollection$, subscribe$, inject$) { + return { + restrict: 'E', + controller: 'GuLazyPreviewCtrl', + controllerAs: 'previewCtrl', + transclude: true, + template: template, + link: function(scope, element, attrs, ctrl) { + // Map attributes as Observable streams + const { + guLazyPreviewItems: itemsAttr, + guLazyPreviewItemsTotal: totalItemsAttr, + guLazyPreviewSelectionMode: selectionMode, + guLazyPreviewLoadRange: loadRangeFn, + guLazyPreviewPreloadedItems: preloadedItemsAttr + } = attrs; + + const items$ = observeCollection$(scope, itemsAttr); + const selectionMode$ = observe$(scope, selectionMode); + const totalItems$ = observe$(scope, totalItemsAttr); + const preloadedItems$ = observe$(scope, preloadedItemsAttr).map(asInt); + + const currentIndex$ = new Rx.BehaviorSubject(0); + + const {item$, rangeToLoad$} = ctrl.init( + {items$, totalItems$, preloadedItems$, currentIndex$} + ); + + subscribe$(scope, rangeToLoad$, range => { + scope.$eval(loadRangeFn, range); + }); + + inject$(scope, currentIndex$, ctrl, 'currentIndex'); + inject$(scope, selectionMode$, ctrl, 'selectionMode'); + inject$(scope, item$, ctrl, 'item'); + } + }; +}]); diff --git a/kahuna/public/js/crop/controller.js b/kahuna/public/js/crop/controller.js index 370278f599a..69770e2ac66 100644 --- a/kahuna/public/js/crop/controller.js +++ b/kahuna/public/js/crop/controller.js @@ -71,10 +71,14 @@ crop.controller('ImageCropCtrl', [ const storageCropType = cropSettings.getCropType(); const storageDefaultCropType = cropSettings.getDefaultCropType(); + const cropOptionDisplayValue = cropOption => cropOption.ratioString + ? `${cropOption.displayName} (${cropOption.ratioString})` + : cropOption.displayName; + ctrl.cropOptions = allCropOptions .filter(option => (!storageCropType || storageCropType === option.key) && !option.isHidden ) .map(option => Object.assign(option, { - value: option.ratioString ? `${option.key} (${option.ratioString})` : option.key, + value: cropOptionDisplayValue(option), minimalValue: option.ratioString || option.key, tooltip: `${option.key} [${option.key.charAt(0)}]`, disabled: storageCropType && storageCropType !== option.key diff --git a/kahuna/public/js/image/controller.js b/kahuna/public/js/image/controller.js index b2d6119a64d..d570927acd9 100644 --- a/kahuna/public/js/image/controller.js +++ b/kahuna/public/js/image/controller.js @@ -27,7 +27,8 @@ import '../components/gr-display-crops/gr-display-crops'; import '../components/gu-date/gu-date'; import {radioList} from '../components/gr-radio-list/gr-radio-list'; import {cropUtil} from '../util/crop'; -import { List } from 'immutable'; +import {List} from 'immutable'; + const image = angular.module('kahuna.image.controller', [ 'util.rx', 'util.storage', @@ -284,7 +285,7 @@ image.controller('ImageCtrl', [ ' does not have the same dimensions as the master. Using the next largest cropped asset with width ' + largestWidth + 'Please correct this inconsistency.'); } - crop.downloadLink = largestAsset.downloadLink; + crop.downloadLink = cropsResource.links.find(link => link.rel.includes(`crop-download-${crop.id}-master`))?.href; } }); } diff --git a/kahuna/public/js/image/service.js b/kahuna/public/js/image/service.js index a72a728f0bc..47fd7422769 100644 --- a/kahuna/public/js/image/service.js +++ b/kahuna/public/js/image/service.js @@ -36,6 +36,7 @@ imageService.factory('imageService', ['imageLogic', function(imageLogic) { canArchive: imageLogic.canBeArchived(image), persistedReasons: imageLogic.getPersistenceExplanation(image).join('; '), isStaffPhotographer: imageLogic.isStaffPhotographer(image), + isAgencyPick: imageLogic.isAgencyPick(image), syndicationStatus: imageLogic.getSyndicationStatus(image), syndicationReason: imageLogic.getSyndicationReason(image), hasSyndicationRights: imageLogic.hasSyndicationRights(image), diff --git a/kahuna/public/js/preview/image-large.html b/kahuna/public/js/preview/image-large.html new file mode 100644 index 00000000000..ca7e19a11d1 --- /dev/null +++ b/kahuna/public/js/preview/image-large.html @@ -0,0 +1,165 @@ +
    + + + + +
    +
    + autorenew +
    + {{ctrl.image.data.metadata.description}} +
    + + +
    +
    + autorenew +
    + {{ctrl.image.data.metadata.description}} +
    + +
    + +
    + + + +
    + +
    +
    + + Uploaded: {{ctrl.image.data.uploadTime | date:'dd/MM/yy'}} + {{ctrl.image.data.uploadTime | date:'HH:mm'}} + + + + + crop + + + + + local_library + + + + + phonelink + +
    +
    + + + +
    +
    + + £ +
    + +
    + + + warning +
    + +
    + trending_up +
    + +
    + + flag +
    +
    +
    +
    +
    +
    diff --git a/kahuna/public/js/preview/image.html b/kahuna/public/js/preview/image.html index 7af1a1a91b3..cdc2a3d880f 100644 --- a/kahuna/public/js/preview/image.html +++ b/kahuna/public/js/preview/image.html @@ -42,7 +42,7 @@ >
    {{::ctrl.imageDescription}} @@ -53,7 +53,7 @@ >
    {{::ctrl.image.data.metadata.description}} diff --git a/kahuna/public/js/preview/image.js b/kahuna/public/js/preview/image.js index f0709c84a5b..d20f4cbea99 100644 --- a/kahuna/public/js/preview/image.js +++ b/kahuna/public/js/preview/image.js @@ -1,10 +1,12 @@ import angular from 'angular'; +import Rx from 'rx'; import '../util/rx'; import '../util/storage'; import {restrictionsText} from '../util/rights-categories'; import template from './image.html'; +import templateLarge from './image-large.html'; import '../image/service'; import '../imgops/service'; @@ -92,9 +94,7 @@ image.controller('uiPreviewImageCtrl', [ ctrl.states = imageService(ctrl.image).states; - ctrl.imageDescription = ctrl.states.isStaffPhotographer ? - `${window._clientConfig.staffPhotographerOrganisation}-owned: ${ctrl.image.data.metadata.description}` : - ctrl.image.data.metadata.description; + ctrl.imageDescription = ctrl.image.data.metadata.description; ctrl.image.isPotentiallyGraphic = graphicImageBlurService.isPotentiallyGraphic(ctrl.image); @@ -179,6 +179,41 @@ image.directive('uiPreviewImage', function() { }; }); +image.directive('uiPreviewImageLarge', ['observe$', 'inject$', 'imgops', + function(observe$, inject$, imgops) { + return { + restrict: 'E', + scope: { + image: '=', + hideInfo: '=', + selectionMode: '=' + }, + // extra actions can be transcluded in + transclude: true, + template: templateLarge, + controller: 'uiPreviewImageCtrl', + controllerAs: 'ctrl', + bindToController: true, + link: function(scope, element, attrs, ctrl) { + ctrl.loading = false; + const image$ = new Rx.Subject(); + + const optimisedImage$ = image$.flatMap((image) => { + return Rx.Observable.fromPromise(imgops.getFullScreenUri(image)); + }); + + scope.$watch(() => ctrl.image.data.id, () => { + ctrl.loading = true; + image$.onNext(ctrl.image); + }); + + inject$(scope, optimisedImage$, ctrl, 'optimisedImage'); + + scope.$watch(() => ctrl.optimisedImage, () => ctrl.loading = false); + } + }; +}]); + image.directive('grStopPropagation', function() { return { restrict: 'A', diff --git a/kahuna/public/js/quotas.js b/kahuna/public/js/quotas.js index d20b6e4fbc9..103c7f8d1ad 100644 --- a/kahuna/public/js/quotas.js +++ b/kahuna/public/js/quotas.js @@ -1,5 +1,5 @@ const mediaApiUri = document.querySelector('link[rel="media-api-uri"').href; -const usageUri = mediaApiUri + 'usage/quotas'; +const usageUri = mediaApiUri + '/usage/quotas'; const show = (content) => { document.getElementById("root").innerHTML = content; diff --git a/kahuna/public/js/search/index.js b/kahuna/public/js/search/index.js index 842a6ad4629..8acde159078 100644 --- a/kahuna/public/js/search/index.js +++ b/kahuna/public/js/search/index.js @@ -176,6 +176,7 @@ search.config(['$stateProvider', '$urlMatcherFactoryProvider', 'uploadedBy', 'until', 'orderBy', + 'useAISearch', 'dateField', 'takenSince', 'takenUntil', diff --git a/kahuna/public/js/search/query.html b/kahuna/public/js/search/query.html index 33108efb378..8315b8ae32b 100644 --- a/kahuna/public/js/search/query.html +++ b/kahuna/public/js/search/query.html @@ -1,4 +1,12 @@
  2. @@ -84,7 +92,7 @@ -
    +
    diff --git a/kahuna/public/js/search/query.js b/kahuna/public/js/search/query.js index abd7c0fdf62..e3f0192b29d 100644 --- a/kahuna/public/js/search/query.js +++ b/kahuna/public/js/search/query.js @@ -23,6 +23,7 @@ import { DefaultSortOption, SortOptions } from "../components/gr-sort-control/gr-sort-control-config"; +import {getFeatureSwitchActive} from "../components/gr-feature-switch-panel/gr-feature-switch-panel"; export var query = angular.module('kahuna.search.query', [ // Note: temporarily disabled for performance reasons, see above @@ -53,7 +54,6 @@ query.controller('SearchQueryCtrl', [ ctrl.costFilterFalseValue = ctrl.costFilterChargeable ? undefined : "'true'"; ctrl.costFilterTrueValue = ctrl.costFilterChargeable ? "'true'" : undefined; ctrl.maybeOrgOwnedValue = window._clientConfig.maybeOrgOwnedValue; - ctrl.canUpload = false; mediaApi.canUserUpload().then(canUpload => { ctrl.canUpload = canUpload; @@ -75,6 +75,13 @@ query.controller('SearchQueryCtrl', [ ctrl.filterMyUploads = false; ctrl.initialShowPaidEvent = ($stateParams.nonFree === undefined && ctrl.usePermissionsFilter) ? false : true; + ctrl.shouldDisplayAISearchOption = getFeatureSwitchActive("enable-ai-search"); + if (!ctrl.shouldDisplayAISearchOption) { + ctrl.useAISearch = false; + } else { + ctrl.useAISearch = ($stateParams.useAISearch === 'true' || $stateParams.useAISearch === true) ? true : false; + } + //--react - angular interop events-- function raisePayableImagesEvent(showPaid) { const boolShowPaid = (showPaid === true || showPaid === "true") ? true : false; @@ -236,7 +243,7 @@ query.controller('SearchQueryCtrl', [ options: SortOptions, selectedOption: DefaultSortOption, onSelect: updateSortChips, - query: ctrl.filter.query, + query: $stateParams.query, orderBy: ctrl.ordering ? ctrl.ordering.orderBy : "" }; //-end sort control- @@ -301,7 +308,6 @@ query.controller('SearchQueryCtrl', [ 'dateField', 'since', 'until', 'takenSince', 'takenUntil', 'modifiedSince', 'modifiedUntil' ]; - Object.keys($stateParams). // Exclude date-related filters, managed separately in dateFilter filter(key => dateFilterParams.indexOf(key) === -1). @@ -315,7 +321,7 @@ query.controller('SearchQueryCtrl', [ function setAndWatchParam(key) { //this value has been set on ctrl.order if (key !== 'orderBy') { - ctrl.filter[key] = valOrUndefined($stateParams[key]); + ctrl.filter[key] = valOrUndefined($stateParams[key]); } ctrl.collectionSearch = ctrl.filter.query ? ctrl.filter.query.indexOf('~') === 0 : false; @@ -364,8 +370,20 @@ query.controller('SearchQueryCtrl', [ })); $scope.$watch(() => ctrl.ordering.orderBy, onValChange(newVal => { - $state.go('search.results', {...ctrl.filter, ...{orderBy: newVal}}); + $state.go('search.results', {...ctrl.filter, orderBy: newVal}); })); + $scope.$watch(() => ctrl.useAISearch, () => { + // Note: $watch expressions execute at least once during initialization, so this is executed on page refresh. + // This is the behaviour we want so that the URL is updated based on the AI search toggle + if (ctrl.useAISearch) { + $state.go('search.results', { + ...ctrl.filter, + useAISearch: true + }); + } else { + $state.go('search.results', {...ctrl.filter, useAISearch: undefined}); + } + }); $scope.$watchCollection(() => ctrl.dateFilter, onValChange(({field, since, until}) => { // Translate dateFilter to actual state and query params diff --git a/kahuna/public/js/search/results.html b/kahuna/public/js/search/results.html index 32a6abdc052..d07b759942a 100644 --- a/kahuna/public/js/search/results.html +++ b/kahuna/public/js/search/results.html @@ -31,13 +31,29 @@ gr-tooltip-updates> {{ctrl.newImagesCount | toLocaleString}} new -
    @@ -121,6 +137,12 @@ ng-if="ctrl.selectionCount > 0 && !ctrl.isDeleted"> + + +
    -
    + + +
    Too many results to display
    @@ -198,7 +232,8 @@
    - No matches, sorry! Try altering your search. + No matches, sorry! Try altering your search. + Please enter a query
    diff --git a/kahuna/public/js/search/results.js b/kahuna/public/js/search/results.js index 3f00803a85d..4891db99a6b 100644 --- a/kahuna/public/js/search/results.js +++ b/kahuna/public/js/search/results.js @@ -9,7 +9,9 @@ import '../util/rx'; import '../util/seq'; import '../util/constants/sendToCapture-config'; import '../components/gu-lazy-table/gu-lazy-table'; +import '../components/gu-lazy-preview/gu-lazy-preview'; import '../components/gu-lazy-table-shortcuts/gu-lazy-table-shortcuts'; +import '../components/gu-lazy-preview-shortcuts/gu-lazy-preview-shortcuts'; import '../components/gr-archiver/gr-archiver'; import '../components/gr-delete-image/gr-delete-image'; import '../components/gr-undelete-image/gr-un-delete-image'; @@ -35,6 +37,8 @@ export var results = angular.module('kahuna.search.results', [ 'util.seq', 'gu.lazyTable', 'gu.lazyTableShortcuts', + 'gu.lazyPreview', + 'gu.lazyPreviewShortcuts', 'gr.archiver', 'gr.downloader', 'gr.batchExportOriginalImages', @@ -115,6 +119,11 @@ results.controller('SearchResultsCtrl', [ ctrl.newImagesCount = 0; ctrl.newImagesLastCheckedMoment = moment(); + // Preview control + ctrl.previewView = false; + + ctrl.needsQuery = $stateParams.useAISearch && (!$stateParams.query || !$stateParams.query.trim()); + // Map to track image->position and help remove duplicates let imagesPositions; @@ -124,7 +133,7 @@ results.controller('SearchResultsCtrl', [ ctrl.loading = true; ctrl.revealNewImages = revealNewImages; - ctrl.applyOrgOwnedFilter = applyOrgOwnedFilter; + ctrl.applyFilter = applyFilter; ctrl.getLastSeenVal = getLastSeenVal; ctrl.imageHasBeenSeen = imageHasBeenSeen; @@ -144,8 +153,8 @@ results.controller('SearchResultsCtrl', [ // TODO: avoid this initial search (two API calls to init!) ctrl.searched = search({length: 1, orderBy: 'newest'}).then(function(images) { ctrl.totalResults = images.total; - // FIXME: https://github.com/argo-rest/theseus has forced us to co-opt the actions field for this - ctrl.orgOwnedCount = images.$response?.$$state?.value?.actions; + // FIXME: https://github.com/argo-rest/theseus has forced us to co-opt the actions field for this + ctrl.tickerCounts = images.$response?.$$state?.value?.actions?.tickerCounts; ctrl.hasQuery = !!$stateParams.query; ctrl.initialSearchUri = images.uri; @@ -250,7 +259,22 @@ results.controller('SearchResultsCtrl', [ // displayed image is matching the uploadTime ctrl.newImagesCount = resp.total; // FIXME: https://github.com/argo-rest/theseus has forced us to co-opt the actions field for this - ctrl.newOrgOwnedCount = resp.$response?.$$state?.value?.actions; + const newTickerCounts = resp.$response?.$$state?.value?.actions?.tickerCounts; + if (newTickerCounts) { + Object.entries(newTickerCounts).forEach(([key, {value, subCounts}]) => { + const existing = ctrl.tickerCounts[key]; + existing.value += value; + if (existing.subCounts && subCounts){ + Object.entries(subCounts).forEach(([subKey, subCount]) => { + if (existing.subCounts[subKey]){ + existing.subCounts[subKey] += subCount; + } else { + existing.subCounts["other"] += subCount; + } + }); + } + }); + } if (ctrl.newImagesCount > 0) { $rootScope.$emit('events:new-images', { count: ctrl.newImagesCount}); @@ -277,16 +301,15 @@ results.controller('SearchResultsCtrl', [ } ctrl.maybeOrgOwnedValue = window._clientConfig.maybeOrgOwnedValue; - const isOrgOwnedClause = `is:${ctrl.maybeOrgOwnedValue}`; - function applyOrgOwnedFilter() { + function applyFilter(searchClause) { $window.scrollTo(0,0); - const toParams = $stateParams.query?.includes(isOrgOwnedClause) + const toParams = $stateParams.query?.includes(searchClause) ? $stateParams : { ...$stateParams, query: $stateParams.query - ? `${$stateParams.query} ${isOrgOwnedClause}` - : isOrgOwnedClause + ? `${$stateParams.query} ${searchClause}` + : searchClause }; $state.transitionTo( $state.current, @@ -373,6 +396,7 @@ results.controller('SearchResultsCtrl', [ offset: offset, length: length, orderBy: orderBy, + useAISearch: $stateParams.useAISearch, hasRightsAcquired: $stateParams.hasRightsAcquired, hasCrops: $stateParams.hasCrops, syndicationStatus: $stateParams.syndicationStatus, diff --git a/kahuna/public/js/search/structured-query/query-suggestions.ts b/kahuna/public/js/search/structured-query/query-suggestions.ts index 382dbb19997..b3a6f723d1a 100644 --- a/kahuna/public/js/search/structured-query/query-suggestions.ts +++ b/kahuna/public/js/search/structured-query/query-suggestions.ts @@ -47,19 +47,17 @@ const subjects = [ const fileTypes = ["jpeg", "tiff", "png"]; -const staffPhotographerOrganisation = - window._clientConfig.staffPhotographerOrganisation; - const isSearch = [ - `${staffPhotographerOrganisation}-owned-photo`, - `${staffPhotographerOrganisation}-owned-illustration`, - `${staffPhotographerOrganisation}-owned`, + `owned-photo`, + `owned-illustration`, + `owned`, "under-quota", - "deleted" + "deleted", + "reapable" ]; -if (window._clientConfig.useReaper === true) { - isSearch.push("reapable"); +if (window._clientConfig.agencyPicksIngredients) { + isSearch.push("agency-pick"); } type TypeaheadField = { @@ -158,6 +156,10 @@ querySuggestions.factory("querySuggestions", [ { fieldName: "is", resolver: isSearch }, { fieldName: "dateTaken" } as const, { fieldName: "date" }, + { + fieldName: "syndicationStatus", + resolver: ['review', 'queued', 'sent', 'unsuitable'] + }, ...Object.keys(fieldAliases).map((fieldName) => { const staticAliasOptions = suggestFieldAliasOptions(fieldName); diff --git a/kahuna/public/js/search/structured-query/structured-query.js b/kahuna/public/js/search/structured-query/structured-query.js index 6583d4defc3..4fa365f942a 100644 --- a/kahuna/public/js/search/structured-query/structured-query.js +++ b/kahuna/public/js/search/structured-query/structured-query.js @@ -73,7 +73,8 @@ grStructuredQuery.directive("grStructuredQuery", [ ng-model="ctrl.structuredQuery" gr:valid-keys="ctrl.filterFields" gr:on-change="ctrl.structuredQueryChanged($chips)" - gr:autocomplete="ctrl.getSuggestions($chip)"> + gr:autocomplete="ctrl.getSuggestions($chip)" + use-ai-search="searchQuery.useAISearch"> values.some(value => { + const fieldValue = field.split('.').reduce((a, b) => a[b], image.data); + if (typeof fieldValue === 'string'){ + return fieldValue.toLowerCase().includes(value.toLowerCase()); + } + if (Array.isArray(fieldValue)) { + return fieldValue.includes(value); + } + return false; + })); + } + function getPersistenceExplanation(image) { const persistReasons = imageAccessor.readPersistedReasons(image); return persistReasons.map(reason => { @@ -111,6 +125,7 @@ imageLogic.factory('imageLogic', ['imageAccessor', function(imageAccessor) { getArchivedState, getPersistenceExplanation, isStaffPhotographer, + isAgencyPick, getSyndicationStatus, getSyndicationReason, hasSyndicationRights, diff --git a/kahuna/public/js/services/image/usages.js b/kahuna/public/js/services/image/usages.js index a0dc0ae5e7a..e8e44cbb158 100644 --- a/kahuna/public/js/services/image/usages.js +++ b/kahuna/public/js/services/image/usages.js @@ -74,7 +74,7 @@ imageUsagesService.factory('imageUsagesService', [function() { return usagesList.filter(item=> { const timestamp = item.get('dateAdded'); const recentIfAfter = moment().subtract(recentDays, 'days'); - return moment(timestamp).isAfter(recentIfAfter); + return item.get('status') === 'published' && moment(timestamp).isAfter(recentIfAfter); }); }); const recentDigitalUsages$ = filterByPlatform('digital').map((usagesList) => { diff --git a/kahuna/public/js/util/constants/cropOptions.js b/kahuna/public/js/util/constants/cropOptions.js index dc1bbea2b16..1945b3ac2ae 100644 --- a/kahuna/public/js/util/constants/cropOptions.js +++ b/kahuna/public/js/util/constants/cropOptions.js @@ -1,9 +1,10 @@ // `ratioString` is sent to the server, being `undefined` for `freeform` is expected 🙈 -export const landscapeOld = {key: 'landscape OLD', ratio: 5 / 3, ratioString: '5:3', isHidden: true}; -export const landscape = {key: 'landscape', ratio: 5 / 4, ratioString: '5:4'}; -export const portrait = {key: 'portrait', ratio: 4 / 5, ratioString: '4:5'}; -export const video = {key: 'video', ratio: 16 / 9, ratioString: '16:9'}; -export const square = {key: 'square', ratio: 1, ratioString: '1:1'}; -export const freeform = {key: 'freeform', ratio: null, isDefault: true}; +export const landscapeOld = {key: 'landscape OLD', ratio: 5 / 3, ratioString: '5:3', displayName: "Landscape 5:3"}; +export const landscape32 = {key: 'landscape32', ratio: 3 / 2, ratioString: '3:2', displayName: "landscape 3:2"}; +export const landscape = {key: 'landscape', ratio: 5 / 4, ratioString: '5:4', isHidden: true, displayName: 'Landscape'}; +export const portrait = {key: 'portrait', ratio: 4 / 5, ratioString: '4:5', displayName: 'Portrait'}; +export const video = {key: 'video', ratio: 16 / 9, ratioString: '16:9', displayName: 'Video'}; +export const square = {key: 'square', ratio: 1, ratioString: '1:1', displayName: 'Square'}; +export const freeform = {key: 'freeform', ratio: null, isDefault: true, displayName: 'Freeform'}; -export const cropOptions = [landscapeOld, landscape, portrait, video, square, freeform]; +export const cropOptions = [landscapeOld, landscape32, landscape, portrait, video, square, freeform]; diff --git a/kahuna/public/js/window.ts b/kahuna/public/js/window.ts index 905cccd4d37..d542f20b2f1 100644 --- a/kahuna/public/js/window.ts +++ b/kahuna/public/js/window.ts @@ -43,6 +43,9 @@ declare global { maybeUploadLimitInBytes: number; imageTypes: string[]; staffPhotographerOrganisation: string; + agencyPicksIngredients: { + [field: string]: string[]; + } }; } } diff --git a/kahuna/public/stylesheets/main.css b/kahuna/public/stylesheets/main.css index ae74b3b467c..8df5701b071 100644 --- a/kahuna/public/stylesheets/main.css +++ b/kahuna/public/stylesheets/main.css @@ -1028,6 +1028,10 @@ textarea.ng-invalid { cursor: pointer; } +#ai-search-checkbox { + align-self: center; +} + .search__advanced-toggle-lbl { margin-right: 6px; } @@ -1501,15 +1505,18 @@ textarea.ng-invalid { margin-left: 5px; } -.image-results-count__org-owned { +.image-results-ticker { font-family: inherit; - background-color: #005689; color: white; display: inline-block; padding: 2px 4px; border-radius: 2px; margin-left: 5px; } +.image-results-ticker .titip-content { + transition: opacity 0.25s ease; + transition-delay: 0.5s; +} .image-results-count { border-left: 0; diff --git a/leases/app/LeasesComponents.scala b/leases/app/LeasesComponents.scala index e236f6f3139..e3dcdf309bf 100644 --- a/leases/app/LeasesComponents.scala +++ b/leases/app/LeasesComponents.scala @@ -1,19 +1,18 @@ -import com.gu.mediaservice.lib.management.InnerServiceStatusCheckController import com.gu.mediaservice.lib.play.GridComponents import controllers.MediaLeaseController import lib.{LeaseNotifier, LeaseStore, LeasesConfig} import play.api.ApplicationLoader.Context import router.Routes +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient class LeasesComponents(context: Context) extends GridComponents(context, new LeasesConfig(_)) { final override val buildInfo = utils.buildinfo.BuildInfo - val store = new LeaseStore(config) + val store = new LeaseStore(config.leasesTable,config.withAWSCredentialsV2(DynamoDbAsyncClient.builder()).build()) val notifications = new LeaseNotifier(config, store) val controller = new MediaLeaseController(auth, store, config, notifications, controllerComponents) - val InnerServiceStatusCheckController = new InnerServiceStatusCheckController(auth, controllerComponents, config.services, wsClient) - override lazy val router = new Routes(httpErrorHandler, controller, management, InnerServiceStatusCheckController) + override lazy val router = new Routes(httpErrorHandler, controller, management) } diff --git a/leases/app/controllers/MediaLeaseController.scala b/leases/app/controllers/MediaLeaseController.scala index 7e9b7b10fd8..bc4758147ab 100644 --- a/leases/app/controllers/MediaLeaseController.scala +++ b/leases/app/controllers/MediaLeaseController.scala @@ -1,10 +1,11 @@ package controllers import java.util.UUID - import com.gu.mediaservice.lib.argo._ import com.gu.mediaservice.lib.argo.model._ import com.gu.mediaservice.lib.auth._ +import com.gu.mediaservice.model.Instance +import com.gu.mediaservice.lib.config.InstanceForRequest import com.gu.mediaservice.model.leases.{LeasesByMedia, MediaLease} import lib.{LeaseNotifier, LeaseStore, LeasesConfig} import play.api.libs.json._ @@ -21,23 +22,23 @@ object AppIndex { class MediaLeaseController(auth: Authentication, store: LeaseStore, config: LeasesConfig, notifications: LeaseNotifier, override val controllerComponents: ControllerComponents)(implicit val ec: ExecutionContext) - extends BaseController with ArgoHelpers { + extends BaseController with ArgoHelpers with InstanceForRequest { private val notFound = respondNotFound("MediaLease not found") - private val indexResponse = { + private def indexResponse()(instance: Instance) = { val appIndex = AppIndex("media-leases", "Media leases service", Map()) val indexLinks = List( - Link("leases", s"${config.rootUri}/leases/{id}"), - Link("by-media-id", s"${config.rootUri}/leases/media/{id}")) + Link("leases", s"${config.rootUri(instance)}/leases/{id}"), + Link("by-media-id", s"${config.rootUri(instance)}/leases/media/{id}")) respond(appIndex, indexLinks) } - private def clearLease(id: String) = store.get(id).collect { case Some(lease) => - store.delete(id).map { _ => notifications.sendRemoveLease(lease.mediaId, id)} + private def clearLease(id: String)(implicit instance: Instance) = store.get(id).collect { case Some(lease) => + store.delete(id).map { _ => notifications.sendRemoveLease(lease.mediaId, id, instance)} }.flatten - private def clearLeases(id: String) = store.getForMedia(id).flatMap { leases => + private def clearLeases(id: String)(implicit instance: Instance) = store.getForMedia(id).flatMap { leases => Future.sequence(leases.map(_.id).collect { case Some(id) => clearLease(id) }) @@ -49,7 +50,7 @@ class MediaLeaseController(auth: Authentication, store: LeaseStore, config: Leas private def prepareLeaseForSave(mediaLease: MediaLease, userId: Option[String]): MediaLease = mediaLease.prepareForSave.copy(id = Some(UUID.randomUUID().toString), leasedBy = userId) - private def addLease(mediaLease: MediaLease, userId: Option[String]) = { + private def addLease(mediaLease: MediaLease, userId: Option[String])(implicit instance: Instance) = { val lease = prepareLeaseForSave(mediaLease, userId) if (lease.isSyndication) { for { @@ -59,42 +60,44 @@ class MediaLeaseController(auth: Authentication, store: LeaseStore, config: Leas } yield replacement } else { store.put(lease).map { _ => - notifications.sendAddLease(lease) + notifications.sendAddLease(lease, instance) } } } - private def addLeases(mediaLeases: List[MediaLease], userId: Option[String]) = { + private def addLeases(mediaLeases: List[MediaLease], userId: Option[String])(implicit instance: Instance) = { val preparedMediaLeases = mediaLeases.map(prepareLeaseForSave(_, userId)) store.putAll(preparedMediaLeases).map { _ => - preparedMediaLeases.map(notifications.sendAddLease) + preparedMediaLeases.map(lease => notifications.sendAddLease(lease, instance)) } } - private def replaceLeases(mediaLeases: List[MediaLease], imageId: String, userId: Option[String]) = { + private def replaceLeases(mediaLeases: List[MediaLease], imageId: String, userId: Option[String])(implicit instance: Instance) = { val preparedMediaLeases = mediaLeases.map(prepareLeaseForSave(_, userId)) for { _ <- clearLeases(imageId) _ <- store.putAll(preparedMediaLeases) } yield { - notifications.sendAddLeases(preparedMediaLeases, imageId) + notifications.sendAddLeases(preparedMediaLeases, imageId, instance) } } - def index = auth { _ => indexResponse } + def index = auth { request => indexResponse()(instanceOf(request)) } - def reindex = auth.async { _ => + def reindex = auth.async { request => + implicit val instance: Instance = instanceOf(request) for { reindexRequests <- store.forEach { leases => leases .foldLeft(Set[String]())((ids, lease) => ids + lease.mediaId) - .map(notifications.sendReindexLeases) + .map(mediaId => notifications.sendReindexLeases(mediaId)) } _ <- Future.sequence(reindexRequests) } yield Accepted } def postLease = auth.async(parse.json) { implicit request => + implicit val instance: Instance = instanceOf(request) request.body.validate[MediaLease] match { case JsSuccess(mediaLease, _) => addLease(mediaLease, Some(Authentication.getIdentity(request.user))).map(_ => Accepted) @@ -105,6 +108,7 @@ class MediaLeaseController(auth: Authentication, store: LeaseStore, config: Leas } def addLeasesForMedia(id: String) = auth.async(parse.json) { implicit request => + implicit val instance: Instance = instanceOf(request) request.body.validate[List[MediaLease]] match { case JsSuccess(mediaLeases, _) => addLeases(mediaLeases, Some(Authentication.getIdentity(request.user))).map(_ => Accepted) @@ -114,29 +118,33 @@ class MediaLeaseController(auth: Authentication, store: LeaseStore, config: Leas } def deleteLease(id: String) = auth.async { implicit request => + implicit val instance: Instance = instanceOf(request) for { _ <- clearLease(id) } yield Accepted } - def getLease(id: String) = auth.async { _ => + def getLease(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) for { leases <- store.get(id) } yield { leases.foldLeft(notFound)((_, lease) => respond[MediaLease]( uri = config.leaseUri(id), data = lease, links = lease.id - .map(config.mediaApiLink) + .map(id => config.mediaApiLink(id)) .toList )) } } - def deleteLeasesForMedia(id: String) = auth.async { _ => + def deleteLeasesForMedia(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) for { _ <- clearLeases(id) } yield Accepted } def validateLeases(leases: List[MediaLease]) = leases.count { _.isSyndication } <= 1 def replaceLeasesForMedia(id: String) = auth.async(parse.json) { implicit request => Future { + implicit val instance: Instance = instanceOf(request) request.body.validate[List[MediaLease]].fold( badRequest, mediaLeases => { @@ -150,7 +158,8 @@ class MediaLeaseController(auth: Authentication, store: LeaseStore, config: Leas ) }} - def getLeasesForMedia(id: String) = auth.async { _ => + def getLeasesForMedia(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) for { leases <- store.getForMedia(id) } yield { respond[LeasesByMedia]( uri = config.leasesMediaUri(id), diff --git a/leases/app/lib/LeaseNotifier.scala b/leases/app/lib/LeaseNotifier.scala index 40cf61bc3d2..5479eb8f660 100644 --- a/leases/app/lib/LeaseNotifier.scala +++ b/leases/app/lib/LeaseNotifier.scala @@ -1,6 +1,7 @@ package lib import com.gu.mediaservice.lib.aws.{ThrallMessageSender, UpdateMessage} +import com.gu.mediaservice.model.Instance import com.gu.mediaservice.model.leases.MediaLease import com.gu.mediaservice.syntax.MessageSubjects import org.joda.time.DateTime @@ -8,25 +9,25 @@ import org.joda.time.DateTime import scala.concurrent.ExecutionContext class LeaseNotifier(config: LeasesConfig, store: LeaseStore) extends ThrallMessageSender(config.thrallKinesisStreamConfig) with MessageSubjects { - def sendReindexLeases(mediaId: String)(implicit ec: ExecutionContext) = { + def sendReindexLeases(mediaId: String)(implicit ec: ExecutionContext, instance: Instance) = { for { leases <- store.getForMedia(mediaId) } yield { - val updateMessage = UpdateMessage(subject = ReplaceImageLeases, leases = Some(leases), id = Some(mediaId)) + val updateMessage = UpdateMessage(subject = ReplaceImageLeases, leases = Some(leases), id = Some(mediaId), instance = instance) publish(updateMessage) } } - def sendAddLease(mediaLease: MediaLease) = { - val updateMessage = UpdateMessage(subject = AddImageLease, mediaLease = Some(mediaLease), id = Some(mediaLease.mediaId)) + def sendAddLease(mediaLease: MediaLease, instance: Instance) = { + val updateMessage = UpdateMessage(subject = AddImageLease, mediaLease = Some(mediaLease), id = Some(mediaLease.mediaId), instance = instance) publish(updateMessage) } - def sendAddLeases(mediaLeases: List[MediaLease], mediaId: String) = { - val updateMessage = UpdateMessage(subject = ReplaceImageLeases, leases = Some(mediaLeases), id = Some(mediaId)) + def sendAddLeases(mediaLeases: List[MediaLease], mediaId: String, instance: Instance) = { + val updateMessage = UpdateMessage(subject = ReplaceImageLeases, leases = Some(mediaLeases), id = Some(mediaId), instance = instance) publish(updateMessage) } - def sendRemoveLease(mediaId: String, leaseId: String) = { - val updateMessage = UpdateMessage(subject = RemoveImageLease, id = Some(mediaId), leaseId = Some(leaseId)) + def sendRemoveLease(mediaId: String, leaseId: String, instance: Instance) = { + val updateMessage = UpdateMessage(subject = RemoveImageLease, id = Some(mediaId), leaseId = Some(leaseId), instance = instance) publish(updateMessage) } } diff --git a/leases/app/lib/LeaseStore.scala b/leases/app/lib/LeaseStore.scala index 3c06a71f8dc..b894ffe182c 100644 --- a/leases/app/lib/LeaseStore.scala +++ b/leases/app/lib/LeaseStore.scala @@ -1,48 +1,72 @@ package lib +import com.gu.mediaservice.model.Instance import com.gu.mediaservice.model.leases.{MediaLease, MediaLeaseType} +import org.joda.time.DateTime import org.scanamo._ +import org.scanamo.generic.auto.{Typeclass, _} import org.scanamo.syntax._ -import org.scanamo.generic.auto._ -import org.joda.time.DateTime import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient import scala.concurrent.{ExecutionContext, Future} -class LeaseStore(config: LeasesConfig) { - val client = config.withAWSCredentialsV2(DynamoDbAsyncClient.builder()).build() +class LeaseStore(tableName: String, client: DynamoDbAsyncClient) { implicit val dateTimeFormat: Typeclass[DateTime] = DynamoFormat.coercedXmap[DateTime, String, IllegalArgumentException](DateTime.parse, _.toString) implicit val enumFormat: Typeclass[MediaLeaseType] = DynamoFormat.coercedXmap[MediaLeaseType, String, IllegalArgumentException](MediaLeaseType(_), _.toString) - private val leasesTable = Table[MediaLease](config.leasesTable) + private val baseMediaLeaseFormat = DynamoFormat[MediaLease] - def get(id: String)(implicit ec: ExecutionContext): Future[Option[MediaLease]] = { - ScanamoAsync(client).exec(leasesTable.get("id" === id)).map(_.flatMap(_.toOption)) + // 2. Define a custom format that also writes the instance index field + private def instanceAwareLeaseFormat(instance: Instance): DynamoFormat[MediaLease] = new DynamoFormat[MediaLease] { + override def read(dv: DynamoValue): Either[DynamoReadError, MediaLease] = baseMediaLeaseFormat.read(dv) + + def write(l: MediaLease): DynamoValue = { + val baseValue = baseMediaLeaseFormat.write(l) + baseValue.asObject match { + case Some(obj) => + val enrichedObj = obj + ("instance" -> DynamoValue.fromString(instance.id)) + enrichedObj.toDynamoValue + case None => + // Fallback for safety, though case classes always derive to Maps + baseValue + } + } } - def getForMedia(id: String)(implicit ec: ExecutionContext): Future[List[MediaLease]] = { - ScanamoAsync(client).exec(leasesTable.index("mediaId").query("mediaId" === id)).map(_.flatMap(_.toOption)) + private val leasesTable = Table[MediaLease](tableName) + + def get(id: String)(implicit ec: ExecutionContext, instance: Instance): Future[Option[MediaLease]] = { + ScanamoAsync(client).exec(leasesTable.get("id" === id and "instance" === instance.id)).map(_.flatMap(_.toOption)) } - def put(lease: MediaLease)(implicit ec: ExecutionContext) = { - ScanamoAsync(client).exec(leasesTable.put(lease)) + def getForMedia(id: String)(implicit ec: ExecutionContext, instance: Instance): Future[List[MediaLease]] = { + ScanamoAsync(client).exec(leasesTable.index("mediaId").query("mediaId" === id and "instance" === instance.id)).map(_.flatMap(_.toOption)) } - def putAll(leases: List[MediaLease])(implicit ec: ExecutionContext) = { - ScanamoAsync(client).exec(leasesTable.putAll(leases.toSet)) + def put(lease: MediaLease)(implicit ec: ExecutionContext, instance: Instance) = { + implicit val format: Typeclass[MediaLease] = instanceAwareLeaseFormat(instance) + val t = Table[MediaLease](tableName) + val op = t.put(lease) + ScanamoAsync(client).exec(op) } - def delete(id: String)(implicit ec: ExecutionContext) = { - ScanamoAsync(client).exec(leasesTable.delete("id" === id)) + def putAll(leases: List[MediaLease])(implicit ec: ExecutionContext, instance: Instance) = { + implicit val format: Typeclass[MediaLease] = instanceAwareLeaseFormat(instance) + val t = Table[MediaLease](tableName) + ScanamoAsync(client).exec(t.putAll(leases.toSet)) } - def forEach[T](run: List[MediaLease] => T)(implicit ec: ExecutionContext) = ScanamoAsync(client).exec( - leasesTable.scan() - .map(ops => ops.flatMap(_.toOption)) + def delete(id: String)(implicit ec: ExecutionContext, instance: Instance) = { + ScanamoAsync(client).exec(leasesTable.delete("id" === id and "instance" === instance.id)) + } + + def forEach[T](run: List[MediaLease] => T)(implicit ec: ExecutionContext, instance: Instance) = { + val instanceLeasesQuery = leasesTable.query("instance" === instance.id) + ScanamoAsync(client).exec(instanceLeasesQuery).map(ops => ops.flatMap(_.toOption)) .map(run) - ) + } } diff --git a/leases/app/lib/LeasesConfig.scala b/leases/app/lib/LeasesConfig.scala index 23f35c81fc4..d62d17e3f8f 100644 --- a/leases/app/lib/LeasesConfig.scala +++ b/leases/app/lib/LeasesConfig.scala @@ -1,23 +1,24 @@ package lib import com.gu.mediaservice.lib.argo.model.Link -import com.gu.mediaservice.lib.config.{CommonConfig, GridConfigResources} +import com.gu.mediaservice.lib.config.{CommonConfig, GridConfigResources, InstanceForRequest} +import com.gu.mediaservice.model.Instance import java.net.URI import scala.util.Try -class LeasesConfig(resources: GridConfigResources) extends CommonConfig(resources) { +class LeasesConfig(resources: GridConfigResources) extends CommonConfig(resources) with InstanceForRequest { val leasesTable = string("dynamo.tablename.leasesTable") - val rootUri: String = services.leasesBaseUri + def rootUri: Instance => String = services.leasesBaseUri private def uri(u: String) = URI.create(u) - private val leasesUri = uri(s"$rootUri/leases") + private def leasesUri(instance: Instance) = uri(s"${rootUri(instance)}/leases") - def leaseUri(leaseId: String): Option[URI] = Try { URI.create(s"$leasesUri/$leaseId") }.toOption - def leasesMediaUri(mediaId: String) = Try { URI.create(s"$leasesUri/media/$mediaId") }.toOption + def leaseUri(leaseId: String)(implicit instance: Instance): Option[URI] = Try { URI.create(s"${leasesUri(instance)}/$leaseId") }.toOption + def leasesMediaUri(mediaId: String)(implicit instance: Instance) = Try { URI.create(s"${leasesUri(instance)}/media/$mediaId") }.toOption - private def mediaApiUri(id: String) = s"${services.apiBaseUri}/images/$id" - def mediaApiLink(id: String) = Link("media", mediaApiUri(id)) + private def mediaApiUri(id: String)(implicit instance: Instance) = s"${services.apiBaseUri(instance)}/images/$id" + def mediaApiLink(id: String)(implicit instance: Instance) = Link("media", mediaApiUri(id)) } diff --git a/leases/conf/routes b/leases/conf/routes index f5849a960db..2402837822a 100644 --- a/leases/conf/routes +++ b/leases/conf/routes @@ -15,7 +15,6 @@ POST /leases controllers.MediaLeaseCont # Management GET /management/healthcheck com.gu.mediaservice.lib.management.Management.healthCheck GET /management/manifest com.gu.mediaservice.lib.management.Management.manifest -GET /management/whoAmI com.gu.mediaservice.lib.management.InnerServiceStatusCheckController.whoAmI(depth: Int) # Shoo robots away diff --git a/leases/test/lib/LeaseStoreSpec.scala b/leases/test/lib/LeaseStoreSpec.scala new file mode 100644 index 00000000000..d9ae3e3e85f --- /dev/null +++ b/leases/test/lib/LeaseStoreSpec.scala @@ -0,0 +1,146 @@ +package lib + +import com.gu.mediaservice.model.Instance +import com.gu.mediaservice.model.leases.{AllowUseLease, MediaLease} +import org.joda.time.{DateTime, DateTimeZone} +import org.scalatest.BeforeAndAfterAll +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.time.{Millis, Seconds, Span} +import org.scalatestplus.mockito.MockitoSugar +import org.testcontainers.containers.localstack.LocalStackContainer +import org.testcontainers.containers.localstack.LocalStackContainer.Service.DYNAMODB +import org.testcontainers.utility.DockerImageName +import software.amazon.awssdk.auth.credentials.{AwsBasicCredentials, StaticCredentialsProvider} +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient +import software.amazon.awssdk.services.dynamodb.model._ + +import java.util.UUID +import scala.concurrent.ExecutionContext.Implicits.global +import scala.jdk.CollectionConverters._ + +class LeaseStoreSpec extends AnyFunSpec with Matchers with ScalaFutures with BeforeAndAfterAll with MockitoSugar { + + implicit val defaultPatience: PatienceConfig = PatienceConfig(timeout = Span(2, Seconds), interval = Span(100, Millis)) + + private val dynamoContainer = new LocalStackContainer(DockerImageName.parse("localstack/localstack:1.4.0")).withServices(DYNAMODB) + dynamoContainer.start() + + private val dynamoClient = DynamoDbAsyncClient.builder(). + endpointOverride(dynamoContainer.getEndpointOverride(DYNAMODB)). + region(Region.of(dynamoContainer.getRegion)). + credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(dynamoContainer.getAccessKey, dynamoContainer.getSecretKey))).build() + + private val leasesTable = "test-leases-table-" + UUID.randomUUID().toString + + private val store = new LeaseStore(leasesTable, dynamoClient) + + private implicit val instance: Instance = Instance("an-instance") + + override def beforeAll(): Unit = { + def createTableRequestFor(tableName: String): CreateTableRequest = { + val attributeDefinitions = List( + AttributeDefinition.builder.attributeName("id").attributeType(ScalarAttributeType.S).build(), + AttributeDefinition.builder.attributeName("instance").attributeType(ScalarAttributeType.S).build(), + AttributeDefinition.builder.attributeName("mediaId").attributeType(ScalarAttributeType.S).build() + ) + val keySchema = List( + KeySchemaElement.builder.attributeName("instance").keyType(KeyType.HASH).build(), + KeySchemaElement.builder.attributeName("id").keyType(KeyType.RANGE).build() + ) + val globalSecondaryIndexes = List( + GlobalSecondaryIndex.builder() + .indexName("mediaId") + .keySchema( + KeySchemaElement.builder.attributeName("instance").keyType(KeyType.HASH).build(), + KeySchemaElement.builder().attributeName("mediaId").keyType(KeyType.RANGE).build() + ) + .projection(Projection.builder().projectionType(ProjectionType.ALL).build()) + .provisionedThroughput(ProvisionedThroughput.builder().readCapacityUnits(1L).writeCapacityUnits(1L).build()) + .build() + ) + CreateTableRequest.builder + .tableName(tableName) + .attributeDefinitions(attributeDefinitions.asJava) + .keySchema(keySchema.asJava) + .globalSecondaryIndexes(globalSecondaryIndexes.asJava) + .provisionedThroughput(ProvisionedThroughput.builder.readCapacityUnits(1L).writeCapacityUnits(1L).build()) + .build() + } + + dynamoClient.createTable(createTableRequestFor(leasesTable)).get() + } + + override def afterAll(): Unit = { + super.afterAll() + dynamoContainer.stop() + } + + describe("LeaseStore") { + val now = DateTime.now.withZone(DateTimeZone.UTC) + val lease = MediaLease( + id = Some(UUID.randomUUID().toString), + mediaId = "media-id-1", + leasedBy = Some("test"), + notes = Some("test notes"), + access = AllowUseLease, + createdAt = now + ) + + it("should be able to add a lease") { + val eventualResult = store.put(lease) + whenReady(eventualResult) { _ => + val readBack = store.get(lease.id.get) + whenReady(readBack) { result => + result should equal(Some(lease)) + } + } + } + + it("should be able to get a lease for a media id") { + val lease2 = lease.copy(id = Some(UUID.randomUUID().toString), mediaId = "media-id-2") + val eventualResult = store.put(lease2) + whenReady(eventualResult) { _ => + val readBack = store.getForMedia("media-id-2") + whenReady(readBack) { result => + result should be(List(lease2)) + } + } + } + + it("should be able to get all leases") { + val lease3 = lease.copy(id = Some(UUID.randomUUID().toString)) + val lease4 = lease.copy(id = Some(UUID.randomUUID().toString)) + val eventualResult = store.putAll(List(lease3, lease4)) + + whenReady(eventualResult) { _ => + val readBack = store.forEach(identity) + whenReady(readBack) { result => + result should contain allOf(lease3, lease4) + } + } + } + + it("should be able to delete a lease") { + val lease5 = lease.copy(id = Some(UUID.randomUUID().toString)) + val eventualResult = store.put(lease5) + + whenReady(eventualResult) { _ => + val readBack = store.get(lease5.id.get) + whenReady(readBack) { result => + result should be(Some(lease5)) + } + + val deleteResult = store.delete(lease5.id.get) + whenReady(deleteResult) { _ => + val readBackAfterDelete = store.get(lease5.id.get) + whenReady(readBackAfterDelete) { result => + result should be(None) + } + } + } + } + } +} diff --git a/media-api/app/MediaApiComponents.scala b/media-api/app/MediaApiComponents.scala index b77dd908aa7..93fa9f672da 100644 --- a/media-api/app/MediaApiComponents.scala +++ b/media-api/app/MediaApiComponents.scala @@ -1,5 +1,7 @@ -import com.gu.mediaservice.lib.aws.ThrallMessageSender -import com.gu.mediaservice.lib.management.{ElasticSearchHealthCheck, InnerServiceStatusCheckController, Management} +import com.gu.mediaservice.lib.aws._ +import com.gu.mediaservice.lib.instances.InstancesClient +import com.gu.mediaservice.lib.aws._ +import com.gu.mediaservice.lib.management.{ElasticSearchHealthCheck, Management} import com.gu.mediaservice.lib.metadata.SoftDeletedMetadataTable import com.gu.mediaservice.lib.play.GridComponents import controllers._ @@ -17,26 +19,27 @@ class MediaApiComponents(context: Context) extends GridComponents(context, new M val mediaApiMetrics = new MediaApiMetrics(config, actorSystem, applicationLifecycle) val s3Client = new S3Client(config) + val s3 = new S3(config) val usageQuota = new UsageQuota(config, actorSystem.scheduler) usageQuota.quotaStore.update() usageQuota.scheduleUpdates() applicationLifecycle.addStopHook(() => Future{usageQuota.stopUpdates()}) - val elasticSearch = new ElasticSearch(config, mediaApiMetrics, config.esConfig, () => usageQuota.usageStore.overQuotaAgencies, actorSystem.scheduler) - elasticSearch.ensureIndexExistsAndAliasAssigned() + val elasticSearch = new ElasticSearch(config, mediaApiMetrics, config.esConfig, () => usageQuota.usageStore.overQuotaAgencies, actorSystem.scheduler, new InstancesClient(config, wsClient)) + // TODO needs to move somewhere more instance aware elasticSearch.ensureIndexExistsAndAliasAssigned() val imageResponse = new ImageResponse(config, s3Client, usageQuota) val softDeletedMetadataTable = new SoftDeletedMetadataTable(config) + val embedder = new Embedder(new S3Vectors(config), new Bedrock(config), new SimpleSqsMessageConsumer(config.queueUrl, config)) - val mediaApi = new MediaApi(auth, messageSender, softDeletedMetadataTable, elasticSearch, imageResponse, config, controllerComponents, s3Client, mediaApiMetrics, wsClient, authorisation) + val mediaApi = new MediaApi(auth, messageSender, softDeletedMetadataTable, elasticSearch, imageResponse, config, controllerComponents, s3, mediaApiMetrics, wsClient, authorisation, embedder, events) val suggestionController = new SuggestionController(auth, elasticSearch, controllerComponents) val aggController = new AggregationController(auth, elasticSearch, controllerComponents) val usageController = new UsageController(auth, config, elasticSearch, usageQuota, controllerComponents) val elasticSearchHealthCheck = new ElasticSearchHealthCheck(controllerComponents, elasticSearch) val healthcheckController = new Management(controllerComponents, buildInfo) - val InnerServiceStatusCheckController = new InnerServiceStatusCheckController(auth, controllerComponents, config.services, wsClient) override val router = new Routes( httpErrorHandler, @@ -45,7 +48,6 @@ class MediaApiComponents(context: Context) extends GridComponents(context, new M aggController, usageController, elasticSearchHealthCheck, - healthcheckController, - InnerServiceStatusCheckController + healthcheckController ) } diff --git a/media-api/app/controllers/AggregationController.scala b/media-api/app/controllers/AggregationController.scala index c55e7f217bc..3c56edf6e4a 100644 --- a/media-api/app/controllers/AggregationController.scala +++ b/media-api/app/controllers/AggregationController.scala @@ -1,8 +1,10 @@ package controllers import com.gu.mediaservice.lib.auth.Authentication +import com.gu.mediaservice.lib.config.InstanceForRequest import com.gu.mediaservice.lib.logging.{LogMarker, MarkerMap} import com.gu.mediaservice.lib.play.RequestLoggingFilter +import com.gu.mediaservice.model.Instance import lib.elasticsearch.{AggregateSearchParams, ElasticSearch} import play.api.mvc._ @@ -10,9 +12,10 @@ import scala.concurrent.ExecutionContext class AggregationController(auth: Authentication, elasticSearch: ElasticSearch, override val controllerComponents: ControllerComponents)(implicit val ec: ExecutionContext) - extends BaseController with AggregateResponses { + extends BaseController with AggregateResponses with InstanceForRequest { def dateHistogram(field: String, q: Option[String]) = auth.async { request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "date-histogram", "requestId" -> RequestLoggingFilter.getRequestId(request), diff --git a/media-api/app/controllers/MediaApi.scala b/media-api/app/controllers/MediaApi.scala index 940e73fa673..d6287a02d99 100644 --- a/media-api/app/controllers/MediaApi.scala +++ b/media-api/app/controllers/MediaApi.scala @@ -1,24 +1,29 @@ package controllers -import org.apache.pekko.stream.scaladsl.StreamConverters import com.google.common.net.HttpHeaders -import com.gu.mediaservice.{GridClient, JsonDiff} import com.gu.mediaservice.lib.argo._ import com.gu.mediaservice.lib.argo.model.{Action, _} -import com.gu.mediaservice.lib.auth.Authentication.{Request, _} +import com.gu.mediaservice.lib.auth.Authentication._ import com.gu.mediaservice.lib.auth.Permissions.{ArchiveImages, DeleteCropsOrUsages, EditMetadata, UploadImages, DeleteImage => DeleteImagePermission} import com.gu.mediaservice.lib.auth._ +import com.gu.mediaservice.lib.auth.provider.ApiKeyAuthenticationProvider +import com.gu.mediaservice.lib.aws.{ContentDisposition, Embedder, ThrallMessageSender, UpdateMessage} +import com.gu.mediaservice.lib.aws.{ContentDisposition, S3, ThrallMessageSender, UpdateMessage} import com.gu.mediaservice.lib.aws.{ContentDisposition, ThrallMessageSender, UpdateMessage} -import com.gu.mediaservice.lib.config.Services +import com.gu.mediaservice.lib.aws.{ContentDisposition, S3, ThrallMessageSender, UpdateMessage} +import com.gu.mediaservice.lib.config.InstanceForRequest +import com.gu.mediaservice.lib.events.UsageEvents import com.gu.mediaservice.lib.formatting.printDateTime import com.gu.mediaservice.lib.logging.{LogMarker, MarkerMap} import com.gu.mediaservice.lib.metadata.SoftDeletedMetadataTable import com.gu.mediaservice.lib.play.RequestLoggingFilter import com.gu.mediaservice.model._ import com.gu.mediaservice.syntax.MessageSubjects +import com.gu.mediaservice.{GridClient, JsonDiff} import lib._ import lib.elasticsearch._ import org.apache.http.entity.ContentType +import org.apache.pekko.stream.scaladsl.StreamConverters import org.http4s.UriTemplate import org.joda.time.{DateTime, DateTimeZone} import play.api.http.HttpEntity @@ -26,9 +31,11 @@ import play.api.libs.json._ import play.api.libs.ws.WSClient import play.api.mvc.Security.AuthenticatedRequest import play.api.mvc._ +import software.amazon.awssdk.services.s3vectors.model.{QueryOutputVector, QueryVectorsResponse} import java.net.URI import scala.concurrent.{ExecutionContext, Future} +import scala.jdk.CollectionConverters._ import scala.util.Try class MediaApi( @@ -39,14 +46,15 @@ class MediaApi( imageResponse: ImageResponse, config: MediaApiConfig, override val controllerComponents: ControllerComponents, - s3Client: S3Client, + s3: S3, mediaApiMetrics: MediaApiMetrics, ws: WSClient, - authorisation: Authorisation -)(implicit val ec: ExecutionContext) extends BaseController with MessageSubjects with ArgoHelpers with ContentDisposition { + authorisation: Authorisation, + embedder: Embedder, + events: UsageEvents, +)(implicit val ec: ExecutionContext) extends BaseController with MessageSubjects with ArgoHelpers with ContentDisposition with InstanceForRequest { - val services: Services = new Services(config.domainRoot, config.serviceHosts, Set.empty) - val gridClient: GridClient = GridClient(services)(ws) + private val gridClient: GridClient = GridClient(config.services)(ws) private val searchParamList = List( "q", @@ -75,15 +83,19 @@ class MediaApi( "hasRightsAcquired", "syndicationStatus", "countAll", - "persisted" + "persisted", + "useAISearch" ).mkString(",") - private val searchLinkHref = s"${config.rootUri}/images{?$searchParamList}" - - private val searchLink = Link("search", searchLinkHref) + private def searchLinkHref()(implicit instance: Instance) = { + s"${config.rootUri(instance)}/images{?$searchParamList}" + } + private def searchLink()(implicit instance: Instance) = { + Link("search", searchLinkHref()) + } - private def indexResponse(user: Principal) = { + private def indexResponse(user: Principal)(implicit instance: Instance) = { val indexData = Json.obj( "description" -> "This is the Media API" // ^ Flatten None away @@ -92,25 +104,25 @@ class MediaApi( val userCanUpload: Boolean = authorisation.hasPermissionTo(UploadImages)(user) val userCanArchive: Boolean = authorisation.hasPermissionTo(ArchiveImages)(user) - val maybeLoaderLink: Option[Link] = Some(Link("loader", config.loaderUri)).filter(_ => userCanUpload) - val maybeArchiveLink: Option[Link] = Some(Link("archive", s"${config.metadataUri}/metadata/{id}/archived")).filter(_ => userCanArchive) + val maybeLoaderLink: Option[Link] = Some(Link("loader", config.loaderUri(instance))).filter(_ => userCanUpload) + val maybeArchiveLink: Option[Link] = Some(Link("archive", s"${config.metadataUri(instance)}/metadata/{id}/archived")).filter(_ => userCanArchive) val indexLinks = List( - searchLink, - Link("image", s"${config.rootUri}/images/{id}"), + searchLink(), + Link("image", s"${config.rootUri(instance)}/images/{id}"), // FIXME: credit is the only field available for now as it's the only on // that we are indexing as a completion suggestion - Link("metadata-search", s"${config.rootUri}/suggest/metadata/{field}{?q}"), - Link("label-search", s"${config.rootUri}/images/edits/label{?q}"), - Link("cropper", config.cropperUri), - Link("edits", config.metadataUri), - Link("session", s"${config.authUri}/session"), + Link("metadata-search", s"${config.rootUri(instance)}/suggest/metadata/{field}{?q}"), + Link("label-search", s"${config.rootUri(instance)}/images/edits/label{?q}"), + Link("cropper", config.cropperUri(instance)), + Link("edits", config.metadataUri(instance)), + Link("session", s"${config.authInstanceUri(instance)}/session"), Link("witness-report", s"${config.services.guardianWitnessBaseUri}/2/report/{id}"), - Link("collections", config.collectionsUri), - Link("permissions", s"${config.rootUri}/permissions"), - Link("leases", config.leasesUri), - Link("syndicate-image", s"${config.rootUri}/images/{id}/{partnerName}/{startPending}/syndicateImage"), - Link("undelete", s"${config.rootUri}/images/{id}/undelete"), - Link("usage", config.usageUri), + Link("collections", config.collectionsUri(instance)), + Link("permissions", s"${config.rootUri(instance)}/permissions"), + Link("leases", config.leasesUri(instance)), + Link("syndicate-image", s"${config.rootUri(instance)}/images/{id}/{partnerName}/{startPending}/syndicateImage"), + Link("undelete", s"${config.rootUri(instance)}/images/{id}/undelete"), + Link("usage", config.usageUri(instance)), ) ++ maybeLoaderLink.toList ++ maybeArchiveLink.toList respond(indexData, indexLinks) } @@ -121,7 +133,10 @@ class MediaApi( private def ImageNotFound(id: String) = respondError(NotFound, "image-not-found", s"No image found with the given id $id") private def ExportNotFound = respondError(NotFound, "export-not-found", "No export found with the given id") - def index = auth { request => indexResponse(request.user) } + def index = auth { request => + implicit val instance: Instance = instanceOf(request) + indexResponse(request.user) + } def getIncludedFromParams(request: AuthenticatedRequest[AnyContent, Principal]): List[String] = { val includedQuery: Option[String] = request.getQueryString("include") @@ -171,6 +186,7 @@ class MediaApi( } def uploadedBy(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "uploaded-by", "requestId" -> RequestLoggingFilter.getRequestId(request), @@ -185,6 +201,7 @@ class MediaApi( } def diffProjection(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "diff-projection", "requestId" -> RequestLoggingFilter.getRequestId(request), @@ -209,6 +226,7 @@ class MediaApi( } def getImageFileMetadata(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "get-image-file-metadata", "requestId" -> RequestLoggingFilter.getRequestId(request), @@ -218,7 +236,7 @@ class MediaApi( elasticSearch.getImageById(id) map { case Some(image) if hasPermission(request.user, image) => val links = List( - Link("image", s"${config.rootUri}/images/$id") + Link("image", s"${config.rootUri(instance)}/images/$id") ) respond(Json.toJson(image.fileMetadata), links) case _ => ImageNotFound(id) @@ -226,6 +244,7 @@ class MediaApi( } def getImageExports(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "get-image-exports", "requestId" -> RequestLoggingFilter.getRequestId(request), @@ -235,7 +254,7 @@ class MediaApi( elasticSearch.getImageById(id) map { case Some(image) if hasPermission(request.user, image) => val links = List( - Link("image", s"${config.rootUri}/images/$id") + Link("image", s"${config.rootUri(instance)}/images/$id") ) respond(Json.toJson(image.exports), links) case _ => ImageNotFound(id) @@ -243,6 +262,7 @@ class MediaApi( } def getImageExport(imageId: String, exportId: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "get-image-export", "requestId" -> RequestLoggingFilter.getRequestId(request), @@ -260,6 +280,7 @@ class MediaApi( } def getSoftDeletedMetadata(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "get-soft-deleted-metadata", "requestId" -> RequestLoggingFilter.getRequestId(request), @@ -275,7 +296,38 @@ class MediaApi( .recover{ case error => respondError(InternalServerError, "cannot-get", s"Cannot get soft-deleted metadata ${error}") } } - def downloadImageExport(imageId: String, exportId: String, width: Int) = auth.async { request => + def downloadImageExportMaster(imageId: String, exportId: String) = auth.async { implicit request => + implicit val instance: Instance = instanceOf(request) + implicit val logMarker: LogMarker = MarkerMap( + "requestType" -> "download-image-export-master", + "requestId" -> RequestLoggingFilter.getRequestId(request), + "imageId" -> imageId, + "exportId" -> exportId, + ) ++ RequestLoggingFilter.loggablePrincipal(request.user) + + elasticSearch.getImageById(imageId) map { + case Some(source) if hasPermission(request.user, source) => + val maybeResult = for { + export <- source.exports.find(_.id.contains(exportId)) + asset <- export.master + key = config.imgPublishingBucket.keyFromS3URL(asset.file) + s3Object <- Try(s3.getObject(config.imgPublishingBucket, key)).toOption + file = StreamConverters.fromInputStream(() => s3Object.getObjectContent) + entity = HttpEntity.Streamed(file, asset.size, asset.mimeType.map(_.name)) + result = Result(ResponseHeader(OK), entity).withHeaders("Content-Disposition" -> getContentDisposition(source, export, asset)) + } yield { + if(config.recordDownloadAsUsage) { + postToUsages(config.usageUri(instance) + "/usages/download", auth.getOnBehalfOfPrincipal(request.user), source.id, Authentication.getIdentity(request.user)) + } + result + } + maybeResult.getOrElse(ExportNotFound) + case _ => ImageNotFound(imageId) + } + } + + def downloadImageExport(imageId: String, exportId: String, width: Int) = auth.async { implicit request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "download-image-export", "requestId" -> RequestLoggingFilter.getRequestId(request), @@ -288,13 +340,14 @@ class MediaApi( val maybeResult = for { export <- source.exports.find(_.id.contains(exportId)) asset <- export.assets.find(_.dimensions.exists(_.width == width)) - s3Object <- Try(s3Client.getObject(config.imgPublishingBucket, asset.file)).toOption + key = config.imgPublishingBucket.keyFromS3URL(asset.file) + s3Object <- Try(s3.getObject(config.imgPublishingBucket, key)).toOption file = StreamConverters.fromInputStream(() => s3Object.getObjectContent) entity = HttpEntity.Streamed(file, asset.size, asset.mimeType.map(_.name)) - result = Result(ResponseHeader(OK), entity).withHeaders("Content-Disposition" -> getContentDisposition(source, export, asset, config.shortenDownloadFilename)) + result = Result(ResponseHeader(OK), entity).withHeaders("Content-Disposition" -> getContentDisposition(source, export, asset)) } yield { if(config.recordDownloadAsUsage) { - postToUsages(config.usageUri + "/usages/download", auth.getOnBehalfOfPrincipal(request.user), source.id, Authentication.getIdentity(request.user)) + postToUsages(config.usageUri(instance) + "/usages/download", auth.getOnBehalfOfPrincipal(request.user), source.id, Authentication.getIdentity(request.user)) } result } @@ -304,6 +357,7 @@ class MediaApi( } def hardDeleteImage(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "hard-delete-image", "requestId" -> RequestLoggingFilter.getRequestId(request), @@ -318,7 +372,7 @@ class MediaApi( val canDelete = authorisation.isUploaderOrHasPermission(request.user, image.uploadedBy, DeleteImagePermission) if (canDelete) { - val updateMessage = UpdateMessage(subject = DeleteImage, id = Some(id)) + val updateMessage = UpdateMessage(subject = DeleteImage, id = Some(id), instance = instanceOf(request)) messageSender.publish(updateMessage) Accepted } else { @@ -333,6 +387,7 @@ class MediaApi( } def deleteImage(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "delete-image", "requestId" -> RequestLoggingFilter.getRequestId(request), @@ -345,7 +400,7 @@ class MediaApi( if (imageCanBeDeleted){ val canDelete = authorisation.isUploaderOrHasPermission(request.user, image.uploadedBy, DeleteImagePermission) if(canDelete){ - val imageStatusRecord = ImageStatusRecord(id, request.user.accessor.identity, DateTime.now(DateTimeZone.UTC).toString, true) + val imageStatusRecord = ImageStatusRecord(id, request.user.accessor.identity, DateTime.now(DateTimeZone.UTC).toString, isDeleted = true, instance.id) softDeletedMetadataTable.setStatus(imageStatusRecord) .map { _ => messageSender.publish( @@ -355,7 +410,8 @@ class MediaApi( softDeletedMetadata = Some(SoftDeletedMetadata( deleteTime = DateTime.now(DateTimeZone.UTC), deletedBy = request.user.accessor.identity - )) + )), + instance = instance ) ) } @@ -370,7 +426,8 @@ class MediaApi( } } - def unSoftDeleteImage(id: String) = auth.async { request => + def unSoftDeleteImage(id: String) = auth.async { implicit request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "soft-undelete-image", "requestId" -> RequestLoggingFilter.getRequestId(request), @@ -386,7 +443,8 @@ class MediaApi( messageSender.publish( UpdateMessage( subject = UnSoftDeleteImage, - id = Some(id) + id = Some(id), + instance = instance ) ) } @@ -398,7 +456,8 @@ class MediaApi( } } - def downloadOriginalImage(id: String) = auth.async { request => + def downloadOriginalImage(id: String) = auth.async { implicit request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "download-original-image", "requestId" -> RequestLoggingFilter.getRequestId(request), @@ -410,23 +469,34 @@ class MediaApi( val apiKey = request.user.accessor logger.info(logMarker, s"Download original image: $id from user: ${Authentication.getIdentity(request.user)}") mediaApiMetrics.incrementImageDownload(apiKey, mediaApiMetrics.OriginalDownloadType) - val s3Object = s3Client.getObject(config.imageBucket, image.source.file) + val key = config.imageBucket.keyFromS3URL(image.source.file) + val s3Object = s3.getObject(config.imageBucket, key) val file = StreamConverters.fromInputStream(() => s3Object.getObjectContent) val entity = HttpEntity.Streamed(file, image.source.size, image.source.mimeType.map(_.name)) if(config.recordDownloadAsUsage) { - postToUsages(config.usageUri + "/usages/download", auth.getOnBehalfOfPrincipal(request.user), id, Authentication.getIdentity(request.user)) + postToUsages(config.usageUri(instance) + "/usages/download", auth.getOnBehalfOfPrincipal(request.user), id, Authentication.getIdentity(request.user)) } - Future.successful( + Future.successful { + val user = request.user match { + case u: UserPrincipal => u.attributes.get(ApiKeyAuthenticationProvider.KindeIdKey) + case _ => None + } + val apiKey = request.user match { + case m: MachinePrincipal => Some(m.accessor.identity) + case _ => None + } + events.downloadOriginal(instance, id, image.source.size, apiKey = apiKey, user = user) Result(ResponseHeader(OK), entity).withHeaders("Content-Disposition" -> getContentDisposition(image, Source, config.shortenDownloadFilename)) - ) + } } case _ => Future.successful(ImageNotFound(id)) } } - def syndicateImage(id: String, partnerName: String, startPending: String) = auth.async { request => + def syndicateImage(id: String, partnerName: String, startPending: String) = auth.async { implicit request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "syndicate-image", "requestId" -> RequestLoggingFilter.getRequestId(request), @@ -439,7 +509,7 @@ class MediaApi( case Some(image) if hasPermission(request.user, image) => { logger.info(logMarker, s"Syndicate image: $id from user: ${Authentication.getIdentity(request.user)}") - postToUsages(config.usageUri + "/usages/syndication", auth.getOnBehalfOfPrincipal(request.user), id, + postToUsages(config.usageUri(instance) + "/usages/syndication", auth.getOnBehalfOfPrincipal(request.user), id, Authentication.getIdentity(request.user), Option(partnerName), Option(startPending)) Future.successful(Ok) @@ -449,7 +519,8 @@ class MediaApi( } } - def downloadOptimisedImage(id: String, width: Integer, height: Integer, quality: Integer) = auth.async { request => + def downloadOptimisedImage(id: String, width: Integer, height: Integer, quality: Integer) = auth.async { implicit request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "download-optimised-image", "requestId" -> RequestLoggingFilter.getRequestId(request), @@ -462,18 +533,19 @@ class MediaApi( logger.info(logMarker, s"Download optimised image: $id from user: ${Authentication.getIdentity(request.user)}") mediaApiMetrics.incrementImageDownload(apiKey, mediaApiMetrics.OptimisedDownloadType) + val key = config.imageBucket.keyFromS3URL(image.optimisedPng.getOrElse(image.source).file) val sourceImageUri = - new URI(s3Client.signUrl(config.imageBucket, image.optimisedPng.getOrElse(image.source).file, image, imageType = image.optimisedPng match { + new URI(s3.signUrl(config.imageBucket, key, image, imageType = image.optimisedPng match { case Some(_) => OptimisedPng case _ => Source })) if(config.recordDownloadAsUsage) { - postToUsages(config.usageUri + "/usages/download", auth.getOnBehalfOfPrincipal(request.user), id, Authentication.getIdentity(request.user)) + postToUsages(config.usageUri(instance) + "/usages/download", auth.getOnBehalfOfPrincipal(request.user), id, Authentication.getIdentity(request.user)) } Future.successful( - Redirect(config.imgopsUri + List(sourceImageUri.getPath, sourceImageUri.getRawQuery).mkString("?") + s"&w=$width&h=$height&q=$quality") + Redirect(config.imgopsUri(instance) + List(sourceImageUri.getPath, sourceImageUri.getRawQuery).mkString("?") + s"&w=$width&h=$height&q=$quality") ) } case _ => Future.successful(ImageNotFound(id)) @@ -487,11 +559,11 @@ class MediaApi( user: String, partnerName: Option[String] = None, startPending: Option[String] = None, - )(implicit logMarker: LogMarker) = { + )(implicit logMarker: LogMarker, instance: Instance) = { val baseRequest = ws.url(uri) .withHttpHeaders(Authentication.originalServiceHeaderName -> config.appName, - HttpHeaders.ORIGIN -> config.rootUri, + HttpHeaders.ORIGIN -> config.rootUri(instance), HttpHeaders.CONTENT_TYPE -> ContentType.APPLICATION_JSON.getMimeType) val request = onBehalfOfPrincipal(baseRequest) @@ -512,19 +584,21 @@ class MediaApi( request.post(Json.toJson(Map("data" -> usagesMetadata))) //fire and forget } + def imageSearch() = auth.async { implicit request => + implicit val instance: Instance = instanceOf(request) val shouldFlagGraphicImages = request.cookies.get("SHOULD_BLUR_GRAPHIC_IMAGES") .map(_.value).getOrElse(config.defaultShouldBlurGraphicImages.toString) == "true" implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "image-search", "shouldFlagGraphicImages" -> shouldFlagGraphicImages, - "requestId" -> RequestLoggingFilter.getRequestId(request) + "requestId" -> RequestLoggingFilter.getRequestId(request), ) ++ RequestLoggingFilter.loggablePrincipal(request.user) val include = getIncludedFromParams(request) - def hitToImageEntity(elasticId: String, image: SourceWrapper[Image]): EmbeddedEntity[JsValue] = { + def hitToImageEntity(elasticId: String, image: SourceWrapper[Image])(implicit instance: Instance): EmbeddedEntity[JsValue] = { val writePermission = authorisation.isUploaderOrHasPermission(request.user, image.instance.uploadedBy, EditMetadata) val deletePermission = authorisation.isUploaderOrHasPermission(request.user, image.instance.uploadedBy, DeleteImagePermission) val deleteCropsOrUsagePermission = canUserDeleteCropsOrUsages(request.user) @@ -532,12 +606,12 @@ class MediaApi( val (imageData, imageLinks, imageActions) = imageResponse.create(elasticId, image, writePermission, deletePermission, deleteCropsOrUsagePermission, include, request.user.accessor.tier) val id = (imageData \ "id").as[String] - val imageUri = URI.create(s"${config.rootUri}/images/$id") + val imageUri = URI.create(s"${config.rootUri(instance)}/images/$id") EmbeddedEntity(uri = imageUri, data = Some(imageData), imageLinks, imageActions) } - def respondSuccess(searchParams: SearchParams) = for { - SearchResults(hits, totalCount, maybeOrgOwnedCount) <- elasticSearch.search( + def performSearchAndRespond(searchParams: SearchParams)(implicit instance: Instance) = for { + SearchResults(hits, totalCount, extraCounts) <- elasticSearch.search( searchParams.copy( shouldFlagGraphicImages = shouldFlagGraphicImages, ) @@ -546,25 +620,69 @@ class MediaApi( prevLink = getPrevLink(searchParams) nextLink = getNextLink(searchParams, totalCount) links = List(prevLink, nextLink).flatten - } yield respondCollection(imageEntities, Some(searchParams.offset), Some(totalCount), maybeOrgOwnedCount, links) + } yield respondCollection(imageEntities, Some(searchParams.offset), Some(totalCount), extraCounts, links) val _searchParams = SearchParams(request) val hasDeletePermission = authorisation.isUploaderOrHasPermission(request.user, "", DeleteImagePermission) val canViewDeletedImages = _searchParams.query.contains("is:deleted") && !hasDeletePermission - val searchParams = if(canViewDeletedImages) _searchParams.copy(uploadedBy = Some(Authentication.getIdentity(request.user))) else _searchParams - - SearchParams.validate(searchParams).fold( - // TODO: respondErrorCollection? - errors => Future.successful(respondError(UnprocessableEntity, InvalidUriParams.errorKey, - errors.map(_.message).mkString(", ")) - ), - params => respondSuccess(params) - ) + + if (_searchParams.useAISearch.contains(true)) { + + val imageIds: Future[List[String]] = _searchParams.query match { + case Some(q) if !q.isBlank => + embedder.createEmbeddingAndSearch(q).map { result => + val results: java.util.List[QueryOutputVector] = result.vectors() + results.asScala.map(_.key()).toList + } + // Empty queries do not make sense for AI search as we can + // only rank results once we have a meaningful vector to compare with. + // So return 0 results if the query was empty. + case _ => Future(Nil) + } + + imageIds.flatMap { ids => + SearchParams( + query = _searchParams.query, + ids = Some(ids), + tier = request.user.accessor.tier, + ) + + for { + SearchResults(hits, totalCount, _) <- elasticSearch.lookupIds(ids, offset = _searchParams.offset, length = _searchParams.length) + imageEntities = hits map (hitToImageEntity _).tupled + links = List() + } yield { + respondCollection(imageEntities, Some(_searchParams.offset), Some(totalCount), None, links) + } + } + + } else { + + val searchParams = if(canViewDeletedImages) { + _searchParams.copy(uploadedBy = Some(Authentication.getIdentity(request.user))) + } + else { + _searchParams + } + + SearchParams.validate(searchParams).fold( + // TODO: respondErrorCollection? + errors => Future.successful(respondError(UnprocessableEntity, InvalidUriParams.errorKey, + errors.map(_.message).mkString(", ")) + ), + params => performSearchAndRespond(params) + ) + } + + } private def getImageResponseFromES( id: String, request: Authentication.Request[AnyContent] )(implicit logMarker: LogMarker): Future[Option[(Image, JsValue, List[Link], List[Action])]] = { + implicit val instance: Instance = instanceOf(request) + implicit val r: Authentication.Request[AnyContent] = request + val include = getIncludedFromParams(request) elasticSearch.getImageWithSourceById(id) map { diff --git a/media-api/app/controllers/SuggestionController.scala b/media-api/app/controllers/SuggestionController.scala index db5ed1d8a6c..c052fd29f85 100644 --- a/media-api/app/controllers/SuggestionController.scala +++ b/media-api/app/controllers/SuggestionController.scala @@ -3,17 +3,18 @@ package controllers import com.gu.mediaservice.lib.ImageFields import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.auth.Authentication -import com.gu.mediaservice.lib.auth.Authentication.Request +import com.gu.mediaservice.lib.config.InstanceForRequest import com.gu.mediaservice.lib.logging.{LogMarker, MarkerMap} import com.gu.mediaservice.lib.play.RequestLoggingFilter +import com.gu.mediaservice.model.Instance import lib.elasticsearch.{AggregateSearchParams, CompletionSuggestionResults, ElasticSearch} -import play.api.mvc.{AnyContent, BaseController, ControllerComponents} +import play.api.mvc.{BaseController, ControllerComponents} import scala.concurrent.{ExecutionContext, Future} class SuggestionController(auth: Authentication, elasticSearch: ElasticSearch, override val controllerComponents: ControllerComponents)(implicit val ec: ExecutionContext) - extends BaseController with ArgoHelpers with ImageFields with AggregateResponses { + extends BaseController with ArgoHelpers with ImageFields with AggregateResponses with InstanceForRequest { def suggestMetadataCredit(q: Option[String], size: Option[Int]) = suggestion("suggestMetadataCredit", q, size) @@ -22,7 +23,8 @@ class SuggestionController(auth: Authentication, elasticSearch: ElasticSearch, // TODO: work with analysed fields // TODO: recover with HTTP error if invalid field // TODO: Add validation, especially if you use length - def metadataSearch(field: String, q: Option[String]) = auth.async { request => + def metadataSearch(field: String, q: Option[String]) = auth.async { implicit request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "metadata-search", "requestId" -> RequestLoggingFilter.getRequestId(request), @@ -33,6 +35,7 @@ class SuggestionController(auth: Authentication, elasticSearch: ElasticSearch, } def editsSearch(field: String, q: Option[String]) = auth.async { request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "edits-search", "requestId" -> RequestLoggingFilter.getRequestId(request), @@ -43,6 +46,8 @@ class SuggestionController(auth: Authentication, elasticSearch: ElasticSearch, } private def suggestion(field: String, query: Option[String], size: Option[Int]) = auth.async { implicit request => + implicit val instance: Instance = instanceOf(request) + query.flatMap(q => if (q.nonEmpty) Some(q) else None).map { q => elasticSearch.completionSuggestion(field, q, size.getOrElse(10)) }.getOrElse( diff --git a/media-api/app/controllers/UsageController.scala b/media-api/app/controllers/UsageController.scala index 4db59c979be..99f723f08ac 100644 --- a/media-api/app/controllers/UsageController.scala +++ b/media-api/app/controllers/UsageController.scala @@ -2,13 +2,12 @@ package controllers import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.auth.Authentication -import com.gu.mediaservice.lib.auth.Authentication.Principal +import com.gu.mediaservice.lib.config.InstanceForRequest import com.gu.mediaservice.lib.logging.{LogMarker, MarkerMap} import com.gu.mediaservice.lib.play.RequestLoggingFilter -import com.gu.mediaservice.model.Agencies +import com.gu.mediaservice.model.{Agencies, Instance} import lib._ import lib.elasticsearch.ElasticSearch -import play.api.mvc.Security.AuthenticatedRequest import play.api.mvc._ import scala.concurrent.{ExecutionContext, Future} @@ -16,11 +15,12 @@ import scala.concurrent.{ExecutionContext, Future} class UsageController(auth: Authentication, config: MediaApiConfig, elasticSearch: ElasticSearch, usageQuota: UsageQuota, override val controllerComponents: ControllerComponents)(implicit val ec: ExecutionContext) - extends BaseController with ArgoHelpers { + extends BaseController with ArgoHelpers with InstanceForRequest { val numberOfDayInPeriod = 30 def bySupplier = auth.async { implicit request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "usage-by-supplier", "requestId" -> RequestLoggingFilter.getRequestId(request) @@ -36,6 +36,7 @@ class UsageController(auth: Authentication, config: MediaApiConfig, elasticSearc } def forSupplier(id: String) = auth.async { implicit request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "usage-for-supplier", "requestId" -> RequestLoggingFilter.getRequestId(request), @@ -50,7 +51,7 @@ class UsageController(auth: Authentication, config: MediaApiConfig, elasticSearc } - def usageStatusForImage(id: String)(implicit logMarker: LogMarker): Future[UsageStatus] = for { + def usageStatusForImage(id: String)(implicit logMarker: LogMarker, instance: Instance): Future[UsageStatus] = for { imageOption <- elasticSearch.getImageById(id) image <- Future { imageOption.get } @@ -62,6 +63,7 @@ class UsageController(auth: Authentication, config: MediaApiConfig, elasticSearc def quotaForImage(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) implicit val logMarker: LogMarker = MarkerMap( "requestType" -> "quota-for-image", "requestId" -> RequestLoggingFilter.getRequestId(request), diff --git a/media-api/app/lib/ImagePersistenceReasons.scala b/media-api/app/lib/ImagePersistenceReasons.scala index 61135654140..c84bdf4554a 100644 --- a/media-api/app/lib/ImagePersistenceReasons.scala +++ b/media-api/app/lib/ImagePersistenceReasons.scala @@ -5,10 +5,9 @@ import com.gu.mediaservice.model.{CommissionedAgency, Illustrator, Image, ImageM import com.sksamuel.elastic4s.requests.searches.queries.Query -case class ImagePersistenceReasons(maybePersistOnlyTheseCollections: Option[Set[String]], persistenceIdentifier: String) { +case class ImagePersistenceReasons(maybePersistOnlyTheseCollections: Option[Set[String]]) { val allReasons: List[PersistenceReason] = List( - HasPersistenceIdentifier(persistenceIdentifier), HasExports, HasUsages, IsArchived, @@ -31,14 +30,6 @@ sealed trait PersistenceReason { val reason: String } -case class HasPersistenceIdentifier(persistenceIdentifier: String) extends PersistenceReason { - override def shouldPersist(image: Image): Boolean = image.identifiers.contains(persistenceIdentifier) - - override val reason: String = "persistence-identifier" - - override val query: Query = PersistedQueries.existedPreGrid(persistenceIdentifier) -} - object HasExports extends PersistenceReason { override def shouldPersist(image: Image): Boolean = image.hasExports diff --git a/media-api/app/lib/ImageResponse.scala b/media-api/app/lib/ImageResponse.scala index eb61e4b4518..ecfa7002fd6 100644 --- a/media-api/app/lib/ImageResponse.scala +++ b/media-api/app/lib/ImageResponse.scala @@ -10,6 +10,7 @@ import com.gu.mediaservice.model.usage._ import lib.ImageResponse.extractAliasFieldValues import lib.elasticsearch.SourceWrapper import lib.usagerights.CostCalculator +import org.apache.commons.codec.binary.Base64 import org.joda.time.DateTime import play.api.libs.functional.syntax._ import play.api.libs.json._ @@ -35,7 +36,7 @@ class ImageResponse(config: MediaApiConfig, s3Client: S3Client, usageQuota: Usag implicit val costing: CostCalculator = Costing - val metadataBaseUri: String = config.services.metadataBaseUri + override val metadataBaseUri: Instance => String = config.services.metadataBaseUri type FileMetadataEntity = EmbeddedEntity[FileMetadata] @@ -47,7 +48,6 @@ class ImageResponse(config: MediaApiConfig, s3Client: S3Client, usageQuota: Usag private val imgPersistenceReasons = ImagePersistenceReasons( config.maybePersistOnlyTheseCollections, - config.persistenceIdentifier ) def imagePersistenceReasons(image: Image): List[String] = imgPersistenceReasons.reasons(image) @@ -61,7 +61,7 @@ class ImageResponse(config: MediaApiConfig, s3Client: S3Client, usageQuota: Usag withDeleteImagePermission: Boolean, withDeleteCropsOrUsagePermission: Boolean, included: List[String] = List(), tier: Tier - )(implicit logMarker: LogMarker): (JsValue, List[Link], List[Action]) = { + )(implicit logMarker: LogMarker, instance: Instance): (JsValue, List[Link], List[Action]) = { val image = imageWrapper.instance @@ -77,11 +77,12 @@ class ImageResponse(config: MediaApiConfig, s3Client: S3Client, usageQuota: Usag val fileUri = image.source.file - val imageUrl = s3Client.signUrl(config.imageBucket, fileUri, image, imageType = Source) + val key = config.imageBucket.keyFromS3URL(fileUri) + val imageUrl = s3Client.signUrl(config.imageBucket, key, image, imageType = Source) val pngUrl: Option[String] = pngFileUri - .map(s3Client.signUrl(config.imageBucket, _, image, imageType = OptimisedPng)) + .map(uri => s3Client.signUrl(config.imageBucket, config.imageBucket.keyFromS3URL(uri), image, imageType = OptimisedPng)) - def s3SignedThumbUrl = s3Client.signUrl(config.thumbBucket, fileUri, image, imageType = Thumbnail) + def s3SignedThumbUrl = s3Client.signUrl(config.thumbnailBucket, key, image, imageType = Thumbnail) val thumbUrl = config.cloudFrontDomainThumbBucket .flatMap(s3Client.signedCloudFrontUrl(_, fileUri.getPath.drop(1))) @@ -99,8 +100,7 @@ class ImageResponse(config: MediaApiConfig, s3Client: S3Client, usageQuota: Usag val aliases = extractAliasFieldValues(config, imageWrapper) - val data = source.transform(addSecureSourceUrl(imageUrl)) - .flatMap(_.transform(wrapUserMetadata(id))) + val data = source.transform(wrapUserMetadata(id)) .flatMap(_.transform(addSecureThumbUrl(thumbUrl))) .flatMap(_.transform( pngUrl @@ -130,30 +130,30 @@ class ImageResponse(config: MediaApiConfig, s3Client: S3Client, usageQuota: Usag (data, links, actions) } - private def downloadLink(id: String) = Link("download", s"${config.rootUri}/images/$id/download") - private def downloadOptimisedLink(id: String) = Link("downloadOptimised", s"${config.rootUri}/images/$id/downloadOptimised?{&width,height,quality}") + private def downloadLink(id: String)(implicit instance: Instance) = Link("download", s"${config.rootUri(instance)}/images/$id/download") + private def downloadOptimisedLink(id: String)(implicit instance: Instance) = Link("downloadOptimised", s"${config.rootUri(instance)}/images/$id/downloadOptimised?{&width,height,quality}") - private def getDownloadLinks(id: String, isDownloadable: Boolean): List[Link] = { + private def getDownloadLinks(id: String, isDownloadable: Boolean)(implicit instance: Instance): List[Link] = { (config.restrictDownload, isDownloadable) match { case (true, false) => Nil case (_, _) => List(downloadLink(id), downloadOptimisedLink(id)) } } - def imageLinks(id: String, secureUrl: String, securePngUrl: Option[String], withWritePermission: Boolean, valid: Boolean, orientationMetadata: Option[OrientationMetadata]): List[Link] = { + def imageLinks(id: String, secureUrl: String, securePngUrl: Option[String], withWritePermission: Boolean, valid: Boolean, orientationMetadata: Option[OrientationMetadata])(implicit instance: Instance): List[Link] = { import BoolImplicitMagic.BoolToOption - val cropLinkMaybe = valid.toOption(Link("crops", s"${config.cropperUri}/crops/$id")) - val editLinkMaybe = withWritePermission.toOption(Link("edits", s"${config.metadataUri}/metadata/$id")) - val optimisedPngLinkMaybe = securePngUrl map { case secureUrl => Link("optimisedPng", makeImgopsUri(new URI(secureUrl), orientationMetadata)) } - - val optimisedLink = Link("optimised", makeImgopsUri(new URI(secureUrl), orientationMetadata)) - val imageLink = Link("ui:image", s"${config.kahunaUri}/images/$id") - val usageLink = Link("usages", s"${config.usageUri}/usages/media/$id") - val leasesLink = Link("leases", s"${config.leasesUri}/leases/media/$id") - val fileMetadataLink = Link("fileMetadata", s"${config.rootUri}/images/$id/fileMetadata") - val projectionLink = Link("loader", s"${config.loaderUri}/images/project/$id") - val projectionDiffLink = Link("api", s"${config.rootUri}/images/$id/projection/diff") + val cropLinkMaybe = valid.toOption(Link("crops", s"${config.cropperUri(instance)}/crops/$id")) + val editLinkMaybe = withWritePermission.toOption(Link("edits", s"${config.metadataUri(instance)}/metadata/$id")) + val optimisedPngLinkMaybe = securePngUrl map { case secureUrl => Link("optimisedPng", makeImgProxyUri(new URI(secureUrl), orientationMetadata)) } + + val optimisedLink = Link("optimised", makeImgProxyUri(new URI(secureUrl), orientationMetadata)) + val imageLink = Link("ui:image", s"${config.kahunaUri(instance)}/images/$id") + val usageLink = Link("usages", s"${config.usageUri(instance)}/usages/media/$id") + val leasesLink = Link("leases", s"${config.leasesUri(instance)}/leases/media/$id") + val fileMetadataLink = Link("fileMetadata", s"${config.rootUri(instance)}/images/$id/fileMetadata") + val projectionLink = Link("loader", s"${config.loaderUri(instance)}/images/project/$id") + val projectionDiffLink = Link("api", s"${config.rootUri(instance)}/images/$id/projection/diff") editLinkMaybe.toList ++ cropLinkMaybe.toList ++ optimisedPngLinkMaybe.toList ++ List( @@ -161,16 +161,16 @@ class ImageResponse(config: MediaApiConfig, s3Client: S3Client, usageQuota: Usag projectionLink, projectionDiffLink) } - def imageActions(id: String, isDeletable: Boolean, withWritePermission: Boolean, withDeleteCropsOrUsagePermission: Boolean): List[Action] = { + def imageActions(id: String, isDeletable: Boolean, withWritePermission: Boolean, withDeleteCropsOrUsagePermission: Boolean)(implicit instance: Instance): List[Action] = { - val imageUri = URI.create(s"${config.rootUri}/images/$id") - val reindexUri = URI.create(s"${config.rootUri}/images/$id/reindex") - val addCollectionUri = URI.create(s"${config.collectionsUri}/images/$id") - val addLeaseUri = URI.create(s"${config.leasesUri}/leases") - val addLeasesUri = URI.create(s"${config.leasesUri}/leases/media/$id") - val replaceLeasesUri = URI.create(s"${config.leasesUri}/leases/media/$id") - val deleteLeasesUri = URI.create(s"${config.leasesUri}/leases/media/$id") - val deleteUsagesUri = URI.create(s"${config.usageUri}/usages/media/$id") + val imageUri = URI.create(s"${config.rootUri(instance)}/images/$id") + val reindexUri = URI.create(s"${config.rootUri(instance)}/images/$id/reindex") + val addCollectionUri = URI.create(s"${config.collectionsUri(instance)}/images/$id") + val addLeaseUri = URI.create(s"${config.leasesUri(instance)}/leases") + val addLeasesUri = URI.create(s"${config.leasesUri(instance)}/leases/media/$id") + val replaceLeasesUri = URI.create(s"${config.leasesUri(instance)}/leases/media/$id") + val deleteLeasesUri = URI.create(s"${config.leasesUri(instance)}/leases/media/$id") + val deleteUsagesUri = URI.create(s"${config.usageUri(instance)}/usages/media/$id") val deleteAction = Action("delete", imageUri, "DELETE") val reindexAction = Action("reindex", reindexUri, "POST") @@ -223,10 +223,10 @@ class ImageResponse(config: MediaApiConfig, s3Client: S3Client, usageQuota: Usag "value" -> isPersisted, "reasons" -> persistenceReasons))) - def wrapUserMetadata(id: String): Reads[JsObject] = + def wrapUserMetadata(id: String)(implicit instance: Instance): Reads[JsObject] = __.read[JsObject].map { root => val edits = (root \ "userMetadata").asOpt[Edits].getOrElse(Edits.getEmpty) - val editsJson = Json.toJson(editsEmbeddedEntity(id, edits)) + val editsJson = Json.toJson(editsEmbeddedEntity(id, edits)(instance)) root ++ Json.obj("userMetadata" -> editsJson) } @@ -254,18 +254,24 @@ class ImageResponse(config: MediaApiConfig, s3Client: S3Client, usageQuota: Usag "aliases" -> JsObject(aliases) )) - def makeImgopsUri(uri: URI, orientationMetadata: Option[OrientationMetadata]): String = { - val resizing = config.imgopsUri + List(uri.getPath, uri.getRawQuery).mkString("?") + "{&w,h,q}" - // imgops rotates counter-clockwise - val orientationCorrectionRotation = -orientationMetadata.map(_.orientationCorrection()).getOrElse(0) - // and ignores negative values - val normalised = if (orientationCorrectionRotation < 0) { - orientationCorrectionRotation + 360 - } else { - orientationCorrectionRotation + private def makeImgProxyUri(uri: URI, orientationMetadata: Option[OrientationMetadata])(implicit instance: Instance): String = { + def normaliseRotation(rotation: Int) = { + // imgproxy does not accept negative rotations + if (rotation < 0) { + rotation + 360 + } else { + rotation + } } - val orientationCorrection = s"&r=" + URLEncoder.encode(normalised.toString, "UTF-8") - resizing + orientationCorrection + val source = uri.toURL.toExternalForm + val signed = new String(Base64.encodeBase64URLSafe(source.getBytes), "UTF-8") + val resizingComponents = Seq(config.imgopsUri(instance), "no-signature", + "auto_rotate:false", "strip_metadata:true", "strip_color_profile:true", + "resize:fit:{w}:{h}", "quality:{q}") + val orientationCorrection = orientationMetadata.map(o => Seq("rotate:" + normaliseRotation(o.orientationCorrection()))).getOrElse(Seq.empty) + val withOrientationCorrection = resizingComponents ++ orientationCorrection :+ signed + val resizingUrl = withOrientationCorrection.mkString("/") + resizingUrl } private def updateCustomSpecialInstructions(source: JsValue): Reads[JsObject] = { @@ -319,7 +325,7 @@ class ImageResponse(config: MediaApiConfig, s3Client: S3Client, usageQuota: Usag import play.api.libs.json.JodaWrites._ - def imageResponseWrites(id: String, expandFileMetaData: Boolean): OWrites[Image] = ( + def imageResponseWrites(id: String, expandFileMetaData: Boolean)(implicit instance: Instance): OWrites[Image] = ( (__ \ "id").write[String] ~ (__ \ "uploadTime").write[DateTime] ~ (__ \ "uploadedBy").write[String] ~ @@ -350,26 +356,26 @@ class ImageResponse(config: MediaApiConfig, s3Client: S3Client, usageQuota: Usag ) (unlift(Image.unapply)) - def fileMetaDataUri(id: String) = URI.create(s"${config.rootUri}/images/$id/fileMetadata") + def fileMetaDataUri(id: String)(implicit instance: Instance) = URI.create(s"${config.rootUri(instance)}/images/$id/fileMetadata") - def usagesUri(id: String) = URI.create(s"${config.usageUri}/usages/media/$id") + def usagesUri(id: String)(implicit instance: Instance) = URI.create(s"${config.usageUri(instance)}/usages/media/$id") - def usageUri(id: String) = { - URI.create(s"${config.usageUri}/usages/${UriEncoding.encodePathSegment(id, "UTF-8")}") + def usageUri(id: String)(implicit instance: Instance) = { + URI.create(s"${config.usageUri(instance)}/usages/${UriEncoding.encodePathSegment(id, "UTF-8")}") } - def leasesUri(id: String) = URI.create(s"${config.leasesUri}/leases/media/$id") + def leasesUri(id: String)(implicit instance: Instance) = URI.create(s"${config.leasesUri(instance)}/leases/media/$id") - def usageEntity(usage: Usage) = EmbeddedEntity[Usage](usageUri(usage.id), Some(usage)) + def usageEntity(usage: Usage)(implicit instance: Instance) = EmbeddedEntity[Usage](usageUri(usage.id)(instance), Some(usage)) - def usagesEntity(id: String, usages: List[Usage]) = - EmbeddedEntity[List[UsageEntity]](usagesUri(id), Some(usages.map(usageEntity))) + def usagesEntity(id: String, usages: List[Usage])(implicit instance: Instance) = + EmbeddedEntity[List[UsageEntity]](usagesUri(id), Some(usages.map(u => usageEntity(u)))) - def leasesEntity(id: String, leaseByMedia: LeasesByMedia) = + def leasesEntity(id: String, leaseByMedia: LeasesByMedia)(implicit instance: Instance) = EmbeddedEntity[LeasesByMedia](leasesUri(id), Some(leaseByMedia)) - def collectionsEntity(id: String, c: Collection): EmbeddedEntity[CollectionResponse] = - collectionEntity(config.collectionsUri, id, c) + def collectionsEntity(id: String, c: Collection)(implicit instance: Instance): EmbeddedEntity[CollectionResponse] = + collectionEntity(config.collectionsUri(instance), id, c) def collectionEntity(rootUri: String, imageId: String, c: Collection) = { // TODO: Currently the GET for this URI does nothing @@ -380,7 +386,7 @@ class ImageResponse(config: MediaApiConfig, s3Client: S3Client, usageQuota: Usag )) } - def fileMetadataEntity(id: String, expandFileMetaData: Boolean, fileMetadata: FileMetadata) = { + def fileMetadataEntity(id: String, expandFileMetaData: Boolean, fileMetadata: FileMetadata)(implicit instance: Instance) = { val displayableMetadata = if (expandFileMetaData) Some(fileMetadata) else None EmbeddedEntity[FileMetadata](fileMetaDataUri(id), displayableMetadata) diff --git a/media-api/app/lib/MediaApiConfig.scala b/media-api/app/lib/MediaApiConfig.scala index fbd43afbab5..dabcef61b93 100644 --- a/media-api/app/lib/MediaApiConfig.scala +++ b/media-api/app/lib/MediaApiConfig.scala @@ -1,33 +1,44 @@ package lib -import com.amazonaws.services.cloudfront.util.SignerUtils +import com.gu.mediaservice.lib.aws.{S3, S3Bucket} import com.gu.mediaservice.lib.config.{CommonConfigWithElastic, GridConfigResources} +import com.gu.mediaservice.lib.elasticsearch.filters +import com.sksamuel.elastic4s.ElasticApi.{matchPhraseQuery, should} +import com.sksamuel.elastic4s.ElasticDsl.matchQuery +import com.sksamuel.elastic4s.requests.searches.queries.Query +import com.sksamuel.elastic4s.requests.searches.queries.matches.MatchQuery +import com.gu.mediaservice.model.Instance import org.joda.time.DateTime import scalaz.NonEmptyList -import java.security.PrivateKey +import scala.collection.immutable import scala.util.Try case class StoreConfig( - storeBucket: String, - storeKey: String + storeBucket: S3Bucket, + storeKey: String, ) class MediaApiConfig(resources: GridConfigResources) extends CommonConfigWithElastic(resources) { - val configBucket: String = string("s3.config.bucket") - val usageMailBucket: String = string("s3.usagemail.bucket") + private val configBucketEndpoint = S3.AmazonAwsS3Endpoint + val configBucket: S3Bucket = S3Bucket(string("s3.config.bucket"), configBucketEndpoint, usesPathStyleURLs = false, clientFor(configBucketEndpoint)) + private val usageMailBucketEndpoint = S3.AmazonAwsS3Endpoint + val usageMailBucket: S3Bucket = S3Bucket(string("s3.usagemail.bucket"), S3.AmazonAwsS3Endpoint, usesPathStyleURLs = false, clientFor(usageMailBucketEndpoint)) val quotaStoreKey: String = string("quota.store.key") val quotaStoreConfig: StoreConfig = StoreConfig(configBucket, quotaStoreKey) - //Lazy allows this to be empty and not break things unless used somewhere - lazy val imgPublishingBucket = string("publishing.image.bucket") - - val imageBucket: String = string("s3.image.bucket") - val thumbBucket: String = string("s3.thumb.bucket") + // TODO this needs to be the same as the crops bucket? Is downloadExports broken? + // TODO can be optional at BBC? + val imgPublishingBucket: S3Bucket = S3Bucket( + string("publishing.image.bucket.name"), + string("publishing.image.bucket.endpoint"), + boolean("publishing.image.bucket.pathStyleURLs"), + clientFor(string("publishing.image.bucket.endpoint")) + ) val cloudFrontDomainThumbBucket: Option[String] = stringOpt("cloudfront.domain.thumbbucket") - val cloudFrontPrivateKeyBucket: Option[String] = stringOpt("cloudfront.private-key.bucket") + val cloudFrontPrivateKeyBucket: Option[S3Bucket] = stringOpt("cloudfront.private-key.bucket").map(S3Bucket(_, S3.AmazonAwsS3Endpoint, usesPathStyleURLs = false, clientFor(S3.AmazonAwsS3Endpoint))) val cloudFrontPrivateKeyBucketKey: Option[String] = stringOpt("cloudfront.private-key.key") val cloudFrontKeyPairId: Option[String] = stringOpt("cloudfront.keypair.id") @@ -40,16 +51,16 @@ class MediaApiConfig(resources: GridConfigResources) extends CommonConfigWithEla } val fuzzyMaxExpansions: Int = intOpt("search.fuzziness.maxExpansions").getOrElse(50) - val rootUri: String = services.apiBaseUri - val kahunaUri: String = services.kahunaBaseUri - val cropperUri: String = services.cropperBaseUri - val loaderUri: String = services.loaderBaseUri - val metadataUri: String = services.metadataBaseUri - val imgopsUri: String = services.imgopsBaseUri - val usageUri: String = services.usageBaseUri - val leasesUri: String = services.leasesBaseUri - val authUri: String = services.authBaseUri - val collectionsUri: String = services.collectionsBaseUri + val rootUri: Instance => String = services.apiBaseUri + val kahunaUri: Instance => String = services.kahunaBaseUri + val cropperUri: Instance => String = services.cropperBaseUri + val loaderUri: Instance => String = services.loaderBaseUri + val metadataUri: Instance => String = services.metadataBaseUri + val imgopsUri: Instance => String = services.imgopsBaseUri + val usageUri: Instance => String = services.usageBaseUri + val leasesUri: Instance => String = services.leasesBaseUri + val authInstanceUri: Instance => String = services.authBaseInstanceUri + val collectionsUri: Instance => String = services.collectionsBaseUri val requiredMetadata = NonEmptyList("credit", "description", "usageRights") @@ -70,4 +81,13 @@ class MediaApiConfig(resources: GridConfigResources) extends CommonConfigWithEla val restrictDownload: Boolean = boolean("restrictDownload") + val queueUrl: String = stringOpt("sqs.embedder.queue.url").getOrElse("") + + val maybeAgencyPickQuery: Option[Query] = agencyPicksIngredients.map { ingredients => + filters.or( + ingredients.flatMap { + case (field, values) => values.map(matchPhraseQuery(field, _)) + }.toList: _* + ) + } } diff --git a/media-api/app/lib/QuotaStore.scala b/media-api/app/lib/QuotaStore.scala new file mode 100644 index 00000000000..16065865543 --- /dev/null +++ b/media-api/app/lib/QuotaStore.scala @@ -0,0 +1,35 @@ +package lib + +import com.gu.mediaservice.lib.BaseStore +import com.gu.mediaservice.lib.aws.S3Bucket +import play.api.libs.json.Json + +import scala.concurrent.ExecutionContext + +class QuotaStore( + quotaFile: String, + bucket: S3Bucket, + config: MediaApiConfig, + )(implicit ec: ExecutionContext) extends BaseStore[String, SupplierUsageQuota](bucket, config)(ec) { + + def getQuota: Map[String, SupplierUsageQuota] = store.get() + + def update(): Unit = { + store.set(fetchQuota) + } + + private def fetchQuota: Map[String, SupplierUsageQuota] = { + val quotaFileString = getS3Object(quotaFile).get + logger.info("Fetched quota file: " + quotaFileString) + + val summary: Seq[SupplierUsageQuota] = Json + .parse(quotaFileString) + .as[List[SupplierUsageQuota]] + + val quotas = summary.foldLeft(Map[String, SupplierUsageQuota]())((memo, quota) => { + memo + (quota.agency.supplier -> quota) + }) + logger.info("Got quotas: " + quotas) + quotas + } +} diff --git a/media-api/app/lib/S3Client.scala b/media-api/app/lib/S3Client.scala index 1161392cfda..a286d5420a0 100644 --- a/media-api/app/lib/S3Client.scala +++ b/media-api/app/lib/S3Client.scala @@ -29,7 +29,7 @@ class S3Client(config: MediaApiConfig) extends S3(config) with CloudFrontDistrib lazy val keyPairId: Option[String] = config.cloudFrontKeyPairId lazy val privateKey: PrivateKey = { config.cloudFrontPrivateKeyBucket.flatMap(bucket => config.cloudFrontPrivateKeyBucketKey.map { key => - val privateKeyStream = client.getObject(bucket, key).getObjectContent + val privateKeyStream = getObject(bucket, key).getObjectContent try { PEM.readPrivateKey(privateKeyStream) } diff --git a/media-api/app/lib/UsageQuota.scala b/media-api/app/lib/UsageQuota.scala index 2ccad4520c5..0dc049fc773 100644 --- a/media-api/app/lib/UsageQuota.scala +++ b/media-api/app/lib/UsageQuota.scala @@ -2,6 +2,7 @@ package lib import org.apache.pekko.actor.Scheduler import com.gu.mediaservice.lib.FeatureToggle +import com.gu.mediaservice.lib.aws.S3 import com.gu.mediaservice.model.UsageRights import scala.concurrent.Await @@ -16,23 +17,21 @@ class UsageQuota(config: MediaApiConfig, scheduler: Scheduler) { val quotaStore = new QuotaStore( config.quotaStoreConfig.storeKey, config.quotaStoreConfig.storeBucket, - config + config, ) val usageStore = new UsageStore( config.usageMailBucket, config, - quotaStore + quotaStore, ) def scheduleUpdates(): Unit = { quotaStore.scheduleUpdates(scheduler) - usageStore.scheduleUpdates(scheduler) } def stopUpdates(): Unit = { quotaStore.stopUpdates() - usageStore.stopUpdates() } def isOverQuota(rights: UsageRights, waitMillis: Int = 100): Boolean = Try { diff --git a/media-api/app/lib/UsageStore.scala b/media-api/app/lib/UsageStore.scala index ea999fb2f93..c8b55defa0e 100644 --- a/media-api/app/lib/UsageStore.scala +++ b/media-api/app/lib/UsageStore.scala @@ -1,21 +1,15 @@ package lib -import java.io.InputStream -import java.util.Properties - import com.gu.mediaservice.lib.BaseStore +import com.gu.mediaservice.lib.aws.S3Bucket import com.gu.mediaservice.lib.logging.GridLogging import com.gu.mediaservice.model.{Agencies, Agency, UsageRights} -import javax.mail.Session -import javax.mail.internet.{MimeBodyPart, MimeMultipart} -import org.apache.commons.mail.util.MimeMessageUtils import org.joda.time.DateTime import play.api.libs.functional.syntax._ import play.api.libs.json._ import scala.collection.compat._ import scala.concurrent.{ExecutionContext, Future} -import scala.io.Source case class SupplierUsageQuota(agency: Agency, count: Int) object SupplierUsageQuota { @@ -61,77 +55,12 @@ object StoreAccess { } object UsageStore extends GridLogging { - def extractEmail(stream: InputStream): List[String] = { - val s = Session.getDefaultInstance(new Properties()) - val message = MimeMessageUtils.createMimeMessage(s, stream) - - message.getContent match { - case content: MimeMultipart => - val parts = for(n <- 0 until content.getCount) yield content.getBodyPart(n) - - val part = parts - .collectFirst { case part: MimeBodyPart if part.getEncoding == "base64" => part } - .map(_.getContent) - - part match { - case Some(c: InputStream) => - Source.fromInputStream(c).getLines().toList - - case _ => - logger.error("Usage email is missing base64 encoded attachment") - List.empty - } - - case other => - logger.error(s"Unexpected message content type ${other.getClass}") - List.empty - } - } - - def csvParser(list: List[String]): List[SupplierUsageSummary] = { - def stripQuotes(s: String): String = s - .stripSuffix("\"") - .stripPrefix("\"") - .replaceAll("\\P{ASCII}", "") // strip all non-ascii chars from the CSV - - val lines = list - .map(_.split(",")) - .map(_.map(stripQuotes)) - .map(_.toList) - - if(lines.exists(_.length != 2)) { - logger.error("CSV header error. Expected 2 columns") - throw new IllegalArgumentException("CSV header error. Expected 2 columns") - } - - lines.headOption match { - case Some("Cpro Name" :: "Id" :: Nil) => - lines.tail.map { - case supplier :: count :: Nil => - SupplierUsageSummary(Agency(supplier), count.toInt) - - case _ => - logger.error("CSV body error. Expected 2 columns") - throw new IllegalArgumentException("CSV body error. Expected 2 columns") - } - - case Some(other) => - val message = s"Unexpected CSV headers [${other.mkString(",")}]. Expected [Cpro Name, Id]" - logger.error(message) - throw new IllegalArgumentException(message) - - case None => - val message = "CSV has no lines" - logger.error(message) - throw new IllegalArgumentException(message) - } - } } class UsageStore( - bucket: String, + bucket: S3Bucket, config: MediaApiConfig, - quotaStore: QuotaStore + quotaStore: QuotaStore, )(implicit val ec: ExecutionContext) extends BaseStore[String, UsageStatus](bucket, config) with GridLogging { import UsageStore._ @@ -143,7 +72,30 @@ class UsageStore( } def getUsageStatus(): Future[StoreAccess] = { - Future.successful(StoreAccess(store.get(), lastUpdated.get())) + //Future.successful(StoreAccess(store.get(), lastUpdated.get())) + + val quota = quotaStore.getQuota + + val results: Map[String, UsageStatus] = quota.keys.flatMap { supplier: String => + val maybeAgency = Agencies.all.get(supplier) + val x = maybeAgency.map { agency => + val supplierUsageSummary: SupplierUsageSummary = SupplierUsageSummary( + agency = agency, count = 0 + ) + val supplierUsageQuota: SupplierUsageQuota = SupplierUsageQuota( + agency = agency, count = quota.get(supplier).map(_.count).getOrElse(0) + ) + val usageStatus = UsageStatus( + exceeded = false, + fractionOfQuota = 0.0.toFloat, + usage = supplierUsageSummary, + quota = Some(supplierUsageQuota) + ) + (supplier, usageStatus) + } + x + }.toMap + Future.successful(StoreAccess(store = results, lastUpdated = DateTime.now())) } def overQuotaAgencies: List[Agency] = store.get.collect { @@ -151,76 +103,9 @@ class UsageStore( }.toList def update(): Unit = { - store.set(fetchUsage) + // TODO reimplement } - private def fetchUsage: Map[String, UsageStatus] = { - logger.info("Updating usage store") - - val maybeLines: Option[List[String]] = getLatestS3Stream.map(extractEmail) - - maybeLines match { - case None => Map.empty - case Some(lines) => - logger.info(s"Last usage file has ${lines.length} lines") - val summary: List[SupplierUsageSummary] = csvParser(lines) - - def copyAgency(supplier: SupplierUsageSummary, id: String) = Agencies.all.get(id) - .map(a => supplier.copy(agency = a)) - .getOrElse(supplier) - - val cleanedSummary = summary - .map { - case s if s.agency.supplier.contains("Rex Features") => copyAgency(s, "rex") - case s if s.agency.supplier.contains("Getty Images") => copyAgency(s, "getty") - case s if s.agency.supplier.contains("Australian Associated Press") => copyAgency(s, "aap") - case s if s.agency.supplier.contains("Alamy") => copyAgency(s, "alamy") - case s => s - } - - val supplierQuota = quotaStore.getQuota - - cleanedSummary - .groupBy(_.agency.supplier) - .view - .mapValues(_.head) - .mapValues((summary: SupplierUsageSummary) => { - val quota = summary.agency.id.flatMap(id => supplierQuota.get(id)) - val exceeded = quota.exists(q => summary.count > q.count) - val fractionOfQuota: Float = quota.map(q => summary.count.toFloat / q.count).getOrElse(0F) - - UsageStatus( - exceeded, - fractionOfQuota, - summary, - quota - ) - }).toMap - } - } } -class QuotaStore( - quotaFile: String, - bucket: String, - config: MediaApiConfig -)(implicit ec: ExecutionContext) extends BaseStore[String, SupplierUsageQuota](bucket, config)(ec) { - - def getQuota: Map[String, SupplierUsageQuota] = store.get() - - def update(): Unit = { - store.set(fetchQuota) - } - - private def fetchQuota: Map[String, SupplierUsageQuota] = { - val quotaFileString = getS3Object(quotaFile).get - - val summary = Json - .parse(quotaFileString) - .as[List[SupplierUsageQuota]] - summary.foldLeft(Map[String,SupplierUsageQuota]())((memo, quota) => { - memo + (quota.agency.supplier -> quota) - }) - } -} diff --git a/media-api/app/lib/elasticsearch/ElasticSearch.scala b/media-api/app/lib/elasticsearch/ElasticSearch.scala index 0ea248661ba..ea584012c70 100644 --- a/media-api/app/lib/elasticsearch/ElasticSearch.scala +++ b/media-api/app/lib/elasticsearch/ElasticSearch.scala @@ -2,12 +2,13 @@ package lib.elasticsearch import org.apache.pekko.actor.Scheduler import com.gu.mediaservice.lib.ImageFields +import com.gu.mediaservice.lib.argo.model.{ExtraCount, ExtraCountConfig, ExtraCounts} import com.gu.mediaservice.lib.elasticsearch.filters -import com.gu.mediaservice.lib.auth.Authentication.Principal import com.gu.mediaservice.lib.elasticsearch.{CompletionPreview, ElasticSearchClient, ElasticSearchConfig, MigrationStatusProvider, Running} +import com.gu.mediaservice.lib.instances.InstancesClient import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker, MarkerMap} import com.gu.mediaservice.lib.metrics.FutureSyntax -import com.gu.mediaservice.model.{Agencies, Agency, AwaitingReviewForSyndication, Image} +import com.gu.mediaservice.model.{Agencies, Agency, AwaitingReviewForSyndication, Image, Instance} import com.sksamuel.elastic4s.ElasticDsl import com.sksamuel.elastic4s.ElasticDsl._ import com.sksamuel.elastic4s.requests.get.{GetRequest, GetResponse} @@ -20,31 +21,52 @@ import com.sksamuel.elastic4s.requests.searches.queries.Query import lib.querysyntax.{HierarchyField, Match, Parser, Phrase} import lib.{MediaApiConfig, MediaApiMetrics, SupplierUsageSummary} import play.api.libs.json.{JsError, JsObject, JsSuccess, Json} -import play.api.mvc.AnyContent -import play.api.mvc.Security.AuthenticatedRequest import play.mvc.Http.Status import scalaz.NonEmptyList import scalaz.syntax.std.list._ import java.util.concurrent.TimeUnit +import scala.collection.immutable.ListMap import scala.concurrent.duration.FiniteDuration import scala.concurrent.{ExecutionContext, Future} class ElasticSearch( val config: MediaApiConfig, mediaApiMetrics: MediaApiMetrics, - elasticConfig: ElasticSearchConfig, + val elasticSearchConfig: ElasticSearchConfig, overQuotaAgencies: () => List[Agency], - val scheduler: Scheduler + val scheduler: Scheduler, + val instancesClient: InstancesClient, ) extends ElasticSearchClient with ImageFields with MatchFields with FutureSyntax with GridLogging with MigrationStatusProvider { - private val orgOwnedAggName = "org-owned" + private val maybeOrgOwnedExtraCount: Option[(String, ExtraCountConfig)] = + if (config.shouldDisplayOrgOwnedCountAndFilterCheckbox) + Some(s"owned" -> ExtraCountConfig( + searchClause = s"is:owned", + backgroundColour = "#005689" + )) + else + None + + private val maybeAgencyPicksExtraCount: Option[(String, ExtraCountConfig)] = + config.maybeAgencyPickQuery.map(_ => + "agency picks" -> ExtraCountConfig( + searchClause = "is:agency-pick", + backgroundColour = config.agencyPicksColour, + maybeSubAggregation = Some( + termsAgg(name = "byAgency", field = "usageRights.supplier").size(9) + ) + ) + ) + + private val aggregationsNameToSearchClauseMap: Map[String, ExtraCountConfig] = List( + maybeOrgOwnedExtraCount, + maybeAgencyPicksExtraCount + ).flatten.toMap - lazy val imagesCurrentAlias = elasticConfig.aliases.current - lazy val imagesMigrationAlias = elasticConfig.aliases.migration - lazy val url = elasticConfig.url - lazy val shards = elasticConfig.shards - lazy val replicas = elasticConfig.replicas + lazy val url = elasticSearchConfig.url + lazy val shards = elasticSearchConfig.shards + lazy val replicas = elasticSearchConfig.replicas private val SearchQueryTimeout = FiniteDuration(10, TimeUnit.SECONDS) // there is 15 seconds timeout set on cluster level as well @@ -62,24 +84,24 @@ class ElasticSearch( val queryBuilder = new QueryBuilder(matchFields, overQuotaAgencies, config) - def getImageById(id: String)(implicit ex: ExecutionContext, logMarker: LogMarker): Future[Option[Image]] = + def getImageById(id: String)(implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[Option[Image]] = getImageWithSourceById(id).map(_.map(_.instance)) private def migrationAwareGetter[T]( id: String, logMessagePart: String, requestFromIndexName: String => GetRequest, - resultTransformer: GetResponse => Option[T] - )(implicit ex: ExecutionContext, logMarker: LogMarker): Future[Option[T]] = { + resultTransformer: GetResponse => Option[T], + )(implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[Option[T]] = { val xlogMarker = logMarker { implicit val logMarker: LogMarker = xlogMarker + ("image-id" -> id) - def getFromCurrentIndex = executeAndLog( - request = requestFromIndexName(imagesCurrentAlias), - message = s"get $logMessagePart by id $id from index with alias $imagesCurrentAlias" + def getFromCurrentIndex()(implicit instance: Instance) = executeAndLog( + request = requestFromIndexName(imagesCurrentAlias(instance)), + message = s"get $logMessagePart by id $id from index with alias ${imagesCurrentAlias(instance)}" ).map { r => r.status match { case Status.OK => resultTransformer(r.result) @@ -93,14 +115,14 @@ class ElasticSearch( ).flatMap { r => r.status match { case Status.OK => Future.successful(resultTransformer(r.result)) - case _ => getFromCurrentIndex + case _ => getFromCurrentIndex() } } - case _ => getFromCurrentIndex + case _ => getFromCurrentIndex() } }} - def getImageWithSourceById(id: String)(implicit ex: ExecutionContext, logMarker: LogMarker): Future[Option[SourceWrapper[Image]]] = { + def getImageWithSourceById(id: String)(implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[Option[SourceWrapper[Image]]] = { migrationAwareGetter( id, logMessagePart = "image", @@ -109,7 +131,7 @@ class ElasticSearch( ) } - def getImageUploaderById(id: String)(implicit ex: ExecutionContext, logMarker: LogMarker): Future[Option[String]] = { + def getImageUploaderById(id: String)(implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[Option[String]] = { migrationAwareGetter( id, logMessagePart = "image uploader", @@ -118,22 +140,37 @@ class ElasticSearch( ) } - def search(params: SearchParams)(implicit ex: ExecutionContext, request: AuthenticatedRequest[AnyContent, Principal], logMarker: LogMarker = MarkerMap()): Future[SearchResults] = { + private val isPotentiallyGraphicFieldName = "isPotentiallyGraphic" - val isPotentiallyGraphicFieldName = "isPotentiallyGraphic" + private def resolveHit(hit: SearchHit) = mapImageFrom( + hit.sourceAsString, + hit.id, + hit.index, + fields = hit.fields match { + case null => JsObject.empty + case _ => Json.obj( + isPotentiallyGraphicFieldName -> hit.fields.get(isPotentiallyGraphicFieldName).map(_.asInstanceOf[List[Boolean]].headOption) + ) + } + ) - def resolveHit(hit: SearchHit) = mapImageFrom( - hit.sourceAsString, - hit.id, - hit.index, - fields = hit.fields match { - case null => JsObject.empty - case _ => Json.obj( - isPotentiallyGraphicFieldName -> hit.fields.get(isPotentiallyGraphicFieldName).map(_.asInstanceOf[List[Boolean]].headOption) - ) + def lookupIds(ids: List[String], offset: Int, length: Int)(implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[SearchResults] = { + val query = filters.pinnedIds(ids) + + val searchRequest = prepareSearch(query) + .trackTotalHits(true) + .storedFields("_source") + .from(offset) + .size(length) + + executeAndLog(searchRequest, "ids lookup") + .map { r => + val imageHits = r.result.hits.hits.map(resolveHit).toSeq.flatten.map(i => (i.instance.id, i)) + SearchResults(hits = imageHits, total = r.result.totalHits, extraCounts = None) } - ) + } + def search(params: SearchParams)(implicit ex: ExecutionContext, instance: Instance, logMarker:MarkerMap = MarkerMap()): Future[SearchResults] = { val query: Query = queryBuilder.makeQuery(params.structuredQuery) val uploadTimeFilter = filters.date("uploadTime", params.since, params.until) @@ -254,10 +291,10 @@ class ElasticSearch( .runtimeMappings(runtimeMappings) .storedFields("_source") // this needs to be explicit when using script fields .scriptfields(graphicImagesScriptFields) - .aggregations(if (config.shouldDisplayOrgOwnedCountAndFilterCheckbox) List(filterAgg( - orgOwnedAggName, - queryBuilder.makeQuery(Parser.run(s"is:${config.staffPhotographerOrganisation}-owned")) - )) else Nil) + .aggregations(aggregationsNameToSearchClauseMap.map { + case (name, ExtraCountConfig(searchClause, _, maybeSubAggregation)) => + filterAgg(name, queryBuilder.makeQuery(Parser.run(searchClause))).subAggregations(maybeSubAggregation) + }) .from(params.offset) .size(params.length) .sortBy(sort) @@ -271,16 +308,28 @@ class ElasticSearch( SearchResults( hits = imageHits, total = if (trackTotalHits) r.result.totalHits else 0, - maybeOrgOwnedCount = - if (config.shouldDisplayOrgOwnedCountAndFilterCheckbox) - Some(r.result.aggregations.filter(orgOwnedAggName).docCount) - else - None + extraCounts = Some(ExtraCounts( + tickerCounts = aggregationsNameToSearchClauseMap.map { + case (name, ExtraCountConfig(searchClause, backgroundColour, maybeSubAggregation)) => + val aggResult = r.result.aggregations.filter(name) + val maybeSubAggResult = maybeSubAggregation.map(_.name).map(aggResult.result[Terms]) + name -> ExtraCount( + value = aggResult.docCount, + searchClause, + backgroundColour, + subCounts = maybeSubAggResult.map { termsAgg => + ListMap(termsAgg.buckets.sortBy(_.docCount).reverse.map { bucket => + (bucket.key, bucket.docCount) + }: _*) + ("other" -> termsAgg.otherDocCount) + }.filter(_.exists { case (_, count) => count > 0 }) + ) + } + )) ) } } - def usageForSupplier(id: String, numDays: Int)(implicit ex: ExecutionContext, logMarker: LogMarker): Future[SupplierUsageSummary] = { + def usageForSupplier(id: String, numDays: Int)(implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[SupplierUsageSummary] = { val supplier = Agencies.get(id) val supplierName = supplier.supplier @@ -305,7 +354,7 @@ class ElasticSearch( } } - def dateHistogramAggregate(params: AggregateSearchParams)(implicit ex: ExecutionContext, logMarker: LogMarker): Future[AggregateSearchResults] = { + def dateHistogramAggregate(params: AggregateSearchParams)(implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[AggregateSearchResults] = { def fromDateHistogramAggregation(name: String, aggregations: Aggregations): Seq[BucketResult] = aggregations.result[DateHistogram](name). buckets.map(b => BucketResult(b.date, b.docCount)) @@ -317,11 +366,11 @@ class ElasticSearch( } - def metadataSearch(params: AggregateSearchParams)(implicit ex: ExecutionContext, logMarker: LogMarker): Future[AggregateSearchResults] = { + def metadataSearch(params: AggregateSearchParams)(implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[AggregateSearchResults] = { aggregateSearch("metadata", params, termsAgg(name = "metadata", field = metadataField(params.field)), fromTermAggregation) } - def editsSearch(params: AggregateSearchParams)(implicit ex: ExecutionContext, logMarker: LogMarker): Future[AggregateSearchResults] = { + def editsSearch(params: AggregateSearchParams)(implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[AggregateSearchResults] = { logger.info(logMarker, "Edit aggregation requested with params.field: " + params.field) val field = "labels" // TODO was - params.field aggregateSearch("edits", params, termsAgg(name = "edits", field = editsField(field)), fromTermAggregation) @@ -334,8 +383,8 @@ class ElasticSearch( name: String, params: AggregateSearchParams, aggregation: Aggregation, - extract: (String, Aggregations) => Seq[BucketResult] - )(implicit ex: ExecutionContext, logMarker: LogMarker): Future[AggregateSearchResults] = { + extract: (String, Aggregations) => Seq[BucketResult], + )(implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[AggregateSearchResults] = { logger.info(logMarker, "aggregate search: " + name + " / " + params + " / " + aggregation) val query = queryBuilder.makeQuery(params.structuredQuery) val search = prepareSearch(query) aggregations aggregation size 0 @@ -352,11 +401,11 @@ class ElasticSearch( AggregateSearchResults(results, results.size) } - def completionSuggestion(name: String, q: String, size: Int)(implicit ex: ExecutionContext, request: AuthenticatedRequest[AnyContent, Principal]): Future[CompletionSuggestionResults] = { + def completionSuggestion(name: String, q: String, size: Int)(implicit ex: ExecutionContext, instance: Instance): Future[CompletionSuggestionResults] = { implicit val logMarker: MarkerMap = MarkerMap() val completionSuggestion = ElasticDsl.completionSuggestion(name, name).text(q).skipDuplicates(true) - val search = ElasticDsl.search(imagesCurrentAlias) suggestions completionSuggestion + val search = ElasticDsl.search(imagesCurrentAlias(instance)) suggestions completionSuggestion executeAndLog(search, "completion suggestion query"). toMetric(Some(mediaApiMetrics.searchQueries), List(mediaApiMetrics.searchTypeDimension("suggestion-completion")))(_.result.took).map { r => logSearchQueryIfTimedOut(search, r.result) @@ -374,11 +423,11 @@ class ElasticSearch( def withSearchQueryTimeout(sr: SearchRequest): SearchRequest = sr timeout SearchQueryTimeout - private def prepareSearch(query: Query): SearchRequest = { + private def prepareSearch(query: Query)(implicit instance: Instance): SearchRequest = { val indexes = migrationStatus match { case completionPreview: CompletionPreview => List(completionPreview.migrationIndexName) - case running: Running => List(imagesCurrentAlias, running.migrationIndexName) - case _ => List(imagesCurrentAlias) + case running: Running => List(imagesCurrentAlias(instance), running.migrationIndexName) + case _ => List(imagesCurrentAlias(instance)) } val migrationAwareQuery = migrationStatus match { case running: Running => filters.and(query, filters.mustNot(filters.term("esInfo.migration.migratedTo", running.migrationIndexName))) diff --git a/media-api/app/lib/elasticsearch/ElasticSearchModel.scala b/media-api/app/lib/elasticsearch/ElasticSearchModel.scala index 50b54991045..4e710ed2e97 100644 --- a/media-api/app/lib/elasticsearch/ElasticSearchModel.scala +++ b/media-api/app/lib/elasticsearch/ElasticSearchModel.scala @@ -1,5 +1,6 @@ package lib.elasticsearch +import com.gu.mediaservice.lib.argo.model.ExtraCounts import com.gu.mediaservice.lib.auth.{Authentication, Tier} import com.gu.mediaservice.lib.formatting.{parseDateFromQuery, printDateTime} import com.gu.mediaservice.model.usage.UsageStatus @@ -8,14 +9,11 @@ import lib.querysyntax.{Condition, Parser} import org.joda.time.DateTime import play.api.libs.json.{Json, OWrites} import play.api.mvc.{AnyContent, Request} -import scalaz.syntax.applicative._ import scalaz.syntax.std.list._ -import scalaz.syntax.validation._ -import scalaz.{Validation, ValidationNel} import scala.util.Try -case class SearchResults(hits: Seq[(String, SourceWrapper[Image])], total: Long, maybeOrgOwnedCount: Option[Long]) +case class SearchResults(hits: Seq[(String, SourceWrapper[Image])], total: Long, extraCounts: Option[ExtraCounts]) case class AggregateSearchResults(results: Seq[BucketResult], total: Long) @@ -87,6 +85,7 @@ case class SearchParams( countAll: Option[Boolean] = None, printUsageFilters: Option[PrintUsageFilters] = None, shouldFlagGraphicImages: Boolean = false, + useAISearch: Option[Boolean] = None, ) case class InvalidUriParams(message: String) extends Throwable @@ -173,6 +172,8 @@ object SearchParams { request.getQueryString("syndicationStatus") flatMap parseSyndicationStatus, request.getQueryString("countAll") flatMap parseBooleanFromQuery, printUsageFilters, + shouldFlagGraphicImages = false, + request.getQueryString("useAISearch") flatMap parseBooleanFromQuery, ) } diff --git a/media-api/app/lib/elasticsearch/IsQueryFilter.scala b/media-api/app/lib/elasticsearch/IsQueryFilter.scala index d946e65a7ea..4c92a7fc802 100644 --- a/media-api/app/lib/elasticsearch/IsQueryFilter.scala +++ b/media-api/app/lib/elasticsearch/IsQueryFilter.scala @@ -6,51 +6,51 @@ import com.gu.mediaservice.model._ import com.sksamuel.elastic4s.ElasticDsl.matchAllQuery import com.sksamuel.elastic4s.requests.searches.queries.Query import lib.MediaApiConfig -import org.joda.time.DateTime import scalaz.syntax.std.list._ sealed trait IsQueryFilter extends Query with ImageFields { def query: Query override def toString: String = this match { - case IsOwnedPhotograph(staffPhotographerOrg) => s"$staffPhotographerOrg-owned-photo" - case IsOwnedIllustration(staffPhotographerOrg) => s"$staffPhotographerOrg-owned-illustration" - case IsOwnedImage(staffPhotographerOrg) => s"$staffPhotographerOrg-owned" + case IsOwnedPhotograph() => s"owned-photo" + case IsOwnedIllustration() => s"owned-illustration" + case IsOwnedImage() => s"owned" case _: IsDeleted => "deleted" case _: IsUnderQuota => "under-quota" case _: IsReapable => "reapable" + case _: IsAgencyPick => "agency-pick" } } object IsQueryFilter { // for readability, the client capitalises gnm, so `toLowerCase` it before matching def apply(value: String, overQuotaAgencies: () => List[Agency], config: MediaApiConfig): Option[IsQueryFilter] = { - val organisation = config.staffPhotographerOrganisation.toLowerCase value.toLowerCase match { - case s if s == s"$organisation-owned-photo" => Some(IsOwnedPhotograph(organisation)) - case s if s == s"$organisation-owned-illustration" => Some(IsOwnedIllustration(organisation)) - case s if s == s"$organisation-owned" => Some(IsOwnedImage(organisation)) + case s if s == s"owned-photo" => Some(IsOwnedPhotograph()) + case s if s == s"owned-illustration" => Some(IsOwnedIllustration()) + case s if s == s"owned" => Some(IsOwnedImage()) case "under-quota" => Some(IsUnderQuota(overQuotaAgencies())) case "deleted" => Some(IsDeleted(true)) - case "reapable" => Some(IsReapable(config.maybePersistOnlyTheseCollections, config.persistenceIdentifier)) + case "reapable" => Some(IsReapable(config.maybePersistOnlyTheseCollections)) + case "agency-pick" => config.maybeAgencyPickQuery.map(IsAgencyPick) case _ => None } } } -case class IsOwnedPhotograph(staffPhotographerOrg: String) extends IsQueryFilter { +case class IsOwnedPhotograph() extends IsQueryFilter { override def query: Query = filters.or( filters.terms(usageRightsField("category"), UsageRights.photographer.map(_.category)) ) } -case class IsOwnedIllustration(staffPhotographerOrg: String) extends IsQueryFilter { +case class IsOwnedIllustration() extends IsQueryFilter { override def query: Query = filters.or( filters.terms(usageRightsField("category"), UsageRights.illustrator.map(_.category)) ) } -case class IsOwnedImage(staffPhotographerOrg: String) extends IsQueryFilter { +case class IsOwnedImage() extends IsQueryFilter { override def query: Query = filters.or( filters.terms(usageRightsField("category"), UsageRights.whollyOwned.map(_.category)) ) @@ -68,6 +68,9 @@ case class IsDeleted(isDeleted: Boolean) extends IsQueryFilter { ) } -case class IsReapable(maybePersistOnlyTheseCollections: Option[Set[String]], persistenceIdentifier: String) +case class IsReapable(maybePersistOnlyTheseCollections: Option[Set[String]]) extends IsQueryFilter with ReapableEligibility { + override def query: Query = preview } + +case class IsAgencyPick(query: Query) extends IsQueryFilter diff --git a/media-api/app/lib/elasticsearch/MatchFields.scala b/media-api/app/lib/elasticsearch/MatchFields.scala index d1a7d2f78a6..faeffa64b17 100644 --- a/media-api/app/lib/elasticsearch/MatchFields.scala +++ b/media-api/app/lib/elasticsearch/MatchFields.scala @@ -13,7 +13,6 @@ trait MatchFields extends ImageFields { "peopleInImage", "specialInstructions", "englishAnalysedCatchAll", "imageType").map(metadataField) ++ Seq("labels").map(editsField) ++ - config.queriableIdentifiers.map(identifierField) ++ Seq("restrictions").map(usageRightsField) } diff --git a/media-api/app/lib/elasticsearch/QueryBuilder.scala b/media-api/app/lib/elasticsearch/QueryBuilder.scala index 7b53c02c4c2..e286374711c 100644 --- a/media-api/app/lib/elasticsearch/QueryBuilder.scala +++ b/media-api/app/lib/elasticsearch/QueryBuilder.scala @@ -4,7 +4,7 @@ import com.gu.mediaservice.lib.ImageFields import com.gu.mediaservice.lib.elasticsearch.IndexSettings import com.gu.mediaservice.lib.formatting.printDateTime import com.gu.mediaservice.lib.logging.GridLogging -import com.gu.mediaservice.model.Agency +import com.gu.mediaservice.model.{Agency, SyndicationStatus} import com.sksamuel.elastic4s.ElasticDsl import com.sksamuel.elastic4s.ElasticDsl._ import com.sksamuel.elastic4s.requests.common.Operator @@ -59,6 +59,7 @@ class QueryBuilder(matchFields: Seq[String], overQuotaAgencies: () => List[Agenc case _ => matchPhraseQuery(resolveFieldPath(field), value) } case DateRange(start, end) => rangeQuery(resolveFieldPath(field)).gte(printDateTime(start)).lte(printDateTime(end)) + case SyndicationStatusValue(status) => new SyndicationFilter(config).statusFilter(SyndicationStatus.apply(status)) case e => throw InvalidQuery(s"Cannot do single field query on $e") } case HierarchyField => condition.value match { diff --git a/media-api/app/lib/elasticsearch/SearchFilters.scala b/media-api/app/lib/elasticsearch/SearchFilters.scala index 9f428261001..745ff9c6587 100644 --- a/media-api/app/lib/elasticsearch/SearchFilters.scala +++ b/media-api/app/lib/elasticsearch/SearchFilters.scala @@ -52,7 +52,7 @@ class SearchFilters(config: MediaApiConfig) extends ImageFields { lazy val freeToUseCategories: List[String] = usageRights.filter(ur => ur.defaultCost.exists(cost => cost == Free || cost == Conditional)).map(ur => ur.category) - val persistedReasons: List[PersistenceReason] = ImagePersistenceReasons(config.maybePersistOnlyTheseCollections, config.persistenceIdentifier).allReasons + val persistedReasons: List[PersistenceReason] = ImagePersistenceReasons(config.maybePersistOnlyTheseCollections).allReasons val persistedFilter: Query = filters.or(persistedReasons.map(_.query): _*) diff --git a/media-api/app/lib/elasticsearch/SyndicationFilter.scala b/media-api/app/lib/elasticsearch/SyndicationFilter.scala index 1b7003806c0..f0de27f58de 100644 --- a/media-api/app/lib/elasticsearch/SyndicationFilter.scala +++ b/media-api/app/lib/elasticsearch/SyndicationFilter.scala @@ -5,6 +5,7 @@ import com.gu.mediaservice.lib.elasticsearch.filters import com.gu.mediaservice.model._ import com.gu.mediaservice.model.leases.{AllowSyndicationLease, DenySyndicationLease} import com.gu.mediaservice.model.usage.SyndicationUsage +import com.sksamuel.elastic4s.ElasticApi.not import com.sksamuel.elastic4s.requests.searches.RuntimeMapping import com.sksamuel.elastic4s.requests.searches.queries.Query import lib.MediaApiConfig @@ -14,18 +15,6 @@ class SyndicationFilter(config: MediaApiConfig) extends ImageFields { val isSyndicationDateFilterActive = config.isProd - private def syndicationRightsAcquired(acquired: Boolean): Query = filters.boolTerm( - field = "syndicationRights.rights.acquired", - value = acquired - ) - - private val noRightsAcquired: Query = filters.or( - filters.existsOrMissing("syndicationRights.rights.acquired", exists = false), - syndicationRightsAcquired(false) - ) - - private val hasRightsAcquired: Query = syndicationRightsAcquired(true) - private val hasAllowLease: Query = filters.term( "leases.leases.access", AllowSyndicationLease.name @@ -72,30 +61,22 @@ class SyndicationFilter(config: MediaApiConfig) extends ImageFields { filters.date("leases.leases.endDate", Some(DateTime.now), None).get ) - private def syndicationRightsPublished: Query = filters.or( - filters.existsOrMissing("syndicationRights.published", exists = false), - filters.date("syndicationRights.published", None, Some(DateTime.now)).get - ) - - private val syndicatableCategory: Query = IsOwnedPhotograph(config.staffPhotographerOrganisation).query + private val syndicatableCategory: Query = IsOwnedPhotograph().query def statusFilter(status: SyndicationStatus): Query = status match { case SentForSyndication => filters.and( - hasRightsAcquired, hasAllowLease, hasSyndicationUsage ) case QueuedForSyndication => filters.and( - hasRightsAcquired, filters.mustNot(hasSyndicationUsage), filters.and( hasAllowLease, leaseHasStarted, - syndicationRightsPublished + syndicatableCategory, ) ) case BlockedForSyndication => filters.and( - hasRightsAcquired, hasDenyLease ) case AwaitingReviewForSyndication => { @@ -114,7 +95,6 @@ class SyndicationFilter(config: MediaApiConfig) extends ImageFields { ) val rightsAcquiredNoLeaseFilter = filters.and( - hasRightsAcquired, syndicatableCategory, filters.mustNot(mustNotClauses:_*), ) @@ -127,7 +107,7 @@ class SyndicationFilter(config: MediaApiConfig) extends ImageFields { case _ => rightsAcquiredNoLeaseFilter } } - case UnsuitableForSyndication => noRightsAcquired + case UnsuitableForSyndication => not(IsOwnedPhotograph().query) } } diff --git a/media-api/app/lib/querysyntax/QuerySyntax.scala b/media-api/app/lib/querysyntax/QuerySyntax.scala index cb62b5346e6..52c8bf02d0c 100644 --- a/media-api/app/lib/querysyntax/QuerySyntax.scala +++ b/media-api/app/lib/querysyntax/QuerySyntax.scala @@ -37,6 +37,7 @@ class QuerySyntax(val input: ParserInput) extends Parser with ImageFields { DateRangeMatch ~> Match | AtMatch | FileTypeMatch ~> Match | CollectionRule | + SyndicationStatusMatch ~> Match | ScopedMatch ~> Match | HashMatch | AnyMatch } @@ -174,6 +175,11 @@ class QuerySyntax(val input: ParserInput) extends Parser with ImageFields { MatchMimeTypeField ~ ':' ~ MatchMimeTypeValue } + def SyndicationStatusMatch = rule { + MatchSyndicationStatusField ~ ':' ~ MatchSyndicationStatusValue + } + + def AtMatch = rule { '@' ~ MatchDateRangeValue ~> (range => Match(SingleField(getFieldPath("uploadTime")), range)) } def MatchDateField = rule { capture(AllowedDateFieldName) ~> resolveDateField _ } @@ -182,10 +188,18 @@ class QuerySyntax(val input: ParserInput) extends Parser with ImageFields { capture("fileType") ~> resolveMimeTypeField _ } + def MatchSyndicationStatusField = rule { + capture("syndicationStatus") ~> resolveSyndicationStatusField _ + } + def resolveMimeTypeField(name: String): Field = name match { case "fileType" => SingleField(getFieldPath("mimeType")) } + def resolveSyndicationStatusField(name: String): Field = name match { + case "syndicationStatus" => SingleField("syndicationStatus") + } + def resolveDateField(name: String): Field = name match { case "date" | "uploaded" => SingleField("uploadTime") case "taken" => SingleField("dateTaken") @@ -212,6 +226,12 @@ class QuerySyntax(val input: ParserInput) extends Parser with ImageFields { def AllowedFileTypesValues = rule { "tiff" | "tif" | "jpg" | "jpeg" | "png" } + def MatchSyndicationStatusValue = rule { + capture(AllowedSyndicationStatusValues) ~> parseSyndicationStatus _ + } + + def AllowedSyndicationStatusValues = rule { "review" | "queued" | "unsuitable" | "sent" } + def translateMimeType(expr: String): MimeType = expr match { case s if s.equals("tif") || s.equals("tiff") => Tiff case s if s.equals("jpg") || s.equals("jpeg") => Jpeg @@ -220,6 +240,8 @@ class QuerySyntax(val input: ParserInput) extends Parser with ImageFields { def parseMimeType(expr: String): Value = Words(translateMimeType(expr).toString) + def parseSyndicationStatus(expr: String): Value = SyndicationStatusValue(expr) + def normaliseDateExpr(expr: String): String = expr.replaceAll("\\.", " ") val todayParser = DateAliasParser("today", today, tomorrow) diff --git a/media-api/app/lib/querysyntax/model.scala b/media-api/app/lib/querysyntax/model.scala index fe22886154d..1772a0611d4 100644 --- a/media-api/app/lib/querysyntax/model.scala +++ b/media-api/app/lib/querysyntax/model.scala @@ -23,3 +23,4 @@ final case class Date(date: DateTime) extends Value final case class DateRange(startDate: DateTime, endDate: DateTime) extends Value final case class HasValue(string: String) extends Value final case class IsValue(string: String) extends Value +final case class SyndicationStatusValue(string: String) extends Value diff --git a/media-api/conf/routes b/media-api/conf/routes index d868fd4c400..02c334a7608 100644 --- a/media-api/conf/routes +++ b/media-api/conf/routes @@ -19,6 +19,7 @@ GET /images/:id/projection/diff controllers. GET /images/:id/fileMetadata controllers.MediaApi.getImageFileMetadata(id: String) GET /images/:imageId/uploadedBy controllers.MediaApi.uploadedBy(imageId: String) GET /images/:imageId/export/:exportId controllers.MediaApi.getImageExport(imageId: String, exportId: String) +GET /images/:imageId/export/:exportId/master/download controllers.MediaApi.downloadImageExportMaster(imageId: String, exportId: String) GET /images/:imageId/export/:exportId/asset/:width/download controllers.MediaApi.downloadImageExport(imageId: String, exportId: String, width: Int) GET /images/:imageId/export controllers.MediaApi.getImageExports(imageId: String) GET /images/:imageId/download controllers.MediaApi.downloadOriginalImage(imageId: String) @@ -43,7 +44,6 @@ GET /usage/quotas/:id controllers. GET /management/healthcheck com.gu.mediaservice.lib.management.ElasticSearchHealthCheck.healthCheck GET /management/manifest com.gu.mediaservice.lib.management.Management.manifest GET /management/imageCounts com.gu.mediaservice.lib.management.ElasticSearchHealthCheck.imageCounts -GET /management/whoAmI com.gu.mediaservice.lib.management.InnerServiceStatusCheckController.whoAmI(depth: Int) # Shoo robots away GET /robots.txt com.gu.mediaservice.lib.management.Management.disallowRobots diff --git a/media-api/test/lib/ImagePersistenceReasonsTest.scala b/media-api/test/lib/ImagePersistenceReasonsTest.scala index d2b6b18e59e..fdf5e324233 100644 --- a/media-api/test/lib/ImagePersistenceReasonsTest.scala +++ b/media-api/test/lib/ImagePersistenceReasonsTest.scala @@ -15,12 +15,10 @@ class ImagePersistenceReasonsTest extends AnyFunSpec with Matchers { val persistedIdentifier = "test-p-id" val persistedCollections = Set("coll1", "coll2", "coll3") - val imgPersistenceReasons = ImagePersistenceReasons(Some(persistedCollections), persistedIdentifier) - val imagePersistenceReasonsWithEmptyListOfPersistedCollections = ImagePersistenceReasons(Some(Set.empty), persistedIdentifier) + val imgPersistenceReasons = ImagePersistenceReasons(Some(persistedCollections)) + val imagePersistenceReasonsWithEmptyListOfPersistedCollections = ImagePersistenceReasons(Some(Set.empty)) imgPersistenceReasons.reasons(img) shouldBe Nil - val imgWithPersistenceIdentifier = img.copy(identifiers = Map(persistedIdentifier -> "test-id")) - imgPersistenceReasons.reasons(imgWithPersistenceIdentifier) shouldBe List("persistence-identifier") val imgWithExports = img.copy(exports = List(Crop(None, None, None, null, None, Nil))) imgPersistenceReasons.reasons(imgWithExports) shouldBe List("exports") val imgWithUsages = img.copy(usages = List(Usage("test", Nil, null, "img", null, None, None, now()))) diff --git a/media-api/test/lib/UsageStoreTest.scala b/media-api/test/lib/UsageStoreTest.scala index 018f49a3fd8..e69de29bb2d 100644 --- a/media-api/test/lib/UsageStoreTest.scala +++ b/media-api/test/lib/UsageStoreTest.scala @@ -1,30 +0,0 @@ -package lib - -import com.gu.mediaservice.model.Agency -import org.scalatest.funspec.AnyFunSpec -import org.scalatest.matchers.should.Matchers - -class UsageStoreTest extends AnyFunSpec with Matchers { - describe("Usage Store") { - it("should parse RCS usage emails") { - val stream = getClass.getResourceAsStream("/example.mail") - - val lines = UsageStore.extractEmail(stream) - - lines.head should be ("\"Cpro Name\",\"Id\"") - lines.tail.head should be ("\"Australian Associated Press Pty Limited (Stacey Shipton)\",\"397\"") - - val list = UsageStore.csvParser(lines) - - list.head should be (SupplierUsageSummary(Agency("Australian Associated Press Pty Limited (Stacey Shipton)"), 397)) - } - - it("should parse non-ASCII RCS usage emails") { - val stream = getClass.getResourceAsStream("/nonascii.mail") - - val lines = UsageStore.extractEmail(stream) - - noException should be thrownBy { UsageStore.csvParser(lines) } - } - } -} diff --git a/media-api/test/lib/elasticsearch/ConditionFixtures.scala b/media-api/test/lib/elasticsearch/ConditionFixtures.scala index fb64634208a..f61c5c2e5c7 100644 --- a/media-api/test/lib/elasticsearch/ConditionFixtures.scala +++ b/media-api/test/lib/elasticsearch/ConditionFixtures.scala @@ -15,9 +15,9 @@ trait ConditionFixtures { val hasFieldCondition = Match(HasField, HasValue("foo")) - val isOwnedPhotoCondition = Match(IsField, IsValue(IsOwnedPhotograph("GNM").toString)) - val isOwnedIllustrationCondition = Match(IsField, IsValue(IsOwnedIllustration("GNM").toString)) - val isOwnedImageCondition = Match(IsField, IsValue(IsOwnedImage("GNM").toString)) + val isOwnedPhotoCondition = Match(IsField, IsValue(IsOwnedPhotograph().toString)) + val isOwnedIllustrationCondition = Match(IsField, IsValue(IsOwnedIllustration().toString)) + val isOwnedImageCondition = Match(IsField, IsValue(IsOwnedImage().toString)) val isUnderQuotaCondition = Match(IsField, IsValue(IsUnderQuota(Nil).toString)) val isInvalidCondition = Match(IsField, IsValue("a-random-string")) @@ -29,4 +29,6 @@ trait ConditionFixtures { val nestedCondition: Condition = Nested(SingleField("usages"), SingleField("usages.status"), Words("pending")) val anotherNestedCondition: Condition = Nested(SingleField("something"), SingleField("something.field"), Phrase("dogs")) + val syndicationStatusCondition: Condition = Match(SingleField("syndicationStatus"), SyndicationStatusValue("review")) + } diff --git a/media-api/test/lib/elasticsearch/ElasticSearchTest.scala b/media-api/test/lib/elasticsearch/ElasticSearchTest.scala index 9f333f9cea1..74008ddad9e 100644 --- a/media-api/test/lib/elasticsearch/ElasticSearchTest.scala +++ b/media-api/test/lib/elasticsearch/ElasticSearchTest.scala @@ -5,6 +5,7 @@ import com.gu.mediaservice.lib.auth.Authentication.Principal import com.gu.mediaservice.lib.auth.{Internal, ReadOnly, Syndication} import com.gu.mediaservice.lib.config.GridConfigResources import com.gu.mediaservice.lib.elasticsearch.{ElasticSearchAliases, ElasticSearchConfig, ElasticSearchExecutions} +import com.gu.mediaservice.lib.instances.InstancesClient import com.gu.mediaservice.lib.logging.{LogMarker, MarkerMap} import com.gu.mediaservice.model._ import com.gu.mediaservice.model.leases.DenySyndicationLease @@ -19,18 +20,17 @@ import org.scalatestplus.mockito.MockitoSugar import play.api.Configuration import play.api.inject.ApplicationLifecycle import play.api.libs.json.{JsString, Json} -import play.api.mvc.AnyContent -import play.api.mvc.Security.AuthenticatedRequest +import java.util.UUID import scala.concurrent.duration._ import scala.concurrent.{Await, Future} import scala.concurrent.ExecutionContext.Implicits.global class ElasticSearchTest extends ElasticSearchTestBase with Eventually with ElasticSearchExecutions with MockitoSugar { - implicit val request: AuthenticatedRequest[AnyContent, Principal] = mock[AuthenticatedRequest[AnyContent, Principal]] + implicit val instance: Instance = Instance(UUID.randomUUID().toString) - private val index = "images" + private val index = instance.id + "_index" private val applicationLifecycle = new ApplicationLifecycle { override def addStopHook(hook: () => Future[_]): Unit = {} @@ -55,7 +55,7 @@ class ElasticSearchTest extends ElasticSearchTestBase with Eventually with Elast ) - private lazy val ES = new ElasticSearch(mediaApiConfig, mediaApiMetrics, elasticConfig, () => List.empty, mock[Scheduler]) + private lazy val ES = new ElasticSearch(mediaApiConfig, mediaApiMetrics, elasticConfig, () => List.empty, mock[Scheduler], mock[InstancesClient]) lazy val client = ES.client private val expectedNumberOfImages = images.size @@ -66,12 +66,12 @@ class ElasticSearchTest extends ElasticSearchTestBase with Eventually with Elast override def beforeAll(): Unit = { super.beforeAll() - ES.ensureIndexExistsAndAliasAssigned() + ES.ensureIndexExistsAndAliasAssigned(alias = ES.imagesCurrentAlias(instance), instance.id + "_index") purgeTestImages Await.ready(saveImages(images), 1.minute) // allow the cluster to distribute documents... eventual consistency! - eventually(timeout(fiveSeconds), interval(oneHundredMilliseconds))(totalImages shouldBe expectedNumberOfImages) + eventually(timeout(fiveSeconds), interval(oneHundredMilliseconds))(totalImages(instance) shouldBe expectedNumberOfImages) } override def afterAll(): Unit = purgeTestImages @@ -289,12 +289,12 @@ class ElasticSearchTest extends ElasticSearchTestBase with Eventually with Elast } } - it("should return 3 images if an Internal tier queries for AwaitingReviewForSyndication images") { + it("should return 6 owned images if an Internal tier queries for AwaitingReviewForSyndication images") { // Elastic1 implementation is returning the images with reviewed and blocked syndicationStatus val search = SearchParams(tier = Internal, syndicationStatus = Some(AwaitingReviewForSyndication)) val searchResult = ES.search(search) whenReady(searchResult, timeout, interval) { result => - result.total shouldBe 3 + result.total shouldBe 6 } } } @@ -431,7 +431,7 @@ class ElasticSearchTest extends ElasticSearchTestBase with Eventually with Elast def overQuotaAgencies = List(Agency("Getty Images"), Agency("AP")) val search = SearchParams(tier = Internal, structuredQuery = List(isUnderQuotaCondition), length = 50) - val elasticsearch = new ElasticSearch(mediaApiConfig, mediaApiMetrics, elasticConfig, () => overQuotaAgencies, mock[Scheduler]) + val elasticsearch = new ElasticSearch(mediaApiConfig, mediaApiMetrics, elasticConfig, () => overQuotaAgencies, mock[Scheduler], mock[InstancesClient]) whenReady(elasticsearch.search(search), timeout, interval) { result => { val overQuotaImages = List( @@ -456,7 +456,7 @@ class ElasticSearchTest extends ElasticSearchTestBase with Eventually with Elast }) } - private def totalImages: Long = Await.result(ES.client.execute(ElasticDsl.search(ES.imagesCurrentAlias)).map { + private def totalImages(instance: Instance): Long = Await.result(ES.client.execute(ElasticDsl.search(ES.imagesCurrentAlias(instance))).map { _.result.totalHits }, oneHundredMilliseconds) @@ -466,7 +466,7 @@ class ElasticSearchTest extends ElasticSearchTestBase with Eventually with Elast def deleteImages = executeAndLog(deleteByQuery(index, matchAllQuery()), s"Deleting images") Await.result(deleteImages, fiveSeconds) - eventually(timeout(fiveSeconds), interval(oneHundredMilliseconds))(totalImages shouldBe 0) + eventually(timeout(fiveSeconds), interval(oneHundredMilliseconds))(totalImages(instance) shouldBe 0) } } diff --git a/media-api/test/lib/elasticsearch/Fixtures.scala b/media-api/test/lib/elasticsearch/Fixtures.scala index 50666174cca..fae81b80f32 100644 --- a/media-api/test/lib/elasticsearch/Fixtures.scala +++ b/media-api/test/lib/elasticsearch/Fixtures.scala @@ -26,7 +26,6 @@ trait Fixtures { val NOT_USED_IN_TEST = "not used in test" val MOCK_CONFIG_KEYS = Seq( "auth.keystore.bucket", - "persistence.identifier", "thrall.kinesis.stream.name", "thrall.kinesis.lowPriorityStream.name", "domain.root", @@ -36,10 +35,17 @@ trait Fixtures { "es.index.aliases.current", "es.index.aliases.migration", "es6.url", - "s3.image.bucket", - "s3.thumb.bucket", + "s3.image.bucket.name", + "s3.image.bucket.endpoint", + "s3.thumb.bucket.name", + "s3.thumb.bucket.endpoint", "grid.stage", - "grid.appName" + "grid.appName", + "instance.service.my", + "instance.service.instances", + "usageEvents.queue.name", + "publishing.image.bucket.name", + "publishing.image.bucket.endpoint" ) def createImage( @@ -57,7 +63,7 @@ trait Fixtures { softDeletedMetadata = None, lastModified = None, identifiers = Map.empty, - uploadInfo = UploadInfo(filename = Some(s"test_$id.jpeg")), + uploadInfo = UploadInfo(filename = Some(s"test_$id.jpeg"), isFeedUpload = Some(false)), source = Asset( file = new URI(s"http://file/$id"), size = Some(292265L), diff --git a/media-api/test/lib/elasticsearch/QueryBuilderTest.scala b/media-api/test/lib/elasticsearch/QueryBuilderTest.scala index ace13ce3005..5340a7d6226 100644 --- a/media-api/test/lib/elasticsearch/QueryBuilderTest.scala +++ b/media-api/test/lib/elasticsearch/QueryBuilderTest.scala @@ -59,6 +59,15 @@ class QueryBuilderTest extends AnyFunSpec with Matchers with ConditionFixtures w query.must.head.asInstanceOf[MatchPhraseQuery].value shouldBe "avalue" } + it("syndication status condition should give a syndicationStatusFilter query") { + val conditions = List(syndicationStatusCondition) + + val query = queryBuilder.makeQuery(conditions).asInstanceOf[BoolQuery] + + println(query) + query.must.size shouldBe 1 + } + it("multiple conditions should give multiple must conditions") { val query = queryBuilder.makeQuery(List(fieldPhraseMatchCondition, anotherFieldPhraseMatchCondition)).asInstanceOf[BoolQuery] diff --git a/media-api/test/scala/lib/querysyntax/ParserTest.scala b/media-api/test/scala/lib/querysyntax/ParserTest.scala index 98d63a20ff6..5dc15b029c2 100644 --- a/media-api/test/scala/lib/querysyntax/ParserTest.scala +++ b/media-api/test/scala/lib/querysyntax/ParserTest.scala @@ -485,6 +485,15 @@ class ParserTest extends AnyFunSpec with Matchers with BeforeAndAfter with Image } } + describe("syndicationStatus filter") { + it("should find review status images") { + Parser.run("syndicationStatus:review") should be (List( + Match(SingleField(getFieldPath("syndicationStatus")), SyndicationStatusValue("review")), + Negation(Match(IsField,IsValue("deleted"))) + )) + } + } + describe("fileType filter") { it("should find jpegs images") { Parser.run("fileType:jpeg") should be (List( diff --git a/metadata-editor/app/MetadataEditorComponents.scala b/metadata-editor/app/MetadataEditorComponents.scala index 1b2c439ebcb..cc777010946 100644 --- a/metadata-editor/app/MetadataEditorComponents.scala +++ b/metadata-editor/app/MetadataEditorComponents.scala @@ -1,15 +1,18 @@ -import com.gu.mediaservice.lib.management.InnerServiceStatusCheckController import com.gu.mediaservice.lib.play.GridComponents import controllers.{EditsApi, EditsController, SyndicationController} import lib._ import play.api.ApplicationLoader.Context import router.Routes +import software.amazon.awssdk.services.dynamodb.DynamoDbClient class MetadataEditorComponents(context: Context) extends GridComponents(context, new EditsConfig(_)) { final override val buildInfo = utils.buildinfo.BuildInfo - val editsStore = new EditsStore(config) - val syndicationStore = new SyndicationStore(config) + val editsStore = new EditsStore(config.withAWSCredentialsV2(DynamoDbClient.builder()).build(), config.editsTable) + val syndicationStore = new SyndicationStore( + config.withAWSCredentialsV2(DynamoDbClient.builder()).build(), + config.syndicationTable + ) val notifications = new Notifications(config) val metrics = new MetadataEditorMetrics(config, actorSystem, applicationLifecycle) @@ -23,10 +26,9 @@ class MetadataEditorComponents(context: Context) extends GridComponents(context, val editsController = new EditsController(auth, editsStore, notifications, config, wsClient, authorisation, controllerComponents) val syndicationController = new SyndicationController(auth, editsStore, syndicationStore, notifications, config, controllerComponents) val controller = new EditsApi(auth, config, authorisation, controllerComponents) - val InnerServiceStatusCheckController = new InnerServiceStatusCheckController(auth, controllerComponents, config.services, wsClient) - override val router = new Routes(httpErrorHandler, controller, editsController, syndicationController, management, InnerServiceStatusCheckController) + override val router = new Routes(httpErrorHandler, controller, editsController, syndicationController, management) } diff --git a/metadata-editor/app/controllers/EditsApi.scala b/metadata-editor/app/controllers/EditsApi.scala index 6e8d9b32f9b..c60c40aeb5e 100644 --- a/metadata-editor/app/controllers/EditsApi.scala +++ b/metadata-editor/app/controllers/EditsApi.scala @@ -5,14 +5,14 @@ import com.gu.mediaservice.lib.argo.model.Link import com.gu.mediaservice.lib.auth.{Authentication, Authorisation} import com.gu.mediaservice.lib.auth.Authentication.Principal import com.gu.mediaservice.lib.auth.Permissions.EditMetadata -import com.gu.mediaservice.lib.config.{RuntimeUsageRightsConfig, UsageRightsConfigProvider} +import com.gu.mediaservice.lib.config.{InstanceForRequest, RuntimeUsageRightsConfig, UsageRightsConfigProvider} import com.gu.mediaservice.model._ import lib.EditsConfig import model.UsageRightsProperty import model.UsageRightsLease import play.api.libs.json._ import play.api.mvc.Security.AuthenticatedRequest -import play.api.mvc.{AnyContent, BaseController, ControllerComponents} +import play.api.mvc.{AnyContent, BaseController, ControllerComponents, Request} import scala.concurrent.ExecutionContext @@ -20,36 +20,41 @@ class EditsApi(auth: Authentication, config: EditsConfig, authorisation: Authorisation, override val controllerComponents: ControllerComponents)(implicit val ec: ExecutionContext) - extends BaseController with ArgoHelpers { + extends BaseController with ArgoHelpers with InstanceForRequest { // TODO: add links to the different responses esp. to the reference image - val indexResponse = { + def indexResponse()(implicit instance: Instance) = { val indexData = Map("description" -> "This is the Metadata Editor Service") val indexLinks = List( - Link("edits", s"${config.rootUri}/metadata/{id}"), - Link("archived", s"${config.rootUri}/metadata/{id}/archived"), - Link("labels", s"${config.rootUri}/metadata/{id}/labels"), - Link("usageRights", s"${config.rootUri}/metadata/{id}/usage-rights"), - Link("metadata", s"${config.rootUri}/metadata/{id}/metadata"), - Link("usage-rights-list", s"${config.rootUri}/usage-rights/categories"), - Link("filtered-usage-rights-list", s"${config.rootUri}/usage-rights/filtered-categories") + Link("edits", s"${config.rootUri(instance)}/metadata/{id}"), + Link("archived", s"${config.rootUri(instance)}/metadata/{id}/archived"), + Link("labels", s"${config.rootUri(instance)}/metadata/{id}/labels"), + Link("usageRights", s"${config.rootUri(instance)}/metadata/{id}/usage-rights"), + Link("metadata", s"${config.rootUri(instance)}/metadata/{id}/metadata"), + Link("usage-rights-list", s"${config.rootUri(instance)}/usage-rights/categories"), + Link("filtered-usage-rights-list", s"${config.rootUri(instance)}/usage-rights/filtered-categories") ) respond(indexData, indexLinks) } - def index = auth { indexResponse } + def index = auth { request => + implicit val instance: Instance = instanceOf(request) + indexResponse() + } - val usageRightsResponse = { + def usageRightsResponse()(implicit request: Request[AnyContent]) = { val usageRights = config.applicableUsageRights.toList val usageRightsData = usageRights - .map(u => CategoryResponse.fromUsageRights(u, config)) + .map(u => CategoryResponse.fromUsageRights(u, config)) respond(usageRightsData) } - def getUsageRights = auth { usageRightsResponse } + def getUsageRights = auth { request => + usageRightsResponse()(request) + } def filteredUsageRightsResponse(request: AuthenticatedRequest[AnyContent, Principal]) = { val usageRights = config.applicableUsageRights.toList diff --git a/metadata-editor/app/controllers/EditsController.scala b/metadata-editor/app/controllers/EditsController.scala index 6ee3079fade..a657b949685 100644 --- a/metadata-editor/app/controllers/EditsController.scala +++ b/metadata-editor/app/controllers/EditsController.scala @@ -1,30 +1,25 @@ package controllers -import java.net.URI -import java.net.URLDecoder.decode - import com.amazonaws.AmazonServiceException import com.gu.mediaservice.GridClient import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.argo.model._ -import com.gu.mediaservice.lib.aws.DynamoDB import com.gu.mediaservice.lib.auth.Authentication.Principal import com.gu.mediaservice.lib.auth.Permissions.EditMetadata import com.gu.mediaservice.lib.auth.{Authentication, Authorisation} -import com.gu.mediaservice.lib.aws.NoItemFound -import com.gu.mediaservice.lib.config.{ServiceHosts, Services} +import com.gu.mediaservice.lib.aws.{DynamoDB, NoItemFound} +import com.gu.mediaservice.lib.config.InstanceForRequest import com.gu.mediaservice.model._ import com.gu.mediaservice.syntax.MessageSubjects import lib._ -import lib.Edit -import org.joda.time.DateTime import play.api.libs.json._ import play.api.libs.ws.WSClient import play.api.mvc.{BaseController, ControllerComponents} +import java.net.URI +import java.net.URLDecoder.decode import scala.concurrent.{ExecutionContext, Future} -import scala.collection.compat._ // FIXME: the argoHelpers are all returning `Ok`s (200) @@ -54,80 +49,89 @@ class EditsController( authorisation: Authorisation, override val controllerComponents: ControllerComponents )(implicit val ec: ExecutionContext) - extends BaseController with ArgoHelpers with EditsResponse with MessageSubjects with Edit { + extends BaseController with ArgoHelpers with EditsResponse with MessageSubjects with Edit with InstanceForRequest { import com.gu.mediaservice.lib.metadata.UsageRightsMetadataMapper.usageRightsToMetadata - val services: Services = new Services(config.domainRoot, config.serviceHosts, Set.empty) - val gridClient: GridClient = GridClient(services)(ws) + private val gridClient: GridClient = GridClient(config.services)(ws) - val metadataBaseUri = config.services.metadataBaseUri + val metadataBaseUri: Instance => String = config.services.metadataBaseUri private val AuthenticatedAndAuthorised = auth andThen authorisation.CommonActionFilters.authorisedForArchive - private def getUploader(imageId: String, user: Principal): Future[Option[String]] = gridClient.getUploadedBy(imageId, auth.getOnBehalfOfPrincipal(user)) + private def getUploader(imageId: String, user: Principal, instance: Instance): Future[Option[String]] = { + implicit val i: Instance = instance + gridClient.getUploadedBy(imageId, auth.getOnBehalfOfPrincipal(user)) + } private def authorisedForEditMetadataOrUploader(imageId: String) = authorisation.actionFilterForUploaderOr(imageId, EditMetadata, getUploader) def decodeUriParam(param: String): String = decode(param, "UTF-8") // TODO: Think about calling this `overrides` or something that isn't metadata - def getAllMetadata(id: String) = auth.async { + def getAllMetadata(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) val emptyResponse = respond(Edits.getEmpty)(editsEntity(id)) - editsStore.get(id) map { dynamoEntry => + editsStore.getV2(id) map { dynamoEntry => dynamoEntry.asOpt[Edits] .map(respond(_)(editsEntity(id))) .getOrElse(emptyResponse) } recover { case NoItemFound => emptyResponse } } - def getEdits(id: String) = auth.async { - editsStore.get(id) map { dynamoEntry => + def getEdits(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) + editsStore.getV2(id) map { dynamoEntry => val edits = dynamoEntry.asOpt[Edits] respond(data = edits) } recover { case NoItemFound => NotFound } } - def getArchived(id: String) = auth.async { - editsStore.booleanGet(id, Edits.Archived) map { archived => - respond(archived.getOrElse(false)) + def getArchived(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) + editsStore.booleanGetV2(id, Edits.Archived) map { archived => + respond(archived) } recover { case NoItemFound => respond(false) } } - def setArchived(id: String) = AuthenticatedAndAuthorised.async(parse.json) { implicit req => + def setArchived(id: String) = AuthenticatedAndAuthorised.async(parse.json) { req => + implicit val instance: Instance = instanceOf(req) (req.body \ "data").validate[Boolean].fold( errors => Future.successful(BadRequest(errors.toString())), archived => - editsStore.booleanSetOrRemove(id, "archived", archived) + editsStore.booleanSetOrRemoveV2(id, "archived", archived) .map(publish(id, UpdateImageUserMetadata)) .map(edits => respond(edits.archived)) ) } - def unsetArchived(id: String) = auth.async { - editsStore.removeKey(id, Edits.Archived) + def unsetArchived(id: String) = auth.async { req => + implicit val instance: Instance = instanceOf(req) + editsStore.removeKeyV2(id, Edits.Archived) .map(publish(id, UpdateImageUserMetadata)) .map(_ => respond(false)) } - def getLabels(id: String) = auth.async { - editsStore.setGet(id, Edits.Labels) + def getLabels(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) + editsStore.setGetV2(id, Edits.Labels) .map(labelsCollection(id, _)) - .map {case (uri, labels) => respondCollection(labels)} recover { + .map {case (_, labels) => respondCollection(labels)} recover { case NoItemFound => respond(Array[String]()) } } def addLabels(id: String) = auth.async(parse.json) { req => + implicit val instance: Instance = instanceOf(req) (req.body \ "data").validate[List[String]].fold( errors => Future.successful(BadRequest(errors.toString())), labels => editsStore - .setAdd(id, Edits.Labels, labels) + .setAddV2(id, Edits.Labels, labels) .map(publish(id, UpdateImageUserMetadata)) .map(edits => labelsCollection(id, edits.labels.toSet)) .map { case (uri, l) => respondCollection(l) } recover { @@ -136,16 +140,18 @@ class EditsController( ) } - def removeLabel(id: String, label: String) = auth.async { - editsStore.setDelete(id, Edits.Labels, decodeUriParam(label)) + def removeLabel(id: String, label: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) + editsStore.setDeleteV2(id, Edits.Labels, decodeUriParam(label)) .map(publish(id, UpdateImageUserMetadata)) .map(edits => labelsCollection(id, edits.labels.toSet)) .map {case (uri, labels) => respondCollection(labels, uri=Some(uri))} } - def getMetadata(id: String) = auth.async { - editsStore.jsonGet(id, Edits.Metadata).map { dynamoEntry => + def getMetadata(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) + editsStore.getV2(id).map { dynamoEntry => val metadata = (dynamoEntry \ Edits.Metadata).as[ImageMetadata] respond(metadata) } recover { @@ -154,6 +160,7 @@ class EditsController( } def setMetadata(id: String) = (auth andThen authorisedForEditMetadataOrUploader(id)).async(parse.json) { req => + implicit val instance: Instance = instanceOf(req) (req.body \ "data").validate[ImageMetadata].fold( errors => Future.successful(BadRequest(errors.toString())), metadata => { @@ -168,15 +175,16 @@ class EditsController( }) val validatedMetadata = metadata.copy(domainMetadata = validatedDomainMetadata) - editsStore.jsonAdd(id, Edits.Metadata, metadataAsMap(validatedMetadata)) + editsStore.jsonAddV2(id, Edits.Metadata, metadataAsMap(validatedMetadata)) .map(publish(id, UpdateImageUserMetadata)) .map(edits => respond(edits.metadata)) } ) } - def setMetadataFromUsageRights(id: String) = (auth andThen authorisedForEditMetadataOrUploader(id)).async { req => - editsStore.get(id) flatMap { dynamoEntry => + def setMetadataFromUsageRights(id: String) = (auth andThen authorisedForEditMetadataOrUploader(id)).async { implicit req => + implicit val instance: Instance = instanceOf(req) + editsStore.getV2(id) flatMap { dynamoEntry => gridClient.getMetadata(id, auth.getOnBehalfOfPrincipal(req.user)) flatMap { imageMetadata => val edits = dynamoEntry.as[Edits] val originalUserMetadata = edits.metadata @@ -191,7 +199,7 @@ class EditsController( imageType = metadata.imageType orElse originalUserMetadata.imageType ) - editsStore.jsonAdd(id, Edits.Metadata, metadataAsMap(mergedMetadata)) + editsStore.jsonAddV2(id, Edits.Metadata, metadataAsMap(mergedMetadata)) .map(publish(id, UpdateImageUserMetadata)) .map(edits => respond(edits.metadata, uri = Some(metadataUri(id)))) } getOrElse { @@ -200,35 +208,43 @@ class EditsController( } } } recover { + // TODO think more about this - image may exist but not have been edited?? case NoItemFound => respondError(NotFound, "item-not-found", "Could not find image") } } - def getUsageRights(id: String) = auth.async { - editsStore.jsonGet(id, Edits.UsageRights).map { dynamoEntry => - val usageRights = (dynamoEntry \ Edits.UsageRights).as[UsageRights] - respond(usageRights) + def getUsageRights(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) + editsStore.getV2(id).map { dynamoEntry => + val mayBeUsageRights = (dynamoEntry \ Edits.UsageRights).toOption.map(_.as[UsageRights]) + mayBeUsageRights match { + case Some(usageRights: UsageRights) => respond(usageRights) + case None => respondNotFound("No usage rights overrides found") + } } recover { case NoItemFound => respondNotFound("No usage rights overrides found") } } def setUsageRights(id: String) = auth.async(parse.json) { req => + implicit val instance: Instance = instanceOf(req) (req.body \ "data").asOpt[UsageRights].map(usageRight => { - editsStore.jsonAdd(id, Edits.UsageRights, DynamoDB.caseClassToMap(usageRight)) + editsStore.jsonAddV2(id, Edits.UsageRights, DynamoDB.caseClassToMap(usageRight)) .map(publish(id, UpdateImageUserMetadata)) .map(_ => respond(usageRight)) }).getOrElse(Future.successful(respondError(BadRequest, "invalid-form-data", "Invalid form data"))) } + //TODO - remove as a second step def deleteUsageRights(id: String) = auth.async { req => - editsStore.removeKey(id, Edits.UsageRights).map(publish(id, UpdateImageUserMetadata)).map(edits => Accepted) + implicit val instance: Instance = instanceOf(req) + editsStore.removeKeyV2(id, Edits.UsageRights).map(publish(id, UpdateImageUserMetadata)).map(edits => Accepted) } - def labelsCollection(id: String, labels: Set[String]): (URI, Seq[EmbeddedEntity[String]]) = + private def labelsCollection(id: String, labels: Set[String])(implicit instance: Instance): (URI, Seq[EmbeddedEntity[String]]) = (labelsUri(id), labels.map(setUnitEntity(id, Edits.Labels, _)).toSeq) - def metadataAsMap(metadata: ImageMetadata) = { + private def metadataAsMap(metadata: ImageMetadata) = { (Json.toJson(metadata).as[JsObject]).as[Map[String, JsValue]] } diff --git a/metadata-editor/app/controllers/SyndicationController.scala b/metadata-editor/app/controllers/SyndicationController.scala index c2fcb66a8f8..6cddefac7fa 100644 --- a/metadata-editor/app/controllers/SyndicationController.scala +++ b/metadata-editor/app/controllers/SyndicationController.scala @@ -2,13 +2,13 @@ package controllers import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.auth.Authentication -import com.gu.mediaservice.lib.aws.{DynamoDB, NoItemFound} -import play.api.mvc.{Action, AnyContent} +import com.gu.mediaservice.lib.aws.NoItemFound +import com.gu.mediaservice.lib.config.InstanceForRequest import com.gu.mediaservice.model._ import com.gu.mediaservice.syntax.MessageSubjects import lib._ import play.api.libs.json._ -import play.api.mvc.{BaseController, ControllerComponents} +import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents} import scala.concurrent.{ExecutionContext, Future} @@ -19,12 +19,13 @@ class SyndicationController(auth: Authentication, val notifications: Notifications, val config: EditsConfig, override val controllerComponents: ControllerComponents)(implicit val ec: ExecutionContext) - extends BaseController with Syndication with MessageSubjects with ArgoHelpers with EditsResponse { + extends BaseController with Syndication with MessageSubjects with ArgoHelpers with EditsResponse with InstanceForRequest { - override val metadataBaseUri: String = config.services.metadataBaseUri + override val metadataBaseUri: Instance => String = config.services.metadataBaseUri - def getPhotoshoot(id: String) = auth.async { - editsStore.jsonGet(id, Edits.Photoshoot).map(dynamoEntry => { + def getPhotoshoot(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) + editsStore.getV2(id).map(dynamoEntry => { (dynamoEntry \ Edits.Photoshoot).toOption match { case Some(photoshoot) => respond(photoshoot.as[Photoshoot]) case None => respondNotFound("No photoshoot found") @@ -34,7 +35,8 @@ class SyndicationController(auth: Authentication, } } - def setPhotoshoot(id: String) = auth.async(parse.json) { req => { + def setPhotoshoot(id: String) = auth.async(parse.json) { req => + implicit val instance: Instance = instanceOf(req) (req.body \ "data").asOpt[Photoshoot].map(photoshoot => setPhotoshootAndPublish(id, photoshoot) .map(photoshoot => respond(photoshoot)) @@ -42,29 +44,33 @@ class SyndicationController(auth: Authentication, .getOrElse( Future.successful(respondError(BadRequest, "invalid-form-data", "Invalid form data")) ) - }} + } - def deletePhotoshoot(id: String) = auth.async { + def deletePhotoshoot(id: String) = auth.async { request => + implicit val instance: Instance = instanceOf(request) deletePhotoshootAndPublish(id).map(_ => Accepted) } - def getSyndication(id: String): Action[AnyContent] = auth.async { + def getSyndication(id: String): Action[AnyContent] = auth.async { request => + implicit val instance: Instance = instanceOf(request) getSyndicationForImage(id) - .map { - case Some(rights) => respond(rights) - // If no rights, send a 404 - no syndication rights for this id. Either id is duff, or it really has none. - case None => NotFound - } + .map { + case Some(rights) => respond(rights) + // If no rights, send a 404 - no syndication rights for this id. Either id is duff, or it really has none. + case None => NotFound + } } def setSyndication(id: String): Action[JsValue] = auth.async(parse.json) { req => { + implicit val instance: Instance = instanceOf(req) (req.body \ "data").asOpt[SyndicationRights].map(syndicationRight => { setSyndicationAndPublish(id, syndicationRight) .map(syndicationRight => respond(syndicationRight)) }).getOrElse(Future.successful(respondError(BadRequest, "invalid-form-data", "Invalid form data"))) }} - def deleteSyndication(id: String): Action[AnyContent] = auth.async { + def deleteSyndication(id: String): Action[AnyContent] = auth.async { request => + implicit val instance: Instance = instanceOf(request) deleteSyndicationAndPublish(id).map(_ => Accepted) } diff --git a/metadata-editor/app/lib/Edit.scala b/metadata-editor/app/lib/Edit.scala index 6bc948a7718..8b60017e54b 100644 --- a/metadata-editor/app/lib/Edit.scala +++ b/metadata-editor/app/lib/Edit.scala @@ -1,7 +1,7 @@ package lib import com.gu.mediaservice.lib.aws.UpdateMessage -import com.gu.mediaservice.model.Edits +import com.gu.mediaservice.model.{Edits, Instance} import com.gu.mediaservice.syntax.MessageSubjects import play.api.libs.json.JsObject @@ -11,9 +11,9 @@ trait Edit extends MessageSubjects { def editsStore: EditsStore def notifications: Notifications - def publish(id: String, subject: String)(metadata: JsObject): Edits = { + def publish(id: String, subject: String)(metadata: JsObject)(implicit instance: Instance): Edits = { val edits = metadata.as[Edits] - val updateMessage = UpdateMessage(subject = subject, id = Some(id), edits = Some(edits)) + val updateMessage = UpdateMessage(subject = subject, id = Some(id), edits = Some(edits), instance = instance) notifications.publish(updateMessage) edits } diff --git a/metadata-editor/app/lib/EditsConfig.scala b/metadata-editor/app/lib/EditsConfig.scala index 41dcfe25121..1ab76ef6d90 100644 --- a/metadata-editor/app/lib/EditsConfig.scala +++ b/metadata-editor/app/lib/EditsConfig.scala @@ -2,6 +2,8 @@ package lib import com.gu.mediaservice.lib.config.{CommonConfig, GridConfigResources} import model.UsageRightsLease +import com.gu.mediaservice.model.Instance + class EditsConfig(resources: GridConfigResources) extends CommonConfig(resources) { val editsTable = string("dynamo.table.edits") @@ -10,9 +12,9 @@ class EditsConfig(resources: GridConfigResources) extends CommonConfig(resources val queueUrl = string("indexed.images.sqs.queue.url") - val rootUri: String = services.metadataBaseUri - val kahunaUri: String = services.kahunaBaseUri - val loginUriTemplate: String = services.loginUriTemplate + val rootUri: Instance => String = services.metadataBaseUri + val kahunaUri: Instance => String = services.kahunaBaseUri + val loginUriTemplate: Instance => String = services.loginUriTemplate val usageRightsLeases: Seq[UsageRightsLease] = configuration.getOptional[Seq[UsageRightsLease]]("usageRights.leases").getOrElse(Seq.empty) diff --git a/metadata-editor/app/lib/EditsStore.scala b/metadata-editor/app/lib/EditsStore.scala index dfeffbfe991..1b61e2b1f2a 100644 --- a/metadata-editor/app/lib/EditsStore.scala +++ b/metadata-editor/app/lib/EditsStore.scala @@ -1,6 +1,7 @@ package lib -import com.gu.mediaservice.lib.aws.DynamoDB +import com.gu.mediaservice.lib.aws.InstanceAwareDynamoDB import com.gu.mediaservice.model.Edits +import software.amazon.awssdk.services.dynamodb.DynamoDbClient -class EditsStore(config: EditsConfig) extends DynamoDB[Edits](config, config.editsTable, Some(Edits.LastModified)) +class EditsStore(client2: DynamoDbClient, tableName: String) extends InstanceAwareDynamoDB[Edits](client2, tableName, Some(Edits.LastModified)) diff --git a/metadata-editor/app/lib/MetadataSqsMessageConsumer.scala b/metadata-editor/app/lib/MetadataSqsMessageConsumer.scala index 3dc414102fb..e566ba65a56 100644 --- a/metadata-editor/app/lib/MetadataSqsMessageConsumer.scala +++ b/metadata-editor/app/lib/MetadataSqsMessageConsumer.scala @@ -1,13 +1,15 @@ package lib import com.gu.mediaservice.lib.aws.SqsViaSnsMessageConsumer +import com.gu.mediaservice.lib.logging.GridLogging +import com.gu.mediaservice.model.Instance import play.api.libs.json._ import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future class MetadataSqsMessageConsumer(config: EditsConfig, metadataEditorMetrics: MetadataEditorMetrics, store: EditsStore) extends SqsViaSnsMessageConsumer( - config.queueUrl, config, metadataEditorMetrics.snsMessage) { + config.queueUrl, config, metadataEditorMetrics.snsMessage) with GridLogging { override def chooseProcessor(subject: String): Option[JsValue => Future[Any]] = PartialFunction.condOpt(subject) { @@ -15,6 +17,12 @@ class MetadataSqsMessageConsumer(config: EditsConfig, metadataEditorMetrics: Met } def processDeletedImage(message: JsValue) = Future { - withImageId(message)(id => store.deleteItem(id)) + def deleteInstanceImage(id: String, instance: String) = { + implicit val i: Instance = Instance(id = instance) + logger.info(s"Got image-deleted message for id $id in instance $instance") + store.deleteItemV2(id) + } + + withImageIdAndInstance(message)(deleteInstanceImage) } } diff --git a/metadata-editor/app/lib/Syndication.scala b/metadata-editor/app/lib/Syndication.scala index 38f484aea3b..a38cdfb8d94 100644 --- a/metadata-editor/app/lib/Syndication.scala +++ b/metadata-editor/app/lib/Syndication.scala @@ -2,10 +2,10 @@ package lib import com.gu.mediaservice.lib.aws.{DynamoDB, NoItemFound, UpdateMessage} import com.gu.mediaservice.lib.logging.GridLogging -import com.gu.mediaservice.model.{Edits, Photoshoot, SyndicationRights} +import com.gu.mediaservice.model.{Edits, Instance, Photoshoot, SyndicationRights} import com.gu.mediaservice.syntax.MessageSubjects import org.joda.time.DateTime -import play.api.libs.json.{JsNull, JsString, Reads} +import play.api.libs.json.{JsNull, Reads} import scala.concurrent.{ExecutionContext, Future} @@ -26,7 +26,7 @@ trait Syndication extends Edit with MessageSubjects with GridLogging { } private[lib] def publishChangedSyndicationRightsForPhotoshoot[T](id: String, unchangedPhotoshoot: Boolean = false, photoshoot: Option[Photoshoot] = None)(f: () => Future[T]) - (implicit ec: ExecutionContext): Future[T] = + (implicit ec: ExecutionContext, instance: Instance): Future[T] = for { oldPhotoshootMaybe <- getPhotoshootForImage(id) newPhotoshootMaybe = if (unchangedPhotoshoot) None else photoshoot // if None, the get rights calls (below) will return none. @@ -45,47 +45,47 @@ trait Syndication extends Edit with MessageSubjects with GridLogging { } def deletePhotoshootAndPublish(id: String) - (implicit ec: ExecutionContext): Future[Unit] = + (implicit ec: ExecutionContext, instance: Instance): Future[Unit] = publishChangedSyndicationRightsForPhotoshoot[Unit](id, unchangedPhotoshoot = false) { () => for { - edits <- editsStore.removeKey(id, Edits.Photoshoot) - _ <- editsStore.removeKey(id, Edits.PhotoshootTitle) + edits <- editsStore.removeKeyV2(id, Edits.Photoshoot) + _ <- editsStore.removeKeyV2(id, Edits.PhotoshootTitle) _ = publish(id, UpdateImagePhotoshootMetadata)(edits) } yield () } def setPhotoshootAndPublish(id: String, newPhotoshoot: Photoshoot) - (implicit ec: ExecutionContext): Future[Photoshoot] = { - publishChangedSyndicationRightsForPhotoshoot[Photoshoot](id, photoshoot = Some(newPhotoshoot)) { () => + (implicit ec: ExecutionContext, instance: Instance): Future[Photoshoot] = { + //publishChangedSyndicationRightsForPhotoshoot[Photoshoot](id, photoshoot = Some(newPhotoshoot)) { () => for { - editsAsJsonResponse <- editsStore.jsonAdd(id, Edits.Photoshoot, DynamoDB.caseClassToMap(newPhotoshoot)) - _ <- editsStore.stringSet(id, Edits.PhotoshootTitle, JsString(newPhotoshoot.title)) // store - don't care about return + editsAsJsonResponse <- editsStore.jsonAddV2(id, Edits.Photoshoot, DynamoDB.caseClassToMap(newPhotoshoot)) + _ <- editsStore.stringSetV2(id, Edits.PhotoshootTitle, newPhotoshoot.title) // store - don't care about return _ = publish(id, UpdateImagePhotoshootMetadata)(editsAsJsonResponse) } yield newPhotoshoot - } + //} } def deleteSyndicationAndPublish(id: String) - (implicit ec: ExecutionContext): Future[Unit] = { + (implicit ec: ExecutionContext, instance: Instance): Future[Unit] = { publishChangedSyndicationRightsForPhotoshoot[Unit](id, unchangedPhotoshoot = true) { () => - syndicationStore.deleteItem(id) + syndicationStore.deleteItemV2(id) // Always publish, in case there is no photoshoot publish(Map(id -> None), UpdateImageSyndicationMetadata) } } def setSyndicationAndPublish(id: String, syndicationRight: SyndicationRights) - (implicit ec: ExecutionContext): Future[SyndicationRights] = + (implicit ec: ExecutionContext, instance: Instance): Future[SyndicationRights] = publishChangedSyndicationRightsForPhotoshoot[SyndicationRights](id, unchangedPhotoshoot = true) { () => - val result = syndicationStore.jsonAdd (id, syndicationRightsFieldName, DynamoDB.caseClassToMap (syndicationRight)).map(_=>syndicationRight) + val result = syndicationStore.jsonAddV2(id, syndicationRightsFieldName, DynamoDB.caseClassToMap (syndicationRight)).map(_=>syndicationRight) // Always publish, in case there is no photoshoot publish(Map(id -> Some(syndicationRight)), UpdateImageSyndicationMetadata) result } def getSyndicationForImage(id: String) - (implicit ec: ExecutionContext): Future[Option[SyndicationRights]] = { - syndicationStore.jsonGet(id, syndicationRightsFieldName) + (implicit ec: ExecutionContext, instance: Instance): Future[Option[SyndicationRights]] = { + syndicationStore.getV2(id) // It's OK for the image to _not_ exist in the syndication store, so this needs to be recovered .recover { case NoItemFound => JsNull } .flatMap(dynamoEntry => (dynamoEntry \ syndicationRightsFieldName).toOption match { @@ -97,7 +97,7 @@ trait Syndication extends Edit with MessageSubjects with GridLogging { } private def getRightsByPhotoshoot(id: String) - (implicit ec: ExecutionContext): Future[Option[SyndicationRights]] = + (implicit ec: ExecutionContext, instance: Instance): Future[Option[SyndicationRights]] = getPhotoshootForImage(id) // It's ok for the image to _not_ exist in the edits store - it may have no photoshoot (or any other edit) .recover { case NoItemFound => None } @@ -128,14 +128,14 @@ trait Syndication extends Edit with MessageSubjects with GridLogging { .filter(_.published.nonEmpty).sortBy(_.published.map(-_.getMillis)).headOption.map(r => r.copy(isInferred = true)) def getAllImageRightsInPhotoshoot(photoshootMaybe: Option[Photoshoot]) - (implicit ec: ExecutionContext): Future[Map[String, SyndicationRights]] = + (implicit ec: ExecutionContext, instance: Instance): Future[Map[String, SyndicationRights]] = photoshootMaybe.map(photoshoot => getAllImageRightsInPhotoshoot(photoshoot)) .getOrElse(Future.successful(Map.empty[String, SyndicationRights])) - def getAllImageRightsInPhotoshoot(photoshoot: Photoshoot) - (implicit ec: ExecutionContext): Future[Map[String, SyndicationRights]] = for { + private def getAllImageRightsInPhotoshoot(photoshoot: Photoshoot) + (implicit ec: ExecutionContext, instance: Instance): Future[Map[String, SyndicationRights]] = for { imageIds <- getImagesInPhotoshoot(photoshoot) - allNonInferredRights <- syndicationStore.batchGet(imageIds, syndicationRightsFieldName) + allNonInferredRights <- syndicationStore.batchGetV2(imageIds, syndicationRightsFieldName) } yield { logger.info(s"Found non-inferred rights for ${allNonInferredRights.size} of ${imageIds.size} images in photoshoot ${photoshoot.title}") val mostRecentInferrableRightsMaybe = getMostRecentInferrableSyndicationRights(allNonInferredRights.values.toList) @@ -143,8 +143,8 @@ trait Syndication extends Edit with MessageSubjects with GridLogging { } def getImagesInPhotoshoot(photoshoot: Photoshoot) - (implicit ec: ExecutionContext): Future[List[String]] = - editsStore.scanForId(config.editsTablePhotoshootIndex, Edits.PhotoshootTitle, photoshoot.title) + (implicit ec: ExecutionContext, instance: Instance): Future[List[String]] = + editsStore.scanForIdV2(config.editsTablePhotoshootIndex, Edits.PhotoshootTitle, photoshoot.title) .recover { case NoItemFound => Nil } def getChangedRights(before: Map[String, SyndicationRights], after: Map[String, SyndicationRights]): Map[String, Option[SyndicationRights]] = { @@ -155,18 +155,18 @@ trait Syndication extends Edit with MessageSubjects with GridLogging { } def getPhotoshootForImage(id: String) - (implicit ec: ExecutionContext): Future[Option[Photoshoot]] = - editsStore.jsonGet(id, Edits.Photoshoot) + (implicit ec: ExecutionContext, instance: Instance): Future[Option[Photoshoot]] = + editsStore.getV2(id) .map(dynamoEntry => (dynamoEntry \ Edits.Photoshoot).toOption map { photoshootJson => photoshootJson.as[Photoshoot] }) .recover { case NoItemFound => None } def publish(imagesInPhotoshoot: Map[String, Option[SyndicationRights]], subject: String) - (implicit ec: ExecutionContext): Future[Unit] = Future { + (implicit ec: ExecutionContext, instance: Instance): Future[Unit] = Future { for (kv <- imagesInPhotoshoot) { val (k, v) = kv - val updateMessage = UpdateMessage(subject = subject, id = Some(k), syndicationRights = v) + val updateMessage = UpdateMessage(subject = subject, id = Some(k), syndicationRights = v, instance = instance) notifications.publish(updateMessage) } } diff --git a/metadata-editor/app/lib/SyndicationStore.scala b/metadata-editor/app/lib/SyndicationStore.scala index 0c2f9d9b57f..30594fa1200 100644 --- a/metadata-editor/app/lib/SyndicationStore.scala +++ b/metadata-editor/app/lib/SyndicationStore.scala @@ -1,6 +1,8 @@ package lib -import com.gu.mediaservice.lib.aws.DynamoDB +import com.gu.mediaservice.lib.aws.InstanceAwareDynamoDB import com.gu.mediaservice.model.SyndicationRights +import software.amazon.awssdk.services.dynamodb.DynamoDbClient -class SyndicationStore(config: EditsConfig) extends DynamoDB[SyndicationRights](config, config.syndicationTable) +class SyndicationStore(client2: DynamoDbClient, tableName: String) + extends InstanceAwareDynamoDB[SyndicationRights](client2, tableName) diff --git a/metadata-editor/conf/routes b/metadata-editor/conf/routes index 0b12d2f0e02..5aab6e425f8 100644 --- a/metadata-editor/conf/routes +++ b/metadata-editor/conf/routes @@ -33,7 +33,6 @@ DELETE /metadata/:id/syndication controllers.SyndicationC # Management GET /management/healthcheck com.gu.mediaservice.lib.management.Management.healthCheck GET /management/manifest com.gu.mediaservice.lib.management.Management.manifest -GET /management/whoAmI com.gu.mediaservice.lib.management.InnerServiceStatusCheckController.whoAmI(depth: Int) # Shoo robots away GET /robots.txt com.gu.mediaservice.lib.management.Management.disallowRobots diff --git a/metadata-editor/test/lib/EditsStoreTest.scala b/metadata-editor/test/lib/EditsStoreTest.scala new file mode 100644 index 00000000000..64aef596d0d --- /dev/null +++ b/metadata-editor/test/lib/EditsStoreTest.scala @@ -0,0 +1,325 @@ +package lib + +import com.gu.mediaservice.model.{Edits, ImageMetadata, Instance} +import org.scalatest.BeforeAndAfterAll +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.time.{Millis, Seconds, Span} +import org.testcontainers.containers.localstack.LocalStackContainer +import org.testcontainers.containers.localstack.LocalStackContainer.Service.DYNAMODB +import org.testcontainers.utility.DockerImageName +import software.amazon.awssdk.auth.credentials.{AwsBasicCredentials, StaticCredentialsProvider} +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.dynamodb.DynamoDbClient +import software.amazon.awssdk.services.dynamodb.model._ + +import java.util.UUID +import scala.concurrent.ExecutionContext.Implicits.global +import scala.jdk.CollectionConverters._ + +class EditsStoreTest extends AnyFunSpec with Matchers with ScalaFutures with BeforeAndAfterAll { + + implicit val defaultPatience: PatienceConfig = PatienceConfig(timeout = Span(5, Seconds), interval = Span(100, Millis)) + + private val dynamoContainer = new LocalStackContainer(DockerImageName.parse("localstack/localstack:1.4.0")).withServices(DYNAMODB) + dynamoContainer.start() + + val testTableName: String = "test-edits-table-" + UUID.randomUUID().toString + + private val dynamoClient2: DynamoDbClient = DynamoDbClient.builder(). + endpointOverride(dynamoContainer.getEndpointOverride(DYNAMODB)). + region(Region.of(dynamoContainer.getRegion)). + credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(dynamoContainer.getAccessKey, dynamoContainer.getSecretKey))).build() + + private val store = new EditsStore(dynamoClient2, testTableName) { + } + + override def beforeAll(): Unit = { + def createTableRequestFor(tableName: String): CreateTableRequest = { + val attributeDefinitions = List( + AttributeDefinition.builder.attributeName("instance").attributeType(ScalarAttributeType.S).build(), + AttributeDefinition.builder.attributeName("id").attributeType(ScalarAttributeType.S).build() + ) + val keySchema = List( + KeySchemaElement.builder.attributeName("instance").keyType(KeyType.HASH).build(), + KeySchemaElement.builder.attributeName("id").keyType(KeyType.RANGE).build() + ) + val provisionedThroughput = ProvisionedThroughput.builder.readCapacityUnits(1L).writeCapacityUnits(1L).build() + val request = CreateTableRequest.builder + .tableName(tableName) + .attributeDefinitions(attributeDefinitions.asJava) + .keySchema(keySchema.asJava) + .provisionedThroughput(provisionedThroughput) + .build() + request + } + + dynamoClient2.createTable(createTableRequestFor(testTableName)) + } + + override def afterAll(): Unit = { + super.afterAll() + dynamoContainer.stop() + } + + describe("EditsStore") { + implicit val instance: Instance = Instance("an-instance") + + it("should fail with NoItemFound for a non-existent item in getV2") { + val imageId = "non-existent-image" + + val eventualResult = store.getV2(imageId) + + whenReady(eventualResult.failed) { exception => + exception shouldBe com.gu.mediaservice.lib.aws.NoItemFound + } + } + + it("should persist a boolean property using V2") { + val imageId = "test-image-for-boolean-get-v2" + + val eventualResult = store.booleanSetV2(imageId, Edits.Archived, value = true).flatMap { _ => + store.booleanGetV2(imageId, Edits.Archived) + } + + whenReady(eventualResult) { result => + result should be(true) + } + } + + it("should fail with NoItemFound for a non-existent attribute in booleanGetV2") { + val imageId = "test-image-for-boolean-get-v2-non-existent-attribute" + + // First, create the item so it exists + store.booleanSetV2(imageId, Edits.Archived, value = true).futureValue + + val eventualResult = store.booleanGetV2(imageId, "another-existent-attribute") + + whenReady(eventualResult.failed) { exception => + exception shouldBe com.gu.mediaservice.lib.aws.NoItemFound + } + } + + it("should get a previously persisted property using V2") { + val imageId = "test-image-for-set-get-v2" + val labels = List("label1", "label2") + val eventualResult = store.setAddV2(imageId, Edits.Labels, labels).flatMap { _ => + store.setGetV2(imageId, Edits.Labels) + } + + whenReady(eventualResult) { result => + result should be(labels.toSet) + } + } + + describe("setAddV2") { + it("should add a value to a non-existent set, creating it") { + val imageId = "test-image-for-set-add-non-existent" + val label = "label1" + + val eventualResult = store.setAddV2(imageId, Edits.Labels, List(label)).flatMap { _ => + store.setGetV2(imageId, Edits.Labels) + } + + whenReady(eventualResult) { result => + result should be(Set(label)) + } + } + + it("should append a value to an existing set") { + val imageId = "test-image-for-set-add-existing" + val labels = List("label1", "label2") + val newLabel = "label3" + + val eventualResult = for { + _ <- store.setAddV2(imageId, Edits.Labels, labels) + _ <- store.setAddV2(imageId, Edits.Labels, List(newLabel)) + result <- store.setGetV2(imageId, Edits.Labels) + } yield result + + whenReady(eventualResult) { result => + result should be(Set("label1", "label2", "label3")) + } + } + + it("should not add a duplicate value to a set") { + val imageId = "test-image-for-set-add-duplicate" + val label = "label1" + + val eventualResult = for { + _ <- store.setAddV2(imageId, Edits.Labels, List(label)) + _ <- store.setAddV2(imageId, Edits.Labels, List(label)) + result <- store.setGetV2(imageId, Edits.Labels) + } yield result + + whenReady(eventualResult) { result => + result should be(Set(label)) + } + } + } + + describe("booleanSetOrRemoveV2") { + it("should set a boolean property to true using V2") { + val imageId = "test-image-for-boolean-set-or-remove-v2-set" + val key = "testBoolean" + + val eventualResult = for { + _ <- store.booleanSetOrRemoveV2(imageId, key, value = true) + result <- store.booleanGetV2(imageId, key) + } yield result + + whenReady(eventualResult) { result => + result should be(true) + } + } + + it("should remove a boolean property when setting to false using V2") { + val imageId = "test-image-for-boolean-set-or-remove-v2-remove" + val key = "testBoolean" + + val setup = for { + _ <- store.booleanSetOrRemoveV2(imageId, key, value = true) + _ <- store.booleanSetOrRemoveV2(imageId, key, value = false) + result <- store.booleanGetV2(imageId, key).failed + } yield result + + whenReady(setup) { exception => + exception shouldBe com.gu.mediaservice.lib.aws.NoItemFound + } + } + } + + describe("removeKey") { + it("should remove a key from an existing item") { + val imageId = "test-image-for-remove-key" + val labels = List("label1", "label2") + val archived = true + + val eventualResult = for { + _ <- store.setAddV2(imageId, Edits.Labels, labels) + _ <- store.booleanSetV2(imageId, Edits.Archived, archived) + _ <- store.removeKeyV2(imageId, Edits.Labels) + result <- store.getV2(imageId) + } yield result + + whenReady(eventualResult) { result => + val edits = result.as[Edits] + edits.archived should be(archived) + edits.labels should be(empty) + } + } + + it("should not fail when removing a non-existent key from an existing item") { + val imageId = "test-image-for-remove-non-existent-key" + val labels = List("label1", "label2") + + val eventualResult = for { + _ <- store.setAddV2(imageId, Edits.Labels, labels) + _ <- store.removeKeyV2(imageId, Edits.Archived) + result <- store.getV2(imageId) + } yield result + + whenReady(eventualResult) { result => + val edits = result.as[Edits] + edits.labels.toSet should be(labels.toSet) + } + } + + it("will create an item when removing a key from a non-existent item") { + // TODO should this really be happening? + val imageId = "non-existent-image-for-remove-key" + + val eventualResult = for { + _ <- store.removeKeyV2(imageId, Edits.Labels) + result <- store.getV2(imageId) + } yield result + + whenReady(eventualResult) { result => + val edits = result.as[Edits] + edits.labels should be(empty) + edits.metadata should be(ImageMetadata.empty) + } + } + } + + describe("setDelete") { + it("should delete an item from a set") { + val imageId = "test-image-for-set-delete" + val labels = List("label1", "label2", "label3") + val labelToDelete = "label2" + + val eventualResult = for { + _ <- store.setAddV2(imageId, Edits.Labels, labels) + _ <- store.setDeleteV2(imageId, Edits.Labels, labelToDelete) + result <- store.setGetV2(imageId, Edits.Labels) + } yield result + + whenReady(eventualResult) { result => + result should be(Set("label1", "label3")) + } + } + + it("should leave an empty set when deleting the last item from a set") { + val imageId = "test-image-for-set-delete-last-item" + val label = "label1" + + val eventualResult = for { + _ <- store.setAddV2(imageId, Edits.Labels, List(label)) + _ <- store.setDeleteV2(imageId, Edits.Labels, label) + result <- store.getV2(imageId) + } yield result + + whenReady(eventualResult) { result => + // After deleting the last item, the key (labels) should be removed. + // leaving an empty set rather than a missing key + val edits = result.as[Edits] + edits.labels should be(empty) + } + } + + it("should not fail when deleting a non-existent item from a set") { + val imageId = "test-image-for-set-delete-non-existent-item" + val labels = List("label1", "label2") + val labelToDelete = "label3" + + val eventualResult = for { + _ <- store.setAddV2(imageId, Edits.Labels, labels) + _ <- store.setDeleteV2(imageId, Edits.Labels, labelToDelete) + result <- store.setGetV2(imageId, Edits.Labels) + } yield result + + whenReady(eventualResult) { result => + result should be(Set("label1", "label2")) + } + } + } + + describe("deleteItemV2") { + it("should delete an existing item") { + val imageId = "test-image-for-delete-item-v2" + val labels = List("label1", "label2") + + val eventualResult = for { + _ <- store.setAddV2(imageId, Edits.Labels, labels) + _ <- store.deleteItemV2(imageId) + result <- store.getV2(imageId).failed + } yield result + + whenReady(eventualResult) { exception => + exception shouldBe com.gu.mediaservice.lib.aws.NoItemFound + } + } + + it("should not fail when deleting a non-existent item") { + val imageId = "non-existent-image-for-delete-item-v2" + + val eventualResult = store.deleteItemV2(imageId) + + whenReady(eventualResult) { result => + result should be(()) + } + } + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000000..fb49cefeab8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "grid", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/project/build.properties b/project/build.properties index bc7390601f4..01a16ed1465 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.10.3 +sbt.version=1.11.7 diff --git a/project/plugins.sbt b/project/plugins.sbt index 3f3177253fc..f9051484823 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,25 +1,7 @@ -libraryDependencies += "org.vafer" % "jdeb" % "1.3" artifacts (Artifact("jdeb", "jar", "jar")) - -addSbtPlugin("org.playframework" % "sbt-plugin" % "3.0.5") +addSbtPlugin("org.playframework" % "sbt-plugin" % "3.0.10") addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.9.0") addSbtPlugin("com.typesafe.sbt" % "sbt-digest" % "1.1.4") addSbtPlugin("com.typesafe.sbt" % "sbt-gzip" % "1.0.2") - -/* - Without setting VersionScheme.Always here on `scala-xml`, sbt 1.8.0 will raise fatal 'version conflict' errors when - used with sbt plugins like `sbt-native-packager`, which currently use sort-of-incompatible versions of the `scala-xml` - library. sbt 1.8.0 has upgraded to Scala 2.12.17, which has itself upgraded to `scala-xml` 2.1.0 - (see https://github.com/sbt/sbt/releases/tag/v1.8.0), but `sbt-native-packager` is currently using `scala-xml` 1.1.1, - and the `scala-xml` library declares that it uses specifically 'early-semver' version compatibility (see - https://www.scala-lang.org/blog/2021/02/16/preventing-version-conflicts-with-versionscheme.html#versionscheme-librarydependencyschemes-and-sbt-150 ), - meaning that for version x.y.z, `x` & `y` *must match exactly* for versions to be considered compatible by sbt. - By setting VersionScheme.Always here on `scala-xml`, we're overriding its declared version-compatability scheme, - choosing to tolerate the risk of binary incompatibility. We consider this to be safe because when set under - `projects/` (ie *not* in `build.sbt` itself) it only affects the compilation of build.sbt, not of the application - build itself. Once the build has succeeded, there is no further risk (ie of a runtime exception due to clashing - versions of `scala-xml`). - */ -libraryDependencySchemes += "org.scala-lang.modules" %% "scala-xml" % VersionScheme.Always diff --git a/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/Authentication.scala b/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/Authentication.scala index 7681ded4ea1..c2ae28529b4 100644 --- a/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/Authentication.scala +++ b/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/Authentication.scala @@ -4,47 +4,51 @@ import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.argo.model.Link import com.gu.mediaservice.lib.auth.Authentication.{InnerServicePrincipal, MachinePrincipal, OnBehalfOfPrincipal, Principal, UserPrincipal} import com.gu.mediaservice.lib.auth.provider._ -import com.gu.mediaservice.lib.config.CommonConfig +import com.gu.mediaservice.lib.config.{CommonConfig, InstanceForRequest} +import com.gu.mediaservice.lib.instances.Instances import com.gu.mediaservice.lib.play.RequestLoggingFilter +import com.gu.mediaservice.model.Instance import play.api.libs.typedmap.TypedMap -import play.api.libs.ws.WSRequest +import play.api.libs.ws.{WSClient, WSRequest} import play.api.mvc.Security.AuthenticatedRequest import play.api.mvc._ import scala.concurrent.{ExecutionContext, Future} -class Authentication(config: CommonConfig, +class Authentication(val config: CommonConfig, providers: AuthenticationProviders, + val wsClient: WSClient, override val parser: BodyParser[AnyContent], override val executionContext: ExecutionContext) - extends ActionBuilder[Authentication.Request, AnyContent] with ArgoHelpers { + extends ActionBuilder[Authentication.Request, AnyContent] with ArgoHelpers with InstanceForRequest with Instances { // make the execution context implicit so it will be picked up appropriately implicit val ec: ExecutionContext = executionContext - val loginLinks: List[Link] = providers.userProvider.loginLink match { + def loginLinks()(implicit instance: Instance): List[Link] = providers.userProvider.loginLink match { case DisableLoginLink => Nil - case BuiltInAuthService => List(Link("login", config.services.loginUriTemplate)) + case BuiltInAuthService => List(Link("login", config.services.loginUriTemplate(instance))) case ExternalLoginLink(link) => List(Link("login", link)) } - def unauthorised(errorMessage: String, throwable: Option[Throwable] = None): Future[Result] = { + private def unauthorised(errorMessage: String, throwable: Option[Throwable] = None)(implicit instance: Instance): Future[Result] = { logger.info(s"Authentication failure $errorMessage", throwable.orNull) - Future.successful(respondError(Unauthorized, "authentication-failure", "Authentication failure", loginLinks)) + Future.successful(respondError(Unauthorized, "authentication-failure", "Authentication failure", loginLinks())) } - def forbidden(errorMessage: String): Future[Result] = { + def forbidden(errorMessage: String)(implicit instance: Instance): Future[Result] = { logger.info(s"User not authorised: $errorMessage") - Future.successful(respondError(Forbidden, "principal-not-authorised", "Principal not authorised", loginLinks)) + Future.successful(respondError(Forbidden, "principal-not-authorised", "Principal not authorised", loginLinks())) } - def expired(user: UserPrincipal): Future[Result] = { + def expired(user: UserPrincipal)(implicit instance: Instance): Future[Result] = { logger.info(s"User token expired for ${user.email}, return 419") - Future.successful(respondError(new Status(419), errorKey = "authentication-expired", errorMessage = "User authentication token has expired", loginLinks)) + Future.successful(respondError(new Status(419), errorKey = "authentication-expired", errorMessage = "User authentication token has expired", loginLinks())) } // gracePeriodCountsAsAuthenticated - if true, then users with valid but recently-expired cookies are considered authenticated, and not required to refresh session for this request def authenticationStatus(requestHeader: RequestHeader, gracePeriodCountsAsAuthenticated: Boolean): Either[Future[Result], Principal] = { + implicit val instance: Instance = instanceOf(requestHeader) def flushToken(resultWhenAbsent: Result): Result = { providers.userProvider.flushToken.fold(resultWhenAbsent)(_(requestHeader, resultWhenAbsent)) } @@ -76,10 +80,58 @@ class Authentication(config: CommonConfig, override def invokeBlock[A](request: Request[A], block: Authentication.Request[A] => Future[Result]): Future[Result] = { // gracePeriodCountsAsAuthenticated is set to true here, so requests using this block should accept users whose session is in the grace period authenticationStatus(request, gracePeriodCountsAsAuthenticated = true) match { - // we have a principal, so process the block - case Right(principal) => - block(new AuthenticatedRequest(principal, request)) - .map(result => result.addAttr(RequestLoggingFilter.requestPrincipal, principal)) + case Right(principal) => { + principal match { + case innerServicePrincipal: InnerServicePrincipal => + logger.info("Allowing InnerServicePrincipal request for all instances") + block(new AuthenticatedRequest(innerServicePrincipal, request)) + .map(result => result.addAttr(RequestLoggingFilter.requestPrincipal, principal)) + + case m: MachinePrincipal => + val instance = instanceOf(request) + val maybeApiKeyInstance = m.attributes.get(ApiKeyAuthenticationProvider.ApiKeyInstance) + logger.info("Api key instance: " + maybeApiKeyInstance) + maybeApiKeyInstance.map { apiKeyInstance => + // we have an end user principal, and a list of the instances they are allowed to access. + // Only process the block if the instance is allowed. + val isAllowedToAccessThisInstance = instance.id == apiKeyInstance + logger.debug(s"$principal is allowed to access instance ${instance.id}: $isAllowedToAccessThisInstance") + if (isAllowedToAccessThisInstance) { + logger.debug("Allowing this request!") + block(new AuthenticatedRequest(principal, request)) + + } else { + logger.warn(s"Blocking request ${request.path} on instance ${instance.id} for principal: " + principal) + Future.successful(Forbidden("You do not have permission to use this instance")) + } + }.getOrElse{ + logger.warn(s"Blocking request ${request.path} on instance ${instance.id} for principal: " + principal) + Future.successful(Forbidden("You do not have permission to use this instance")) + } + + case _ => + val instance = instanceOf(request) + principal.attributes.get(ApiKeyAuthenticationProvider.KindeIdKey).map { owner => + getMyInstances(owner).flatMap { principalsInstances => + // we have an end user principal, and a list of the instances they are allowed to access. + // Only process the block if the instance is allowed. + val isAllowedToAccessThisInstance = principalsInstances.exists(_.id == instance.id) + logger.debug(s"$principal is allowed to access instance ${instance.id}: $isAllowedToAccessThisInstance") + if (isAllowedToAccessThisInstance) { + logger.debug("Allowing this request!") + block(new AuthenticatedRequest(principal, request)) + + } else { + logger.warn(s"Blocking request ${request.path} on instance ${instance.id} for principal: " + principal) + Future.successful(Forbidden("You do not have permission to use this instance")) + } + } + }.getOrElse { + logger.warn(s"Blocking request ${request.path} on instance ${instance.id} for principal: " + principal) + Future.successful(Forbidden("You do not have permission to use this instance")) + } + } + } // no principal so return a result which will either be an error or a form of redirect case Left(result) => result } diff --git a/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/Authorisation.scala b/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/Authorisation.scala index c666b10d652..072046c8729 100644 --- a/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/Authorisation.scala +++ b/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/Authorisation.scala @@ -5,11 +5,13 @@ import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.auth.Authentication.{InnerServicePrincipal, MachinePrincipal, Principal, Request, UserPrincipal} import com.gu.mediaservice.lib.auth.Permissions.{ArchiveImages, DeleteCropsOrUsages, PrincipalFilter, UploadImages} import com.gu.mediaservice.lib.auth.provider.AuthorisationProvider +import com.gu.mediaservice.lib.config.InstanceForRequest +import com.gu.mediaservice.model.Instance import play.api.mvc.{ActionFilter, Filter, RequestHeader, Result, Results} import scala.concurrent.{ExecutionContext, Future} -class Authorisation(provider: AuthorisationProvider, executionContext: ExecutionContext) extends Results with ArgoHelpers { +class Authorisation(provider: AuthorisationProvider, executionContext: ExecutionContext) extends Results with ArgoHelpers with InstanceForRequest { def forbidden(permission: SimplePermission): Result = respondError(Forbidden, "permission-denied", s"You do not have permission to ${permission.name}") @@ -32,7 +34,7 @@ class Authorisation(provider: AuthorisationProvider, executionContext: Execution def actionFilterForUploaderOr( imageId: String, permission: SimplePermission, - getUploader: (String, Principal) => Future[Option[String]] + getUploader: (String, Principal, Instance) => Future[Option[String]] )(implicit ec: ExecutionContext): ActionFilter[Request] = new ActionFilter[Request] { // implicit val ec = executionContext override protected def filter[A](request: Request[A]): Future[Option[Result]] = { @@ -42,7 +44,7 @@ class Authorisation(provider: AuthorisationProvider, executionContext: Execution Future.successful(None) } else { val result = for { - uploadedBy <- getUploader(imageId, request.user) + uploadedBy <- getUploader(imageId, request.user, instanceOf(request)) isAuthorised = isUploaderOrHasPermission(request.user, uploadedBy.getOrElse(""), permission) if isAuthorised } yield { diff --git a/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/BaseControllerWithLoginRedirects.scala b/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/BaseControllerWithLoginRedirects.scala index 9adf467487b..6351c91af10 100644 --- a/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/BaseControllerWithLoginRedirects.scala +++ b/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/BaseControllerWithLoginRedirects.scala @@ -1,23 +1,25 @@ package com.gu.mediaservice.lib.auth -import com.gu.mediaservice.lib.config.Services +import com.gu.mediaservice.lib.config.{InstanceForRequest, Services} +import com.gu.mediaservice.model.Instance import play.api.mvc.Security.AuthenticatedRequest import play.api.mvc._ import java.net.URLEncoder import scala.concurrent.Future -trait BaseControllerWithLoginRedirects extends BaseController { +trait BaseControllerWithLoginRedirects extends BaseController with InstanceForRequest { def auth: Authentication def services: Services private def withLoginRedirectAsync(isLoginOptional: Boolean)(handler: Request[AnyContent] => Future[Result]): Action[AnyContent] = Action.async { request => + implicit val instance: Instance = instanceOf(request) // gracePeriodCountsAsAuthenticated=false here means that a user must have a fresh session (ie. not yet in grace period) if they turn up here to count as authenticated. // If login is optional then they may still be allowed access auth.authenticationStatus(request, gracePeriodCountsAsAuthenticated = false) match { case Right(principal) => handler(new AuthenticatedRequest(principal, request)) - case Left(resultFuture) => auth.loginLinks.headOption match { + case Left(resultFuture) => auth.loginLinks().headOption match { case None if isLoginOptional => handler(request) // if login is not strictly required, then still perform the action (just the user principal won't be available) case None => resultFuture // if login is strictly required, then return the auth failure result case Some(loginLink) => diff --git a/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/ApiKeyAuthenticationProvider.scala b/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/ApiKeyAuthenticationProvider.scala index 04a3292e309..da12325adb4 100644 --- a/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/ApiKeyAuthenticationProvider.scala +++ b/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/ApiKeyAuthenticationProvider.scala @@ -1,9 +1,13 @@ package com.gu.mediaservice.lib.auth.provider import com.gu.mediaservice.lib.auth.Authentication.{MachinePrincipal, Principal} +import com.gu.mediaservice.lib.auth.provider.ApiKeyAuthenticationProvider.ApiKeyInstance import com.gu.mediaservice.lib.auth.{ApiAccessor, KeyStore} +import com.gu.mediaservice.lib.aws.{S3, S3Bucket, S3Ops} +import com.gu.mediaservice.lib.config.InstanceForRequest +import com.gu.mediaservice.model.Instance import com.typesafe.scalalogging.StrictLogging import play.api.Configuration -import play.api.libs.typedmap.{TypedKey, TypedMap} +import play.api.libs.typedmap.{TypedEntry, TypedKey, TypedMap} import play.api.libs.ws.WSRequest import play.api.mvc.RequestHeader @@ -11,14 +15,20 @@ import scala.concurrent.{ExecutionContext, Future} object ApiKeyAuthenticationProvider extends ApiKeyAuthentication { val ApiKeyHeader: TypedKey[(String, String)] = TypedKey[(String, String)]("ApiKeyHeader") + val KindeIdKey: TypedKey[String] = TypedKey[String]("kinde_id") + val ApiKeyInstance: TypedKey[String] = TypedKey[String]("apikey_instance") } -class ApiKeyAuthenticationProvider(configuration: Configuration, resources: AuthenticationProviderResources) extends MachineAuthenticationProvider with StrictLogging { +class ApiKeyAuthenticationProvider(configuration: Configuration, resources: AuthenticationProviderResources) extends MachineAuthenticationProvider with StrictLogging + with InstanceForRequest { implicit val executionContext: ExecutionContext = resources.controllerComponents.executionContext var keyStorePlaceholder: Option[KeyStore] = _ override def initialise(): Unit = { - val store = new KeyStore(configuration.get[String]("authKeyStoreBucket"), resources.commonConfig) + val authBucketEndPoint = S3.AmazonAwsS3Endpoint + val authBucketS3Client = S3Ops.buildS3Client(resources.commonConfig, forceV2Sigs = true) + val authKeyStoreBucket = S3Bucket(configuration.get[String]("authKeyStoreBucket"), authBucketEndPoint, usesPathStyleURLs = false, authBucketS3Client) + val store = new KeyStore(authKeyStoreBucket, resources.commonConfig) store.scheduleUpdates(resources.actorSystem.scheduler) keyStorePlaceholder = Some(store) } @@ -38,21 +48,25 @@ class ApiKeyAuthenticationProvider(configuration: Configuration, resources: Auth * @return An authentication status expressing whether the */ override def authenticateRequest(request: RequestHeader): ApiAuthenticationStatus = { + implicit val instance: Instance = instanceOf(request) request.headers.get(ApiKeyAuthenticationProvider.apiKeyHeaderName) match { case Some(key) => keyStore.lookupIdentity(key) match { // api key provided - case Some(apiKey) => + case Some(apiAccessor) => // valid api key - if (ApiAccessor.hasAccess(apiKey, request, resources.commonConfig.services)) { + if (ApiAccessor.hasAccess(apiAccessor, request, resources.commonConfig.services)) { + val apiKeyInstanceAttribute = TypedEntry[String](ApiKeyInstance, instance.id) + val attributes = TypedMap(ApiKeyAuthenticationProvider.ApiKeyHeader -> (ApiKeyAuthenticationProvider.apiKeyHeaderName -> key)).updated(apiKeyInstanceAttribute) // valid api key which has access // store the header that was used in the attributes map of the principal for use in onBehalfOf calls - val accessor = MachinePrincipal(apiKey, TypedMap(ApiKeyAuthenticationProvider.ApiKeyHeader -> (ApiKeyAuthenticationProvider.apiKeyHeaderName -> key))) - logger.info(s"Using api key with name ${apiKey.identity} and tier ${apiKey.tier}", apiKey) + val accessor = MachinePrincipal(apiAccessor, attributes) + logger.info(s"Using api key with name ${apiAccessor.identity} and tier ${apiAccessor.tier}", apiAccessor) + resources.events.apiKeyUsed(instance = instance, apiKey = apiAccessor.identity) Authenticated(accessor) } else { // valid api key which doesn't have access - NotAuthorised(s"API key ${apiKey.identity} valid but not authorised for this request") + NotAuthorised(s"API key ${apiAccessor.identity} valid but not authorised for this request") } // provided api key not known case None => Invalid("API key not valid") diff --git a/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationProvider.scala b/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationProvider.scala index 0ef3f4c23f6..2ea2a1834d1 100644 --- a/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationProvider.scala +++ b/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationProvider.scala @@ -5,6 +5,7 @@ import com.gu.mediaservice.lib.auth.Authentication.{InnerServicePrincipal, Princ import com.gu.mediaservice.lib.auth.Authorisation import com.gu.mediaservice.lib.auth.provider.AuthenticationProvider.RedirectUri import com.gu.mediaservice.lib.config.{CommonConfig, Provider} +import com.gu.mediaservice.lib.events.UsageEvents import play.api.libs.crypto.CookieSigner import play.api.libs.ws.{WSClient, WSRequest} import play.api.mvc.{ControllerComponents, RequestHeader, Result} @@ -24,7 +25,9 @@ case class AuthenticationProviderResources( actorSystem: ActorSystem, wsClient: WSClient, controllerComponents: ControllerComponents, - authorisation: Authorisation + authorisation: Authorisation, + cookieSigner: CookieSigner, + events: UsageEvents ) sealed trait LoginLink diff --git a/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/KindeAuthenticationProvider.scala b/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/KindeAuthenticationProvider.scala new file mode 100644 index 00000000000..ec59bad6ced --- /dev/null +++ b/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/KindeAuthenticationProvider.scala @@ -0,0 +1,196 @@ +package com.gu.mediaservice.lib.auth.provider + +import com.gu.mediaservice.lib.auth.Authentication.{Principal, UserPrincipal} +import com.gu.mediaservice.lib.auth.provider.AuthenticationProvider.RedirectUri +import com.gu.mediaservice.lib.config.InstanceForRequest +import com.typesafe.scalalogging.StrictLogging +import play.api.Configuration +import play.api.libs.crypto.CookieSigner +import play.api.libs.json.{Json, Reads} +import play.api.libs.typedmap.{TypedEntry, TypedKey, TypedMap} +import play.api.libs.ws.{DefaultWSCookie, WSClient, WSRequest} +import play.api.mvc.Results._ +import play.api.mvc._ + +import java.util.UUID +import scala.concurrent.duration.{Duration, SECONDS} +import scala.concurrent.{ExecutionContext, Future} + +class KindeAuthenticationProvider( + resources: AuthenticationProviderResources, + providerConfiguration: Configuration + ) extends UserAuthenticationProvider with StrictLogging with UrlEncodedCookieDataCodec + with InstanceForRequest { + + private val wsClient: WSClient = resources.wsClient + implicit val ec: ExecutionContext = resources.controllerComponents.executionContext + + private val kindeDomain = providerConfiguration.get[String]("domain") + private val callbackUri = providerConfiguration.get[String]("redirectUri") + private val clientId = providerConfiguration.get[String]("clientId") + private val clientSecret = providerConfiguration.get[String]("clientSecret") + + private val loggedInUserCookieName = "loggedInUser" + + private val loginCookieDomain = resources.commonConfig.domainRoot + + private val loggedInUserCookieTypedKey: TypedKey[Cookie] = TypedKey[Cookie]("loggedInUserCookie") + + private val stateSessionAttributeName = "kindeState" + + /** + * Establish the authentication status of the given request header. This can return an authenticated user or a number + * of reasons why a user is not authenticated. + * + * @param request The request header containing cookies and other request headers that can be used to establish the + * authentication status of a request. + * @return An authentication status expressing whether the + */ + override def authenticateRequest(request: RequestHeader): AuthenticationStatus = { + // Look for our cookie with we set in the auth app + request.cookies.get(loggedInUserCookieName).flatMap { cookie => + val userData = decode(cookie.value) + userData.get("id").map { id => + val userProfile = UserProfile( + id = id, + first_name = userData.get("first_name"), + last_name = userData.get("first_name"), + preferred_email = userData.get("preferred_email") + ) + Authenticated(authedUser = gridUserFrom(userProfile, request)) + } + }.getOrElse { + NotAuthenticated + } + } + + /** + * If this provider supports sending a user that is not authorised to a federated auth provider then it should + * provide a function here to redirect the user. The function signature takes the the request and returns a result + * which is likely a redirect to an external authentication system. + */ + override def sendForAuthentication: Option[RequestHeader => Future[Result]] = Some { + { requestHeader: RequestHeader => + logger.info(s"Requesting Kinde redirect URI with callback uri: $callbackUri") + + val state = UUID.randomUUID().toString + + val oauthRedirectUrl = kindeDomain + + s"/oauth2/auth?response_type=code&client_id=$clientId&redirect_uri=$callbackUri&scope=openid%20profile%20email&state=" + state + logger.info(s"Redirecting to Kinde OAuth URL: $oauthRedirectUrl") + Future.successful(Redirect(oauthRedirectUrl).addingToSession((stateSessionAttributeName, state))(requestHeader)) + } + } + + /** + * If this provider supports sending a user that is not authorised to a federated auth provider then it should + * provide a function here that deals with the return of a user from a federated provider. This should be + * used to set a cookie or similar to ensure that a subsequent call to authenticateRequest will succeed. If + * authentication failed then this should return an appropriate 4xx result. + * The function should take the Play request header and the redirect URI that the user should be + * sent to on successful completion of the authentication. + */ + override def sendForAuthenticationCallback: Option[(RequestHeader, Option[RedirectUri]) => Future[Result]] = Some { + { (requestHeader, redirectUri) => + logger.info("Got auth callback request header: " + requestHeader) + requestHeader.session.get(stateSessionAttributeName).flatMap { state => + requestHeader.getQueryString("code").map { code => + logger.info(s"Got callback code: $code") + val url = kindeDomain + "/oauth2/token" + + val parameters = Map( + "client_id" -> clientId, + "client_secret" -> clientSecret, + "grant_type" -> "authorization_code", + "redirect_uri" -> callbackUri, + "code" -> code, + "state" -> state, + ) + wsClient.url(url).post(parameters).flatMap { r => + implicit val trr: Reads[TokenResponse] = Json.reads[TokenResponse] + val token = Json.parse(r.body).as[TokenResponse] + + val userProfileUrl = kindeDomain + "/oauth2/user_profile" + logger.info(s"Calling Kinde user_profile: $userProfileUrl") + wsClient.url(userProfileUrl). + withHttpHeaders(("Authorization", "Bearer " + token.access_token)). + withRequestTimeout(Duration(10, SECONDS)). + get().map { r => + logger.info("Got user profile response " + r.status) + implicit val upr = Json.reads[UserProfile] + val userProfile = Json.parse(r.body).as[UserProfile] + + val cookieData = Seq( // TODO need to obscure this. It is already signed + Some("id" -> userProfile.id), + userProfile.first_name.map("first_name" -> _), + userProfile.last_name.map("last_name" -> _), + userProfile.preferred_email.map("preferred_email" -> _) + ).flatten.toMap + + logger.info("Encoding logged in user cookie data: " + cookieData) + val cookieContents = encode(cookieData) + val loggedInUserCookie = Cookie(name = loggedInUserCookieName, value = cookieContents, domain = Some(loginCookieDomain)) + resources.events.userAuthed(instance = instanceOf(requestHeader), user = userProfile.id) + + val exitRedirectUri = redirectUri.getOrElse("/") + Redirect(exitRedirectUri).withNewSession.withCookies(loggedInUserCookie) + } + } + + } + }.getOrElse { + Future.successful(BadRequest) + } + } + } + + /** + * If this provider is able to clear user tokens (i.e. by clearing cookies) then it should provide a function to + * do that here which will be used to log users out and also if the token is invalid. + * This function takes the request header and a result to modify and returns the modified result. + */ + override def flushToken: Option[(RequestHeader, Result) => Result] = Some { (request, _) => + // Flush our cookies and session the redirect through Kinde to logout the Kinde session + val kindeLogoutUrl = kindeDomain + "/logout?redirect=https://" + loginCookieDomain + Redirect(kindeLogoutUrl).discardingCookies(DiscardingCookie(loggedInUserCookieName, "/", domain = Some(loginCookieDomain))).withNewSession + } + + /** + * A function that allows downstream API calls to be made using the credentials of the current principal. + * It is recommended that any data required for this downstream request enrichment is put into the principal's + * attribute map when the principal is created in the authenticateRequest call. + * + * @param principal The principal for the current request + * @return Either a function that adds appropriate authentication headers to a WSRequest or an error string explaining + * why it wasn't possible to create a function. + */ + + override def onBehalfOf(request: Principal): Either[String, WSRequest => WSRequest] = { + request.attributes.get(loggedInUserCookieTypedKey) match { + case Some(cookie) => Right { wsRequest: WSRequest => + wsRequest.addCookies(DefaultWSCookie(loggedInUserCookieName, cookie.value)) + } + case None => Left(s"Login cookie $loggedInUserCookieName is missing in principal.") + } + } + + private def gridUserFrom(userProfile: UserProfile, request: RequestHeader): UserPrincipal = { + val maybeLoggedInUserCookie: Option[TypedEntry[Cookie]] = request.cookies.get(loggedInUserCookieName).map(TypedEntry[Cookie](loggedInUserCookieTypedKey, _)) + val kindeIdAttribute = TypedEntry[String](ApiKeyAuthenticationProvider.KindeIdKey, userProfile.id) + val attributes = TypedMap.empty.updated(maybeLoggedInUserCookie.toSeq: _*).updated(kindeIdAttribute) + UserPrincipal( + firstName = userProfile.first_name.getOrElse(""), + lastName = userProfile.last_name.getOrElse(""), + email = userProfile.preferred_email.getOrElse(userProfile.id), + attributes = attributes + ) + } + + override def cookieSigner: CookieSigner = resources.cookieSigner + + override def isSigned: Boolean = true +} + +case class TokenResponse(access_token: String) + +case class UserProfile(id: String, first_name: Option[String], last_name: Option[String], preferred_email: Option[String]) diff --git a/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/LocalAuthenticationProvider.scala b/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/LocalAuthenticationProvider.scala index a2fcafb7b4b..b398c413f63 100644 --- a/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/LocalAuthenticationProvider.scala +++ b/rest-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/LocalAuthenticationProvider.scala @@ -4,25 +4,29 @@ import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.auth.Authentication import com.gu.mediaservice.lib.auth.Authentication.UserPrincipal import com.gu.mediaservice.lib.auth.provider.AuthenticationProvider.RedirectUri +import com.gu.mediaservice.lib.config.InstanceForRequest +import com.gu.mediaservice.model.Instance import com.typesafe.scalalogging.StrictLogging import play.api.http.HeaderNames import play.api.libs.ws.WSRequest import play.api.mvc.{RequestHeader, Result} + import scala.concurrent.{ExecutionContext, Future} class LocalAuthenticationProvider (resources: AuthenticationProviderResources) - extends UserAuthenticationProvider with StrictLogging with ArgoHelpers with HeaderNames { + extends UserAuthenticationProvider with StrictLogging with ArgoHelpers with HeaderNames with InstanceForRequest { logger.warn("Authentication set to local, every user will be authenticated") implicit val ec: ExecutionContext = resources.controllerComponents.executionContext - private val kahunaBaseURI: String = resources.commonConfig.services.kahunaBaseUri + private def kahunaBaseURI: Instance => RedirectUri = resources.commonConfig.services.kahunaBaseUri override def authenticateRequest(request: RequestHeader): AuthenticationStatus = { Authenticated(UserPrincipal("John", "Doe", "johndoe@example.com")) } override def sendForAuthentication: Option[RequestHeader => Future[Result]] = Some({requestHeader: RequestHeader => + implicit val instance: Instance = instanceOf(requestHeader) Future(redirectToSource(requestHeader)) }) @@ -34,8 +38,8 @@ class LocalAuthenticationProvider (resources: AuthenticationProviderResources) Right { identity } } - private def redirectToSource(request: RequestHeader): Result = { - request.getQueryString("redirectUri").map(redirectURL => Redirect(redirectURL)).getOrElse(Redirect(kahunaBaseURI)) + private def redirectToSource(request: RequestHeader)(implicit instance: Instance): Result = { + request.getQueryString("redirectUri").map(redirectURL => Redirect(redirectURL)).getOrElse(Redirect(kahunaBaseURI(instance))) } } diff --git a/rest-lib/src/main/scala/com/gu/mediaservice/lib/guardian/auth/PandaAuthenticationProvider.scala b/rest-lib/src/main/scala/com/gu/mediaservice/lib/guardian/auth/PandaAuthenticationProvider.scala index 126127df987..21b4ca198d6 100644 --- a/rest-lib/src/main/scala/com/gu/mediaservice/lib/guardian/auth/PandaAuthenticationProvider.scala +++ b/rest-lib/src/main/scala/com/gu/mediaservice/lib/guardian/auth/PandaAuthenticationProvider.scala @@ -2,10 +2,12 @@ package com.gu.mediaservice.lib.guardian.auth import com.gu.mediaservice.lib.argo.ArgoHelpers import com.gu.mediaservice.lib.argo.model.Link -import com.gu.mediaservice.lib.auth.Authentication.{UserPrincipal, Principal} +import com.gu.mediaservice.lib.auth.Authentication.{Principal, UserPrincipal} import com.gu.mediaservice.lib.auth.provider.AuthenticationProvider.RedirectUri import com.gu.mediaservice.lib.auth.provider._ import com.gu.mediaservice.lib.aws.S3Ops +import com.gu.mediaservice.lib.config.InstanceForRequest +import com.gu.mediaservice.model.Instance import com.gu.pandomainauth.PanDomainAuthSettingsRefresher import com.gu.pandomainauth.action.AuthActions import com.gu.pandomainauth.model.{AuthenticatedUser, User, Authenticated => PandaAuthenticated, Expired => PandaExpired, GracePeriod => PandaGracePeriod, InvalidCookie => PandaInvalidCookie, NotAuthenticated => PandaNotAuthenticated, NotAuthorized => PandaNotAuthorised} @@ -25,19 +27,19 @@ class PandaAuthenticationProvider( resources: AuthenticationProviderResources, providerConfiguration: Configuration ) - extends UserAuthenticationProvider with AuthActions with StrictLogging with ArgoHelpers with HeaderNames { + extends UserAuthenticationProvider with AuthActions with StrictLogging with ArgoHelpers with HeaderNames with InstanceForRequest { implicit val ec: ExecutionContext = controllerComponents.executionContext - final override def authCallbackUrl: String = s"${resources.commonConfig.services.authBaseUri}/oauthCallback" + final override def authCallbackUrl: String = ??? // TODO We're stuck here but will be replacing Panda s"${resources.commonConfig.services.authBaseUri}/oauthCallback" override lazy val panDomainSettings: PanDomainAuthSettingsRefresher = buildPandaSettings() override def wsClient: WSClient = resources.wsClient override def controllerComponents: ControllerComponents = resources.controllerComponents override def apiGracePeriod: Long = Duration(24, HOURS).toMillis - val loginLinks = List( - Link("login", resources.commonConfig.services.loginUriTemplate) + def loginLinks(implicit instance: Instance): List[Link] = List( + Link("login", resources.commonConfig.services.loginUriTemplate(instance)) ) /** @@ -84,6 +86,8 @@ class PandaAuthenticationProvider( */ override def sendForAuthenticationCallback: Option[(RequestHeader, Option[RedirectUri]) => Future[Result]] = Some({ (requestHeader: RequestHeader, maybeUri: Option[RedirectUri]) => + implicit val instance: Instance = instanceOf(requestHeader) + // We use the `Try` here as the `GoogleAuthException` are thrown before we // get to the asynchronicity of the `Future` it returns. // We then have to flatten the Future[Future[T]]. Fiddly... diff --git a/rest-lib/src/main/scala/com/gu/mediaservice/lib/management/InnerServiceStatusCheckController.scala b/rest-lib/src/main/scala/com/gu/mediaservice/lib/management/InnerServiceStatusCheckController.scala index e313e238ba6..e69de29bb2d 100644 --- a/rest-lib/src/main/scala/com/gu/mediaservice/lib/management/InnerServiceStatusCheckController.scala +++ b/rest-lib/src/main/scala/com/gu/mediaservice/lib/management/InnerServiceStatusCheckController.scala @@ -1,67 +0,0 @@ -package com.gu.mediaservice.lib.management - -import com.gu.mediaservice.lib.argo.ArgoHelpers -import com.gu.mediaservice.lib.auth.Authentication -import com.gu.mediaservice.lib.auth.Authentication.InnerServicePrincipal -import com.gu.mediaservice.lib.config.Services -import play.api.libs.json.{JsString, JsValue, Json, Writes} -import play.api.libs.ws.{WSClient, WSRequest} -import play.api.mvc.{BaseController, ControllerComponents} - -import scala.concurrent.{ExecutionContext, Future} -import scala.util.Try - -case class WhoAmIResponse (baseUri: String, status: Int, body: JsValue) -object WhoAmIResponse { implicit val writes: Writes[WhoAmIResponse] = Json.writes[WhoAmIResponse] } - -class InnerServiceStatusCheckController( - auth: Authentication, - override val controllerComponents: ControllerComponents, - services: Services, - ws: WSClient -)(implicit ec: ExecutionContext) - extends BaseController with ArgoHelpers { - - private def safeJsonParse(maybeJsonStr: String) = Try(Json.parse(maybeJsonStr)).getOrElse(JsString(maybeJsonStr)) - - private def callAllInternalServices(depth: Int, authenticator: WSRequest => WSRequest) = { - val nextDepth = depth - 1 - val whoAmIFutures = services.allInternalUris.map { baseUri => - authenticator(ws.url(s"$baseUri/management/whoAmI").addQueryStringParameters("depth" -> nextDepth.toString)).get() - .map(resp => WhoAmIResponse(baseUri, resp.status, safeJsonParse(resp.body))) - .recover{ - case throwable: Throwable => WhoAmIResponse(baseUri, SERVICE_UNAVAILABLE, Json.obj( - "errorMessage" -> throwable.getMessage, - "stackTrace" -> throwable.getStackTrace.map(_.toString) - ))} - } - - Future.sequence(whoAmIFutures).map { whoAmIResponses => - val overallStatus = whoAmIResponses.map(_.status).max - new Status(overallStatus)(Json.toJson(whoAmIResponses.map(resp => resp.baseUri -> resp).toMap)) - } - } - - def whoAmI(depth: Int) = auth.async { request => - if (depth < 0 || depth > 2) { Future.successful(BadRequest("'depth' query param must be at least 0 and no more than 2"))} - else request.user match { - case principal: InnerServicePrincipal if depth > 0 => - callAllInternalServices( - depth, - authenticator = auth.getOnBehalfOfPrincipal(principal) - ) - case _ => - Future.successful( - Ok(Json.toJson(request.user.toString)) - ) - } - } - - def statusCheck(depth: Int) = Action.async { - if (depth < 1 || depth > 3) { Future.successful(BadRequest("'depth' query param must be at least 1 and no more than 3"))} - else callAllInternalServices( - depth, - authenticator = auth.innerServiceCall - ) - } -} diff --git a/rest-lib/src/main/scala/com/gu/mediaservice/lib/management/Management.scala b/rest-lib/src/main/scala/com/gu/mediaservice/lib/management/Management.scala index 42cb83ce8b1..046ce07dd1d 100644 --- a/rest-lib/src/main/scala/com/gu/mediaservice/lib/management/Management.scala +++ b/rest-lib/src/main/scala/com/gu/mediaservice/lib/management/Management.scala @@ -60,7 +60,7 @@ class ElasticSearchHealthCheck(override val controllerComponents: ControllerComp implicit val imageCountsFormat: Format[ElasticSearchImageCounts] = Json.format[ElasticSearchImageCounts] - elasticsearch.countImages().map { + elasticsearch.countImages(???).map { case counts: ElasticSearchImageCounts => Ok(Json.toJson(counts)) case _ => diff --git a/rest-lib/src/main/scala/com/gu/mediaservice/lib/play/GridComponents.scala b/rest-lib/src/main/scala/com/gu/mediaservice/lib/play/GridComponents.scala index 354613905d5..bdfa57edd54 100644 --- a/rest-lib/src/main/scala/com/gu/mediaservice/lib/play/GridComponents.scala +++ b/rest-lib/src/main/scala/com/gu/mediaservice/lib/play/GridComponents.scala @@ -3,6 +3,7 @@ package com.gu.mediaservice.lib.play import com.gu.mediaservice.lib.auth.{Authentication, Authorisation} import com.gu.mediaservice.lib.auth.provider.{AuthenticationProviderResources, AuthenticationProviders, AuthorisationProvider, AuthorisationProviderResources, InnerServiceAuthenticationProvider, MachineAuthenticationProvider, UserAuthenticationProvider} import com.gu.mediaservice.lib.config.{ApiAuthenticationProviderLoader, AuthorisationProviderLoader, CommonConfig, GridConfigResources, UserAuthenticationProviderLoader} +import com.gu.mediaservice.lib.events.UsageEvents import com.gu.mediaservice.lib.logging.LogConfig import com.gu.mediaservice.lib.management.{BuildInfo, Management} import play.api.ApplicationLoader.Context @@ -13,6 +14,9 @@ import play.filters.HttpFiltersComponents import play.filters.cors.CORSConfig.Origins import play.filters.cors.{CORSComponents, CORSConfig} import play.filters.gzip.GzipFilterComponents +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.sqs.SqsClient +import software.amazon.awssdk.services.sqs.model.GetQueueUrlRequest import scala.concurrent.ExecutionContext @@ -27,20 +31,18 @@ abstract class GridComponents[Config <: CommonConfig](context: Context, val load def buildInfo: BuildInfo implicit val ec: ExecutionContext = executionContext + protected val instanceSpecificCorsFilter = new InstanceSpecificCORSFilter(config, context.initialConfiguration) - final override def httpFilters: Seq[EssentialFilter] = Seq( - corsFilter, - csrfFilter, - securityHeadersFilter, - gzipFilter, - new RequestLoggingFilter(materializer), - new ConnectionBrokenFilter(materializer), - new RequestMetricFilter(config, materializer, actorSystem, applicationLifecycle) - ) - final override lazy val corsConfig: CORSConfig = CORSConfig.fromConfiguration(context.initialConfiguration).copy( - allowedOrigins = Origins.Matching(config.services.corsAllowedDomains) - ) + override def httpFilters: Seq[EssentialFilter] = Seq( + instanceSpecificCorsFilter, + // csrfFilter, TODO Ineffective as gateway is not setting correct hostname headers! + securityHeadersFilter, // TODO needs to be replemented to be request/instance specfic + gzipFilter, + new RequestLoggingFilter(materializer), + new ConnectionBrokenFilter(materializer), + new RequestMetricFilter(config, materializer, actorSystem, applicationLifecycle) + ) lazy val management = new Management(controllerComponents, buildInfo) @@ -48,12 +50,26 @@ abstract class GridComponents[Config <: CommonConfig](context: Context, val load private val authorisationProvider: AuthorisationProvider = config.configuration.get[AuthorisationProvider]("authorisation.provider")(AuthorisationProviderLoader.singletonConfigLoader(authorisationProviderResources, applicationLifecycle)) val authorisation = new Authorisation(authorisationProvider, executionContext) + private val sqsClient: SqsClient = SqsClient.builder() + .region(Region.EU_WEST_1) + .build() + + private val usageEventsQueueUrl: String = { + val getQueueRequest = GetQueueUrlRequest.builder() + .queueName(config.usageEventsQueueName) + .build() + sqsClient.getQueueUrl(getQueueRequest).queueUrl + } + val events = new UsageEvents(actorSystem, applicationLifecycle, sqsClient, usageEventsQueueUrl) + private val authProviderResources = AuthenticationProviderResources( commonConfig = config, actorSystem = actorSystem, wsClient = wsClient, controllerComponents = controllerComponents, - authorisation = authorisation + authorisation = authorisation, + cookieSigner = cookieSigner, + events = events ) protected val providers: AuthenticationProviders = AuthenticationProviders( @@ -62,5 +78,5 @@ abstract class GridComponents[Config <: CommonConfig](context: Context, val load innerServiceProvider = new InnerServiceAuthenticationProvider(cookieSigner, serviceName=config.appName) ) - val auth = new Authentication(config, providers, controllerComponents.parsers.default, executionContext) + val auth = new Authentication(config, providers, wsClient, controllerComponents.parsers.default, executionContext) } diff --git a/rest-lib/src/main/scala/com/gu/mediaservice/lib/play/InstanceSpecificCORSFilter.scala b/rest-lib/src/main/scala/com/gu/mediaservice/lib/play/InstanceSpecificCORSFilter.scala new file mode 100644 index 00000000000..d40f0dac6a5 --- /dev/null +++ b/rest-lib/src/main/scala/com/gu/mediaservice/lib/play/InstanceSpecificCORSFilter.scala @@ -0,0 +1,18 @@ +package com.gu.mediaservice.lib.play + +import com.gu.mediaservice.lib.config.{CommonConfig, InstanceForRequest} +import play.api.Configuration +import play.api.mvc.{EssentialAction, EssentialFilter} +import play.filters.cors.CORSConfig.Origins +import play.filters.cors.{CORSConfig, CORSFilter} + +class InstanceSpecificCORSFilter(config: CommonConfig, playConfig: Configuration) extends EssentialFilter + with InstanceForRequest { + override def apply(next: EssentialAction): EssentialAction = { req => + val instance = instanceOf(req) + val corsConfig: CORSConfig = CORSConfig.fromConfiguration(playConfig).copy( + allowedOrigins = Origins.Matching(config.services.corsAllowedDomains(instance)) + ) + new CORSFilter(corsConfig = corsConfig).apply(next).apply(req) + } +} diff --git a/rest-lib/src/main/scala/com/gu/mediaservice/lib/play/RequestLoggingFilter.scala b/rest-lib/src/main/scala/com/gu/mediaservice/lib/play/RequestLoggingFilter.scala index db121a13507..47f9ce43c28 100644 --- a/rest-lib/src/main/scala/com/gu/mediaservice/lib/play/RequestLoggingFilter.scala +++ b/rest-lib/src/main/scala/com/gu/mediaservice/lib/play/RequestLoggingFilter.scala @@ -88,7 +88,12 @@ class RequestLoggingFilter(override val mat: Materializer)(implicit ec: Executio }, response => { val length = response.header.headers.getOrElse("Content-Length", 0) - logger.info(s"""$originIp - "${request.method} ${request.uri} ${request.version}" ${response.header.status} $length "$referer" ${duration}ms""")(markers) + val message = s"""$originIp - "${request.method} ${request.uri} ${request.version}" ${response.header.status} $length "$referer" ${duration}ms""" + if (request.uri == "/management/healthcheck") { + logger.debug(message)(markers) + } else { + logger.info(message)(markers) + } } ) diff --git a/rest-lib/src/test/resources/application.conf b/rest-lib/src/test/resources/application.conf index 930c6a00d57..7e55f72bb25 100644 --- a/rest-lib/src/test/resources/application.conf +++ b/rest-lib/src/test/resources/application.conf @@ -3,3 +3,7 @@ grid.appName: "test" thrall.kinesis.stream.name: "not-used" thrall.kinesis.lowPriorityStream.name: "not-used" domain.root: "notused.example.com" +s3.image.bucket.name: images +s3.image.bucket.endpoint: some-providers-s3-endpoint +s3.thumb.bucket.name: thumbs +s3.thumb.bucket.endpoint: some-providers-s3-endpoint diff --git a/rest-lib/src/test/scala/com/gu/mediaservice/lib/auth/ApiKeyAuthenticationProviderTest.scala b/rest-lib/src/test/scala/com/gu/mediaservice/lib/auth/ApiKeyAuthenticationProviderTest.scala index 4e90313d619..8fcc8ef54c7 100644 --- a/rest-lib/src/test/scala/com/gu/mediaservice/lib/auth/ApiKeyAuthenticationProviderTest.scala +++ b/rest-lib/src/test/scala/com/gu/mediaservice/lib/auth/ApiKeyAuthenticationProviderTest.scala @@ -1,15 +1,20 @@ package com.gu.mediaservice.lib.auth +import com.amazonaws.services.s3.AmazonS3 import org.apache.pekko.actor.ActorSystem import com.gu.mediaservice.lib.auth.Authentication.MachinePrincipal import com.gu.mediaservice.lib.auth.provider.{ApiKeyAuthenticationProvider, Authenticated, AuthenticationProviderResources, Invalid, NotAuthenticated, NotAuthorised} +import com.gu.mediaservice.lib.aws.{S3, S3Bucket} import com.gu.mediaservice.lib.config.{CommonConfig, GridConfigResources} +import com.gu.mediaservice.lib.events.UsageEvents +import com.gu.mediaservice.model.Instance import org.scalatest.Inside.inside import org.scalatest.freespec.AsyncFreeSpec import org.scalatest.matchers.should.Matchers import org.scalatest.{BeforeAndAfterAll, EitherValues} import org.scalatestplus.mockito.MockitoSugar.mock import play.api.inject.ApplicationLifecycle +import play.api.libs.crypto.CookieSigner import play.api.mvc.DefaultControllerComponents import play.api.test.{FakeRequest, WsTestClient} import play.api.{Configuration, Environment} @@ -17,7 +22,6 @@ import play.api.{Configuration, Environment} import scala.concurrent.ExecutionContext.global import scala.concurrent.Future -//noinspection NotImplementedCode,SpellCheckingInspection class ApiKeyAuthenticationProviderTest extends AsyncFreeSpec with Matchers with EitherValues with BeforeAndAfterAll { private val actorSystem: ActorSystem = ActorSystem() @@ -33,7 +37,7 @@ class ApiKeyAuthenticationProviderTest extends AsyncFreeSpec with Matchers with )){} private val providerConfig = Configuration.empty private val controllerComponents: DefaultControllerComponents = DefaultControllerComponents(null, null, null, null, null, global) - private val resources = AuthenticationProviderResources(config, actorSystem, wsClient, controllerComponents, mock[Authorisation]) + private val resources = AuthenticationProviderResources(config, actorSystem, wsClient, controllerComponents, mock[Authorisation], mock[CookieSigner], mock[UsageEvents] ) private val provider = new ApiKeyAuthenticationProvider(providerConfig, resources) { override def initialise(): Unit = { /* do nothing */ } @@ -41,8 +45,9 @@ class ApiKeyAuthenticationProviderTest extends AsyncFreeSpec with Matchers with Future.successful(()) } - override def keyStore: KeyStore = new KeyStore("not-used", resources.commonConfig) { - override def lookupIdentity(key: String): Option[ApiAccessor] = { + val mockS3Client: AmazonS3 = mock[AmazonS3] + override def keyStore: KeyStore = new KeyStore(S3Bucket("not-used", S3.AmazonAwsS3Endpoint, usesPathStyleURLs = false, mockS3Client), resources.commonConfig) { + override def lookupIdentity(key: String)(implicit instance: Instance): Option[ApiAccessor] = { key match { case "key-chuckle" => Some(ApiAccessor("brothers", Internal)) case "key-limited" => Some(ApiAccessor("locked-down", ReadOnly)) diff --git a/rest-lib/src/test/scala/com/gu/mediaservice/lib/auth/AuthenticationTest.scala b/rest-lib/src/test/scala/com/gu/mediaservice/lib/auth/AuthenticationTest.scala index 21361213976..6ef7c1db44a 100644 --- a/rest-lib/src/test/scala/com/gu/mediaservice/lib/auth/AuthenticationTest.scala +++ b/rest-lib/src/test/scala/com/gu/mediaservice/lib/auth/AuthenticationTest.scala @@ -9,6 +9,7 @@ import com.gu.mediaservice.lib.config.{CommonConfig, GridConfigResources} import org.scalatest.freespec.AsyncFreeSpec import org.scalatest.matchers.should.Matchers import org.scalatest.{BeforeAndAfterAll, EitherValues} +import org.scalatestplus.mockito.MockitoSugar.mock import org.scalatestplus.play.PlaySpec import play.api.{Configuration, Environment} import play.api.http.Status @@ -16,7 +17,7 @@ import play.api.inject.ApplicationLifecycle import play.api.libs.crypto.CookieSigner import play.api.libs.json.{Format, Json} import play.api.libs.typedmap.{TypedKey, TypedMap} -import play.api.libs.ws.{DefaultWSCookie, WSRequest} +import play.api.libs.ws.{DefaultWSCookie, WSClient, WSRequest} import play.api.mvc.{Cookie, DiscardingCookie, PlayBodyParsers, RequestHeader, Result} import play.api.test.Helpers.defaultAwaitTimeout import play.api.test.{FakeRequest, Helpers, WsTestClient} @@ -65,6 +66,7 @@ class AuthenticationTest extends AsyncFreeSpec with Matchers with EitherValues w new Authentication( config = config, providers = testProviders, + mock[WSClient], parser = PlayBodyParsers().default, executionContext = global ) diff --git a/riff-raff.yaml b/riff-raff.yaml index 95a71a754b2..1fbab430336 100644 --- a/riff-raff.yaml +++ b/riff-raff.yaml @@ -119,3 +119,43 @@ deployments: fileName: image-counter-lambda.zip functionNames: - "image-counter-lambda-function-" + + lambda-upload-eu-west-1-image-embedder-lambda-image-embedder-lambda: + type: aws-lambda + regions: + - eu-west-1 + app: image-embedder-lambda + contentDirectory: image-embedder-lambda + parameters: + bucketSsmLookup: true + lookupByTags: true + fileName: image-embedder.zip + actions: + - uploadLambda + cfn-eu-west-1-image-embedder-lambda-image-embedder-lambda: + type: cloud-formation + regions: + - eu-west-1 + app: image-embedder-lambda + contentDirectory: cdk.out + parameters: + templateStagePaths: + PROD: ImageEmbedderLambda-euwest-1-PROD.template.json + TEST: ImageEmbedderLambda-euwest-1-TEST.template.json + dependencies: + - lambda-upload-eu-west-1-image-embedder-lambda-image-embedder-lambda + lambda-update-eu-west-1-image-embedder-lambda-image-embedder-lambda: + type: aws-lambda + regions: + - eu-west-1 + app: image-embedder-lambda + contentDirectory: image-embedder-lambda + parameters: + bucketSsmLookup: true + lookupByTags: true + fileName: image-embedder.zip + actions: + - updateLambda + dependencies: + - cfn-eu-west-1-image-embedder-lambda-image-embedder-lambda + diff --git a/s3watcher/lambda/package-lock.json b/s3watcher/lambda/package-lock.json index c050264e4c7..4bec433c49a 100644 --- a/s3watcher/lambda/package-lock.json +++ b/s3watcher/lambda/package-lock.json @@ -5187,9 +5187,9 @@ "dev": true }, "node_modules/js-yaml": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz", - "integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "dependencies": { "argparse": "^1.0.7", @@ -5426,9 +5426,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true }, "node_modules/lodash.defaults": { @@ -6350,9 +6350,9 @@ "dev": true }, "node_modules/qs": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", + "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", "dev": true, "engines": { "node": ">=0.6" @@ -12682,9 +12682,9 @@ "dev": true }, "js-yaml": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz", - "integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "requires": { "argparse": "^1.0.7", @@ -12874,9 +12874,9 @@ } }, "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true }, "lodash.defaults": { @@ -13602,9 +13602,9 @@ "dev": true }, "qs": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", + "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", "dev": true }, "querystring": { diff --git a/scripts/embedder-deploy/build.sh b/scripts/embedder-deploy/build.sh new file mode 100755 index 00000000000..d873bbdbcce --- /dev/null +++ b/scripts/embedder-deploy/build.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +set -e + +( + cd image-embedder-lambda + npm ci + # Install sharp's native bindings for the lambda target architecture (linux-arm64) + npm install --cpu=arm64 --os=linux --libc=glibc sharp + npm run build + + # Unfortunately because sharp contains native C++ code, we can't bundle + # it into the single JS file produced by esbuild. + # Instead, we have to declare it external and add it and its depedencies + # to the deploy zip ourselves. + # https://sharp.pixelplumbing.com/install/#esbuild + mkdir -p dist/node_modules/@img + cp -r node_modules/sharp dist/node_modules/ + cp -r node_modules/@img/sharp-linux-arm64 dist/node_modules/@img/ + cp -r node_modules/@img/sharp-libvips-linux-arm64 dist/node_modules/@img/ + cp -r node_modules/@img/colour dist/node_modules/@img/ + # Sharp's JS dependencies + cp -r node_modules/detect-libc dist/node_modules/ + cp -r node_modules/semver dist/node_modules/ + + cd dist + zip -r ../../image-embedder.zip . +) \ No newline at end of file diff --git a/scripts/embedder-deploy/cdk.sh b/scripts/embedder-deploy/cdk.sh new file mode 100755 index 00000000000..06ba8a13abd --- /dev/null +++ b/scripts/embedder-deploy/cdk.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -e + +( + cd cdk + npm ci + npm run lint + npm run test + npm run synth +) \ No newline at end of file diff --git a/scripts/embedder-deploy/get-s3-vector-by-key.sh b/scripts/embedder-deploy/get-s3-vector-by-key.sh new file mode 100755 index 00000000000..1b0f553c9b3 --- /dev/null +++ b/scripts/embedder-deploy/get-s3-vector-by-key.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -euo pipefail + +if [ $# -lt 2 ]; then + echo "Error: Stage and vector key arguments are required" + echo "" + echo "Usage: $0 " + echo "" + echo "STAGE: test, dev, or prod" + echo "" + echo "Example:" + echo " $0 test 9e3fec5784b8e203b9ee6139477d689cc9162c9a" + exit 1 +fi + +STAGE="$1" +VECTOR_KEY="$2" + +if [[ ! "$STAGE" =~ ^(dev|test|prod)$ ]]; then + echo "Invalid stage. Please use dev, test, or prod" + exit 1 +fi + + +# Write this to stderr so we can pipe get-vectors stdout to jq +>&2 echo "Fetching vector with key: ${VECTOR_KEY} from ${STAGE}" + +aws s3vectors get-vectors \ + --vector-bucket-name image-embeddings-"${STAGE}" \ + --index-name cohere-embed-english-v3 \ + --return-data \ + --keys "${VECTOR_KEY}" \ + --return-metadata \ + --profile media-service \ + --region eu-central-1 \ No newline at end of file diff --git a/scripts/embedder-deploy/send-message-batch.sh b/scripts/embedder-deploy/send-message-batch.sh new file mode 100755 index 00000000000..57e55e93d1a --- /dev/null +++ b/scripts/embedder-deploy/send-message-batch.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -euo pipefail + +# This script send a batch of duplicate messages and the handler will loop through them. +# BUT the write to the S3 Vector Store will fail because we're trying to write duplicate keys! +# Right now, this script is just useful for testing the loop behaviour +# and perhaps may be useful in the future to test skipping images already embedded (when we add that) + +# Check for required arguments +if [ $# -ne 4 ]; then + echo "Usage: $0 " + exit 1 +fi + +IMAGE_ID="$1" +FILE_TYPE="$2" +S3_BUCKET="$3" +S3_KEY="$4" + +# Fixed stage +STAGE="TEST" + +QUEUE_NAME="image-embedder-${STAGE}" + +QUEUE_URL=$( + aws sqs get-queue-url \ + --queue-name "$QUEUE_NAME" \ + --query 'QueueUrl' \ + --output text \ + --profile media-service \ + --region eu-west-1 +) +# Build entries using jq +ENTRIES=$(jq -n \ + --arg imageId "$IMAGE_ID" \ + --arg fileType "$FILE_TYPE" \ + --arg s3Bucket "$S3_BUCKET" \ + --arg s3Key "$S3_KEY" \ + '[range(1;11) | { + Id: ("msg-" + (. | tostring)), + MessageBody: ({imageId: $imageId, fileType: $fileType, s3Bucket: $s3Bucket, s3Key: $s3Key} | tostring) + }]') + +aws sqs send-message-batch \ + --queue-url "$QUEUE_URL" \ + --entries "$ENTRIES" \ + --profile media-service \ + --region eu-west-1 \ No newline at end of file diff --git a/scripts/embedder-deploy/send-message.sh b/scripts/embedder-deploy/send-message.sh new file mode 100755 index 00000000000..b846919c7a8 --- /dev/null +++ b/scripts/embedder-deploy/send-message.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -euo pipefail + +# Check for required arguments +if [ $# -ne 4 ]; then + echo "Usage: $0 " + exit 1 +fi + +IMAGE_ID="$1" +FILE_TYPE="$2" +S3_BUCKET="$3" +S3_KEY="$4" + +# Fixed stage +STAGE="TEST" + +QUEUE_NAME="image-embedder-${STAGE}" + +QUEUE_URL=$( + aws sqs get-queue-url \ + --queue-name "$QUEUE_NAME" \ + --query 'QueueUrl' \ + --output text \ + --profile media-service \ + --region eu-west-1 +) + +aws sqs send-message \ + --queue-url "$QUEUE_URL" \ + --message-body \ + "{\"imageId\": \"${IMAGE_ID}\", \"fileType\": \"${FILE_TYPE}\", \"s3Bucket\": \"${S3_BUCKET}\", \"s3Key\": \"${S3_KEY}\"}" \ + --profile media-service \ + --region eu-west-1 + +echo "Sent message to queue ${QUEUE_NAME}:" diff --git a/scripts/inadvisable-metadata-backup/yarn.lock b/scripts/inadvisable-metadata-backup/yarn.lock index 70cc8cdba90..1e87c606e6c 100644 --- a/scripts/inadvisable-metadata-backup/yarn.lock +++ b/scripts/inadvisable-metadata-backup/yarn.lock @@ -67,9 +67,9 @@ create-require@^1.1.0: integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== diff@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" - integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + version "4.0.4" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.4.tgz#7a6dbfda325f25f07517e9b518f897c08332e07d" + integrity sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ== events@1.1.1: version "1.1.1" diff --git a/scripts/src/main/scala/com/gu/mediaservice/lib/JsonValueCodecJsValue.scala b/scripts/src/main/scala/com/gu/mediaservice/lib/JsonValueCodecJsValue.scala index 0540dcdc851..c6ad7b03f85 100644 --- a/scripts/src/main/scala/com/gu/mediaservice/lib/JsonValueCodecJsValue.scala +++ b/scripts/src/main/scala/com/gu/mediaservice/lib/JsonValueCodecJsValue.scala @@ -97,6 +97,8 @@ object JsonValueCodecJsValue { out.writeObjectEnd() case JsNull => out.writeNull() + case _ => + out.writeNull() } } diff --git a/scripts/src/main/scala/com/gu/mediaservice/scripts/BackfillEditLastModified.scala b/scripts/src/main/scala/com/gu/mediaservice/scripts/BackfillEditLastModified.scala index 881d532857d..e69de29bb2d 100644 --- a/scripts/src/main/scala/com/gu/mediaservice/scripts/BackfillEditLastModified.scala +++ b/scripts/src/main/scala/com/gu/mediaservice/scripts/BackfillEditLastModified.scala @@ -1,188 +0,0 @@ -package com.gu.mediaservice.scripts - -import com.sksamuel.elastic4s.ElasticApi.matchQuery -import com.sksamuel.elastic4s.ElasticDsl._ -import org.joda.time.DateTime -import play.api.libs.json.Json -import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider -import software.amazon.awssdk.regions.Region -import software.amazon.awssdk.services.dynamodb.model.{AttributeValue, ScanRequest, UpdateItemRequest} -import software.amazon.awssdk.services.dynamodb.{DynamoDbClient, DynamoDbClientBuilder} -import software.amazon.awssdk.services.s3.S3Client - -import java.io.{File, FileOutputStream, PrintWriter} -import scala.jdk.CollectionConverters._ -import scala.io.Source -import scala.util.Try - -object BackfillEditLastModified extends EsScript { - - private val profile = "media-service" - private val region = Region.EU_WEST_1 - private val credentials = DefaultCredentialsProvider.builder.profileName(profile).build - private val dynamo: DynamoDbClient = DynamoDbClient.builder - .region(region) - .credentialsProvider(credentials) - .build - - private var total = 0 - private var skipped = 0 - private var updated = 0 - - override def run(esUrl: String, args: List[String]): Unit = { - def option(options: List[String], argName: String): (Option[String], List[String]) = { - val arg = options.find(_.startsWith(s"--$argName=")) - val value = arg.map(_.stripPrefix(s"--$argName=")) - value -> options.filterNot(arg.contains) - } - - args match { - case dynamoTable :: fileNamePrefix :: remainder => - val (maybeRun, _) = option(remainder, "run") - val dryRun = !maybeRun.contains("true") - val esClient = new EsClient(esUrl) - try { - backfill(esClient, dynamoTable, fileNamePrefix, dryRun) - } finally { - esClient.client.close() - } - case _ => throw new IllegalArgumentException("Usage: BackfillEditLastModified [--run=true]") - } - } - - def backfill(esClient: EsClient, dynamoTable: String, fileName: String, dryRun: Boolean): Unit = { - val lastFile = new File(s"$fileName.last") - val lastEvaluatedKey = if (lastFile.exists()) { - val source = Source.fromFile(lastFile) - val lastId = try { - source.getLines().toList match { - case id :: totalStr :: _ => - total = totalStr.toInt - Some(id) - case _ => None - } - } finally { - source.close() - } - lastId.map { id => - Map("id" -> AttributeValue.builder.s(id).build).asJava - } - } else None - - val logWriter = new PrintWriter(new FileOutputStream(s"$fileName.$total.log")) - def log(msg: String) = logWriter.println(s"[${DateTime.now}/($total/U:$updated/S:$skipped]: $msg") - - log(s"Starting backfill of $dynamoTable from ${esClient.url}; ${if(dryRun){"DRYRUN ONLY"}else{"RUNNING"}}") - log(s"Last evaluated key: $lastEvaluatedKey") - // scan through edits table (this is the shorter list by far) - val scanIterator = dynamo.scanPaginator( - ScanRequest.builder - .tableName(dynamoTable) - .projectionExpression("id") - .limit(500) - .exclusiveStartKey(lastEvaluatedKey.orNull) - .build - ) - scanIterator - .iterator().asScala - .map { r => - - val lastEvaluatedKey = Some(r.lastEvaluatedKey.asScala) - .filter(_ => r.hasLastEvaluatedKey) - .flatMap(_.get("id")) - .map(_.s()) - - - lastEvaluatedKey -> r.items.asScala - } - .map { case (lek, records) => - lek -> records.flatMap(_.asScala.get("id").map(_.s)) - } - .foreach { case (lek, imageIds) => - imageIds.foreach { id => - // for each edit entry: - // do a simple get on the ES cluster - val maybeLastModified = getUserMetadataLastModified(esClient, id) - // update just the last modified value on the edits table - maybeLastModified match { - case Left(reason) => - skipped += 1 - log(s"$id: Unable to get userMetadataLastModified - $reason") - case Right(lastModified) => - updated += 1 - val result = updateEditRecord(dynamoTable, id, lastModified, dryRun) - log(s"$id: $result") - } - total += 1 - Thread.sleep(10) - } - - lek match { - case Some(id) => - log(s"Last evaluated key $id (batch of ${imageIds.size})") - val logWriter = new PrintWriter(new FileOutputStream(lastFile)) - try { - logWriter.println(id) - logWriter.println(total) - } finally { - logWriter.close() - } - case None => // nowt - } - - } - log(s"Finished. Updated: $updated; Skipped: $skipped") - } - - def getUserMetadataLastModified(esClient: EsClient, id: String): Either[String, DateTime] = { - val client = esClient.client - val index = esClient.currentIndex - val queryType = matchQuery("id", id) - val queryResponse = client.execute({ - search(index) - .query(queryType) - .fetchSource(false) - .sourceInclude( - "id", - "userMetadataLastModified" - ) - }).await - - queryResponse.status match { - case 200 => queryResponse.result.hits.hits.flatMap{ hit => - (Json.parse(hit.sourceAsString) \ "userMetadataLastModified").asOpt[String] - } - .headOption - .toRight("Not in ES document") - .flatMap { lm => - Try(new DateTime(lm)).toEither.left.map { t => - t.toString - } - } - case _ => - client.close() - throw new Exception("Failed performing search query") - } - } - - def updateEditRecord(tableName: String, id: String, lastModified: DateTime, dryRun: Boolean): String = { - val request = UpdateItemRequest.builder - .tableName(tableName) - .key(Map("id" -> AttributeValue.builder.s(id).build).asJava) - .updateExpression("SET lastModified = :lastModified") - .expressionAttributeValues(Map(":lastModified" -> AttributeValue.builder.s(lastModified.toString).build).asJava) - .build - val msg = s"Updated with $lastModified ($request)" - if (dryRun) { - s"Would: $msg" - } else { - dynamo.updateItem(request) - msg - } - } - - override def usageError: Nothing = { - System.err.println("Usage: BackfillEditLastModified [--run=true]") - sys.exit(1) - } -} diff --git a/scripts/src/main/scala/com/gu/mediaservice/scripts/BucketMetadata.scala b/scripts/src/main/scala/com/gu/mediaservice/scripts/BucketMetadata.scala deleted file mode 100644 index 1949018042b..00000000000 --- a/scripts/src/main/scala/com/gu/mediaservice/scripts/BucketMetadata.scala +++ /dev/null @@ -1,68 +0,0 @@ -package com.gu.mediaservice.scripts - - -import org.apache.commons.compress.compressors.bzip2.BZip2CompressorOutputStream -import org.joda.time.DateTime -import play.api.libs.json.{Json, OFormat} -import software.amazon.awssdk.auth.credentials.{DefaultCredentialsProvider, ProfileCredentialsProvider} -import software.amazon.awssdk.regions.Region -import software.amazon.awssdk.services.s3.S3Client -import software.amazon.awssdk.services.s3.model.{HeadObjectRequest, ListObjectsV2Request} - -import java.io.{BufferedWriter, File, FileOutputStream, FileWriter, OutputStreamWriter} -import java.time.Instant -import scala.jdk.CollectionConverters._ - -case class ObjectMetadata(key: String, lastModified: Instant, metadata: Map[String, String]) -object ObjectMetadata { - implicit val format: OFormat[ObjectMetadata] = Json.format[ObjectMetadata] -} - -/** - * Dump selected metadata for all objects in a bucket. - * Given a bucket and an output file this will create a JSON line per object containing the key, lastModified time and - * user metadata. - */ -object BucketMetadata { - def apply(args: List[String]): Unit = { - args match { - case bucketName :: fileName :: Nil => bucketMetadata(bucketName, new File(fileName)) - case _ => throw new IllegalArgumentException("Usage: BucketMetadata ") - } - } - - def bucketMetadata(bucketName: String, outputFile: File) = { - val fileOutputStream = new FileOutputStream(outputFile) - val compressOutputStream = new BZip2CompressorOutputStream(fileOutputStream) - val sw = new OutputStreamWriter(compressOutputStream) - System.err.println(s"Output encoding: ${sw.getEncoding}") - val stream = new BufferedWriter(sw) - - try { - val s3: S3Client = S3Client.builder - .region(Region.EU_WEST_1) - .credentialsProvider(DefaultCredentialsProvider.builder.profileName("media-service").build) - .build - - val results = s3.listObjectsV2Paginator(ListObjectsV2Request.builder.bucket(bucketName).build) - results.iterator().asScala.flatMap { results => - results.contents().asScala - }.map { s3Object => - val headObjectResponse = s3.headObject(HeadObjectRequest.builder.bucket(bucketName).key(s3Object.key).build) - s3Object -> headObjectResponse - }.map { case (s3Object, metadata) => - ObjectMetadata(s3Object.key, metadata.lastModified, metadata.metadata.asScala.toMap) - }.map { md => - Json.stringify(Json.toJson(md)) - }.zipWithIndex - .foreach { case (json, idx) => - if (idx % 1000 == 0) System.err.println(s"${DateTime.now.toString}: ${idx}") - stream.write(json) - stream.newLine() - stream.flush() - } - } finally { - stream.close() - } - } -} diff --git a/scripts/src/main/scala/com/gu/mediaservice/scripts/BulkDeleteS3Files.scala b/scripts/src/main/scala/com/gu/mediaservice/scripts/BulkDeleteS3Files.scala deleted file mode 100644 index 3dac2e67826..00000000000 --- a/scripts/src/main/scala/com/gu/mediaservice/scripts/BulkDeleteS3Files.scala +++ /dev/null @@ -1,68 +0,0 @@ -package com.gu.mediaservice.scripts - -import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider -import software.amazon.awssdk.regions.Region -import software.amazon.awssdk.services.s3.S3Client -import software.amazon.awssdk.services.s3.model.{Delete, DeleteObjectsRequest, ObjectIdentifier} - -import java.io.{File, PrintWriter} -import scala.io.Source -import scala.jdk.CollectionConverters._ - -object BulkDeleteS3Files { - - private val profile = "media-service" - private val region = Region.EU_WEST_1 - private val credentials = DefaultCredentialsProvider.builder.profileName(profile).build - private val s3: S3Client = S3Client.builder - .region(region) - .credentialsProvider(credentials) - .build - def apply(args: List[String]): Unit = { - args match { - case bucketName :: inputFileName :: auditFileName :: _ => - apply(bucketName, inputFileName, auditFileName) - case _ => throw new IllegalArgumentException("Usage: BulkDeleteS3Files ") - } - } - - def apply(bucketName: String, inputFileName: String, auditFileName: String) = { - - val inputFileSource = Source.fromFile(inputFileName) - val auditFileWriter = new PrintWriter(new File(auditFileName)) - auditFileWriter.println("path, result") - try { - for { - bucketKeys <- inputFileSource.getLines().drop(1 /* column heading */).grouped(1000).toList - objectIdentifiers = bucketKeys.map(key => ObjectIdentifier.builder().key(key.trim().drop(1).dropRight(1)).build()) - batchResult = s3.deleteObjects( - DeleteObjectsRequest.builder() - .bucket(bucketName) - .delete( - Delete.builder() - .objects(objectIdentifiers:_*) - .quiet(false) // so we get success back too - .build() - ) - .build() - ) - successfulDeletesLogLines = batchResult.deleted().asScala.map{ deletedObject => - s"${deletedObject.key()}, DELETED" - } - failedDeletesLogLines = batchResult.errors().asScala.map{ error => - s"${error.key()}, FAILED, ${error.code()}, ${error.message()}" - } - logLine <- successfulDeletesLogLines ++ failedDeletesLogLines - } { - println(logLine) - auditFileWriter.println(logLine) - } - } - finally { - inputFileSource.close() - auditFileWriter.close() - } - - } - -} diff --git a/scripts/src/main/scala/com/gu/mediaservice/scripts/ConvertConfig.scala b/scripts/src/main/scala/com/gu/mediaservice/scripts/ConvertConfig.scala deleted file mode 100644 index 231595e5313..00000000000 --- a/scripts/src/main/scala/com/gu/mediaservice/scripts/ConvertConfig.scala +++ /dev/null @@ -1,65 +0,0 @@ -package com.gu.mediaservice.scripts - -import java.io.File -import java.nio.charset.StandardCharsets -import java.nio.file.{Files, Path, Paths} - -import com.gu.typesafe.config.{ConfigFactory, ConfigRenderOptions} - -import scala.util.Try - -import scala.jdk.CollectionConverters._ - -object ConvertConfig { - case class Conversion(input: File, output: File) - - def apply(args: List[String]): Unit = { - args match { - case "-f" :: as => convertConfigs(as, overwrite = true) - case as => convertConfigs(as, overwrite = false) - } - } - - private def convertConfigs(args: List[String], overwrite: Boolean): Unit = { - val conversions = args.flatMap { arg => - val input = new File(arg) - assert(input.exists, s"File provided for conversion doesn't exist: $input") - val files: List[File] = if (input.isDirectory) { - val paths: List[Path] = Files.walk(input.toPath).iterator.asScala.toList - val regularFiles = paths.filter(file => Files.isRegularFile(file)) - val propertiesFiles = regularFiles.filter(_.toString.endsWith(".properties")) - val files = propertiesFiles.map(_.toFile) - assert(files.nonEmpty, s"No properties files found to convert in $input") - files - } else { - assert(input.toString.endsWith(".properties"), s"File provided for conversion isn't a java properties input: $input") - List(input) - } - files.map { f => - val output = new File(s"${f.toString.stripSuffix(".properties")}.conf") - if (!overwrite) { - assert(!output.exists, s"Output file for $input already exists: $output") - } - Conversion(f, output) - } - } - - conversions.foreach(conversion => convert(conversion)) - } - - private def convert(conversion: Conversion): Either[Throwable, Unit] = { - for { - config <- Try(ConfigFactory.parseFile(conversion.input)).toEither - options = ConfigRenderOptions.defaults().setOriginComments(false).setJson(false).setCompactKeys(true) - hoconStr = config.root().render(options) - _ <- writeToFile(conversion.output, hoconStr) - } yield () - } - - private def writeToFile(file: File, str: String): Either[Throwable, Path] = { - Try{ - System.err.println(s"Writing config to $file") - Files.write(file.toPath, str.getBytes(StandardCharsets.UTF_8)) - }.toEither - } -} diff --git a/scripts/src/main/scala/com/gu/mediaservice/scripts/DecodeComparator.scala b/scripts/src/main/scala/com/gu/mediaservice/scripts/DecodeComparator.scala deleted file mode 100644 index 6d2113dcb19..00000000000 --- a/scripts/src/main/scala/com/gu/mediaservice/scripts/DecodeComparator.scala +++ /dev/null @@ -1,39 +0,0 @@ -package com.gu.mediaservice.scripts - -import com.gu.mediaservice.lib.net.URI -import play.api.libs.json.{JsError, JsSuccess, Json} - -import java.io.File -import scala.io.Source - -/** - * Given a JSON lines file output from BucketMetadata this will verify that metadata is not changed when being - * passed through a URI decode function. This was to check that it is safe to deploy - * https://github.com/guardian/grid/pull/3165 - */ -object DecodeComparator { - def apply(args: List[String]): Unit = { - args match { - case fileName :: Nil => compare(new File(fileName)) - case as => throw new IllegalArgumentException("Usage: DecodeComparator ") - } - } - - def compare(file: File): Unit = { - val source = Source.fromFile(file) - try { - source.getLines().foreach { line => - Json.fromJson[ObjectMetadata](Json.parse(line)) match { - case JsError(errors) => System.err.println(s"Couldn't parse JSON $line") - case JsSuccess(metadata, _) => - metadata.metadata.toList.foreach{ case (key, value) => - val decodedValue = URI.decode(value) - if (value != decodedValue) System.out.println(s"Difference between $key '$value' and '$decodedValue'") - } - } - } - } finally { - source.close() - } - } -} diff --git a/scripts/src/main/scala/com/gu/mediaservice/scripts/EnactS3Changes.scala b/scripts/src/main/scala/com/gu/mediaservice/scripts/EnactS3Changes.scala index c728ca354e5..e69de29bb2d 100644 --- a/scripts/src/main/scala/com/gu/mediaservice/scripts/EnactS3Changes.scala +++ b/scripts/src/main/scala/com/gu/mediaservice/scripts/EnactS3Changes.scala @@ -1,200 +0,0 @@ -package com.gu.mediaservice.scripts - -import java.io._ -import com.amazonaws.auth.profile.ProfileCredentialsProvider -import org.apache.commons.compress.compressors.bzip2.{BZip2CompressorInputStream, BZip2CompressorOutputStream} -import play.api.libs.json.{JsObject, JsValue, Json, OWrites} - -import scala.io.Source -import com.amazonaws.services.s3.AmazonS3 -import com.amazonaws.services.s3.AmazonS3ClientBuilder -import com.amazonaws.services.s3.model.AmazonS3Exception -import org.joda.time.{DateTime, Duration} -import com.gu.mediaservice.lib.JsonValueCodecJsValue.jsValueCodec -import com.github.plokhotnyuk.jsoniter_scala.core._ -import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider -import software.amazon.awssdk.regions.Region -import software.amazon.awssdk.services.s3.S3Client -import software.amazon.awssdk.services.s3.model.{CopyObjectRequest, HeadObjectRequest, ListObjectsRequest, ListObjectsV2Request, MetadataDirective, NoSuchKeyException} - -import scala.jdk.CollectionConverters._ -import scala.util.{Failure, Success, Try} - -object EnactS3Changes { - - private val profile = "media-service" - private val region = Region.EU_WEST_1 - private val credentials = DefaultCredentialsProvider.builder.profileName(profile).build - private val s3: S3Client = S3Client.builder - .region(region) - .credentialsProvider(credentials) - .build - - def option(options: List[String], argName: String): (Option[String], List[String]) = { - val arg = options.find(_.startsWith(s"$argName=")) - val value = arg.map(_.stripPrefix(s"$argName=")) - value -> options.filterNot(arg.contains) - } - - def apply(args: List[String]): Unit = { - args match { - case bucketName :: inputFileName :: auditFileName :: options => - val (maybeDrop, optionsAfterDrop) = option(options, "drop") - val (maybeTake, optionsAfterTake) = option(optionsAfterDrop, "take") - val (maybePrefixFilter, unknownOptions) = option(optionsAfterTake, "prefixFilter") - if (unknownOptions.nonEmpty) throw new IllegalArgumentException(s"Didn't recognise options $unknownOptions") - enactS3Changes( - bucketName, - new File(inputFileName), - new File(auditFileName), - maybeDrop.map(_.toInt), - maybeTake.map(_.toInt), - maybePrefixFilter - ) - case _ => throw new IllegalArgumentException("Usage: EnactS3Changes [drop=] [take=] [prefixFilter=]") - } - } - - def getBzipWriter(outputFile: File, append: Boolean = false) = { - val fileOutputStream = new FileOutputStream(outputFile, append) - val compressOutputStream = new BZip2CompressorOutputStream(fileOutputStream) - val sw = new OutputStreamWriter(compressOutputStream) - new BufferedWriter(sw) - } - - case class AuditEntry(status: String, key: String, message: Option[String] = None, details: Option[JsObject] = None) - - object AuditEntry { - def apply(status: String, key: String, message: String): AuditEntry = AuditEntry(status, key, Some(message)) - def apply(status: String, key: String, message: String, details: JsObject): AuditEntry = AuditEntry(status, key, Some(message), Some(details)) - - implicit val writes: OWrites[AuditEntry] = Json.writes[AuditEntry] - def audit(writer: BufferedWriter, entry: AuditEntry): Unit = { - val value = Json.toJsObject(entry) - val json = writeToString[JsValue](value) - writer.append(s"$json\n") - } - val OK = "OK" - val SKIPPED = "SKIPPED" - val ERROR = "ERROR" - } - - def getAuditFileName(auditFile: File, id: Int = 0): File = { - val file = new File(s"${auditFile.getPath}${if (id > 0) s".$id" else ""}") - if (file.exists()) getAuditFileName(auditFile, id+1) else file - } - - def enactS3Changes( - bucketName: String, - inputFile: File, - auditFile: File, - maybeDrop: Option[Int], - maybeTake: Option[Int], - prefixFilter: Option[String]) = { - import AuditEntry._ - - // do this to ensure creds work before starting - s3.listObjectsV2(ListObjectsV2Request.builder.bucket(bucketName).build) - - // reporting - val batchSize = 1000 - val startTime = DateTime.now - var startBatchTime = DateTime.now - var total = 0L - - // auditing - val auditWriter = getBzipWriter(getAuditFileName(auditFile)) - auditWriter.append(s"Enacting changes in $bucketName from $inputFile (dropping: $maybeDrop, taking: $maybeTake, prefixFilter: $prefixFilter)\n") - System.err.println(s"Enacting changes in $bucketName from $inputFile (dropping: $maybeDrop, taking: $maybeTake, prefixFilter: $prefixFilter)") - try { - withSourceFromBzipFile(inputFile) {source => - val lines = source - .getLines() - .map(line => { - val maybeJsValue = Try(readFromString[JsValue](line)).toOption - ( - maybeJsValue.map(_ \ "proposed").flatMap(_.asOpt[ObjectMetadata]), - maybeJsValue.map(_ \ "original").flatMap(_.asOpt[ObjectMetadata]) - ) - }) - val filteredLines = prefixFilter - .map(pf => lines.filter{ - case (Some(proposed), _) => proposed.key.startsWith(pf) - case _ => false - }) - .getOrElse(lines) - val droppedLines = maybeDrop.map(filteredLines.drop).getOrElse(filteredLines) - val linesToProcess = maybeTake.map(droppedLines.take).getOrElse(droppedLines) - - val auditEntries: Iterator[AuditEntry] = linesToProcess - .map { - case (Some(proposed), Some(original)) => - val key = proposed.key - Try {Some(s3.headObject(HeadObjectRequest.builder.bucket(bucketName).key(key).build))}.recover{ - case _:NoSuchKeyException => None - } match { - case Success(Some(headObjectResponse)) => - val check = headObjectResponse.metadata.asScala - if (check!=original.metadata) { - if (check==proposed.metadata) { - AuditEntry(SKIPPED, key, s"Already updated") - } else { - AuditEntry(ERROR, key, s"Metadata doesn't match that expected", Json.obj( - "actual" -> check, - "expected" -> original.metadata - )) - } - } else { - val request = CopyObjectRequest.builder - .sourceBucket(bucketName).sourceKey(key) - .destinationBucket(bucketName).destinationKey(key) - .metadata(proposed.metadata.asJava) - .metadataDirective(MetadataDirective.REPLACE) - .build - Try {s3.copyObject(request)} match { - case Success(_) => AuditEntry(OK, key) - case Failure(e) => AuditEntry(ERROR, key, s"Error whilst copying object ($e)") - } - } - case Success(None) => - AuditEntry(SKIPPED, key, "Object no longer exists") - case Failure(e) => - AuditEntry(ERROR, key, s"Error whilst getting object metadata ($e)") - } - case other => AuditEntry(ERROR, "n/a", s"Unable to parse record", Json.obj("record" -> other.toString)) - } - auditEntries - .map{ entry => - audit(auditWriter, entry) - entry - } - .grouped(batchSize) - .foreach{ auditEntryBatch => - val auditEntries = auditEntryBatch.toList - total = total + auditEntries.size - val now = DateTime.now - val elapsed = new Duration(startTime, now) - val elapsedBatch = new Duration(startBatchTime, now).getMillis - System.err.println(s"${DateTime.now.toString} Processed $total lines") - System.err.println(s"Batch: Processed ${auditEntries.size} in ${elapsedBatch}ms (mean: ${elapsedBatch/auditEntries.size}ms per line)") - System.err.println(s" OK: ${auditEntries.count(_.status==OK)} SKIPPED: ${auditEntries.count(_.status==SKIPPED)} ERROR: ${auditEntries.count(_.status==ERROR)}") - System.err.println(s"Total: Processed $total in ${elapsed.getStandardSeconds}s (mean: ${elapsed.getMillis/total}ms per line)") - startBatchTime = now - } - } - } finally { - auditWriter.close() - } - } - - private def withSourceFromBzipFile[T](file: File)(f: Source => T) = { - val fileInputStream = new FileInputStream(file) - val compressInputStream = new BZip2CompressorInputStream(fileInputStream) - val source = Source.fromInputStream(compressInputStream) - try { - f(source) - } finally { - source.close() - } - } -} diff --git a/scripts/src/main/scala/com/gu/mediaservice/scripts/EsImageMetadata.scala b/scripts/src/main/scala/com/gu/mediaservice/scripts/EsImageMetadata.scala deleted file mode 100644 index 76dff10c58b..00000000000 --- a/scripts/src/main/scala/com/gu/mediaservice/scripts/EsImageMetadata.scala +++ /dev/null @@ -1,165 +0,0 @@ -package com.gu.mediaservice.scripts - -import com.sksamuel.elastic4s.ElasticClient -import com.sksamuel.elastic4s.ElasticDsl._ -import com.sksamuel.elastic4s.requests.searches.{SearchHit, SearchResponse} -import org.apache.commons.compress.compressors.bzip2.BZip2CompressorOutputStream -import play.api.libs.json._ - -import java.io.{BufferedWriter, File, FileOutputStream, OutputStreamWriter} -import java.util.concurrent.TimeUnit -import scala.annotation.tailrec -import scala.concurrent.duration.{Duration, FiniteDuration} -import scala.concurrent.{Await, Future} - -object EsImageMetadata extends EsScript { - override def run(esUrl: String, args: List[String]): Unit = { - val outputFile: File = args match { - case fileName :: Nil => - val file = new File(fileName) - //if (file.exists()) throw new IllegalStateException("This would overwrite a file so bailing now in case that would be bad") - file - case _ => throw new IllegalArgumentException("Usage: BucketMetadata ") - } - - object Client extends EsClient(esUrl) { - } - - val scrollTime = new FiniteDuration(5, TimeUnit.MINUTES) - val scrollSize = 500 - val currentIndex = Client.currentIndex - - System.err.println(s"Using index $currentIndex") - - val fileOutputStream = new FileOutputStream(outputFile) - val compressOutputStream = new BZip2CompressorOutputStream(fileOutputStream) - val sw = new OutputStreamWriter(compressOutputStream) - System.err.println(s"Output encoding: ${sw.getEncoding}") - val writer = new BufferedWriter(sw) - - try { - val initialResults = initialQuery(Client.client, currentIndex, scrollTime, scrollSize) - Await.ready(recurse(initialResults, writer, 0L, 0L), Duration.Inf) - } finally { - Client.client.close() - writer.close() - } - - @tailrec - def recurse(lastResponse: SearchResponse, outputWriter: BufferedWriter, done: Long, warnings: Long): Future[Unit] = { - val newDone = done + scrollSize - val hits = lastResponse.hits.hits - if(hits.nonEmpty) { - val newWarnings = writeMetadata(outputWriter, hits) + warnings - System.err.println(scrollPercentage(lastResponse, newDone, newWarnings)) - val scrollResponse = performScroll(Client.client, lastResponse.scrollId.get, scrollTime) - Thread.sleep(500) - //if (done < 100000) - recurse(scrollResponse, outputWriter, newDone, newWarnings) - //else Future.successful(()) - } else { - System.err.println("No more results found") - Future.successful(()) - } - } - - } - - override def usageError: Nothing = { - System.err.println("Usage: UpdateSettings ") - sys.exit(1) - } - - def scrollPercentage(scroll: SearchResponse, done: Long, warnings: Long): String = { - val total = scroll.hits.total.value - val percentage = (Math.min(done,total).toFloat / total) * 100 - s"Extracted ${Math.min(done,total)} of $total ($percentage%) with $warnings warnings" - } - - def initialQuery(client: ElasticClient, index: String, scrollTime: FiniteDuration, scrollSize: Int) : SearchResponse = { - val queryType = matchAllQuery() - //val queryType = matchQuery("id", "cbaa4f052b1d4ae996e63ee1d07ada0092b4b581") - - val queryResponse = client.execute({ - search(index) - .scroll(scrollTime) - .size(scrollSize) - .query(queryType) - .fetchSource(false) - .sourceInclude( - //.docValues( - "id", - "uploadedBy", - "uploadTime", - "uploadInfo.filename", - "identifiers.*", - "lastModified" - ) - }).await - - queryResponse.status match { - case 200 => queryResponse.result - case _ => - client.close() - throw new Exception("Failed performing search query") - } - } - - def performScroll(client: ElasticClient, scrollId: String, scrollTime: FiniteDuration): SearchResponse = { - val scrollResponse = client.execute({ - searchScroll(scrollId) - .keepAlive(scrollTime) - }).await - - scrollResponse.status match { - case 200 => scrollResponse.result - case _ => - client.close() - throw new Exception("Failed performing bulk index") - } - } - - def writeMetadata(writer: BufferedWriter, hits: Array[SearchHit]): Long = { - def jsObjectToStringMap(obj: JsObject): Map[String, String] = { - obj.fields.collect { - case (key, JsString(value)) => key -> value - }.toMap - } - - val jsonLines = hits.flatMap { hit => - val source = Json.parse(hit.sourceAsString) - (source \ "id").asOpt[String] map { id => - val doc = EsDocumentWithMetadata( - id = id, - lastModified = (source \ "lastModified").asOpt[String], - uploadedBy = (source \ "uploadedBy").asOpt[String], - uploadTime = (source \ "uploadTime").asOpt[String], - fileName = (source \ "uploadInfo" \ "filename").asOpt[String], - identifiers = (source \ "identifiers").asOpt[JsObject].map(jsObjectToStringMap).getOrElse(Map.empty) - ) - Json.stringify(Json.toJson(doc)) - } - } - - jsonLines.foreach{ json => - writer.write(json) - writer.newLine() - } - writer.flush() - - hits.length - jsonLines.length - } -} - -case class EsDocumentWithMetadata( - id: String, - lastModified: Option[String], - uploadedBy: Option[String], - uploadTime: Option[String], - fileName: Option[String], - identifiers: scala.collection.immutable.Map[String, String] = Map.empty - ) - -object EsDocumentWithMetadata { - implicit val formats: Format[EsDocumentWithMetadata] = Json.using[Json.WithDefaultValues].format[EsDocumentWithMetadata] -} diff --git a/scripts/src/main/scala/com/gu/mediaservice/scripts/EsScript.scala b/scripts/src/main/scala/com/gu/mediaservice/scripts/EsScript.scala deleted file mode 100644 index 57d1d4ee26d..00000000000 --- a/scripts/src/main/scala/com/gu/mediaservice/scripts/EsScript.scala +++ /dev/null @@ -1,452 +0,0 @@ -package com.gu.mediaservice.scripts - -import com.gu.mediaservice.lib.elasticsearch.{ElasticSearchClient, Mappings} -import com.sksamuel.elastic4s.ElasticDsl._ -import com.sksamuel.elastic4s.handlers.index.GetIndexResponse -import com.sksamuel.elastic4s.requests.bulk.BulkResponse -import com.sksamuel.elastic4s.requests.indexes.IndexRequest -import com.sksamuel.elastic4s.requests.searches.{SearchHit, SearchResponse} -import com.sksamuel.elastic4s.{Indexes, Response} -import org.joda.time.DateTime -import play.api.libs.json.Json - -import java.io.{File, PrintWriter} -import java.util.concurrent.TimeUnit -import scala.annotation.tailrec -import scala.concurrent.ExecutionContext.Implicits.global -import scala.concurrent.duration.{Duration, DurationInt, FiniteDuration, SECONDS} -import scala.concurrent.{Await, Future} - - -object Reindex extends EsScript { - - def run(esUrl: String, args: List[String]) = { - - object IndexClient extends EsClient(esUrl) { - val srcIndexVersionCheck = """images_(\d+)""".r - val srcIndexVersion = this.currentIndex match { - case srcIndexVersionCheck(version) => version.toInt - case _ => 1 - } - val nextIndex = s"${imagesIndexPrefix}_${srcIndexVersion+1}" - } - - def raiseError(msg: String) = { - System.err.println(s"Reindex error on: $esUrl : $msg ") - System.err.println("Exiting...") - IndexClient.client.close() - System.exit(1) - } - - def validateCurrentState(esClient: ElasticSearchClient, from: Option[DateTime]) = { - if(from.exists(_.isAfter(DateTime.now()))) - raiseError("DateTime parameter 'from' must be earlier than the current time" ) - } - - def getArg(argKey: String): Option[String] = { - args.find(_ contains s"${argKey}=") - .map(_ replaceFirst(s"${argKey}=", "")) - } - - val scrollTime = new FiniteDuration(5, TimeUnit.MINUTES) - val scrollSize = 500 - val currentIndex = IndexClient.currentIndex - val newIndex = getArg("NEW_INDEX") match { - case Some(arg) => arg - case None => IndexClient.nextIndex - } - val from = getArg("FROM_TIME") match { - case Some(arg) => Some(DateTime.parse(arg)) - case None => None - } - validateCurrentState(IndexClient, from) - Await.result(reindex(from, IndexClient), Duration.Inf) - println(s"Pointing $esImagesReadAlias to new index: $newIndex") - IndexClient.changeAliasTo(newIndex, currentIndex, esImagesReadAlias) - println(s"Finished reindexing from $currentIndex to $newIndex") - IndexClient.client.close() - - def reindex(from: Option[DateTime], esClient: ElasticSearchClient) : Future[SearchResponse] = { - - def _scroll(scroll: SearchResponse, done: Long = 0): Future[SearchResponse] = { - val client = esClient.client - val currentBatch = done + scrollSize - System.out.println(scrollPercentage(scroll, currentBatch, done)) - - def bulkFromHits(hits: Array[SearchHit]): BulkResponse = { - val bulkRequests: Array[IndexRequest] = hits.map { hit => - indexInto(newIndex) - .withId(hit.id) - .source(hit.sourceAsString) - } - - val bulkResponse = IndexClient.client.execute({ - bulk(bulkRequests) - }).await - - bulkResponse.status match { - case 200 => bulkResponse.result - case _ => { - IndexClient.client.close() - throw new Exception("Failed performing bulk index") - } - } - } - - def scrollPercentage(scroll: SearchResponse, currentBatch: Long, done: Long): String = { - val total = scroll.hits.total.value - // roughly accurate as we're using done, which is relative to scrollSize, rather than the actual number of docs in the new index - val percentage = (Math.min(done,total).toFloat / total) * 100 - s"Reindexing ${Math.min(currentBatch,total)} of $total ($percentage%)" - } - - def performScroll(scrollId: String, scrollTime: FiniteDuration): SearchResponse = { - val scrollResponse = IndexClient.client.execute({ - searchScroll(scrollId) - .keepAlive(scrollTime) - }).await - - scrollResponse.status match { - case 200 => scrollResponse.result - case _ => { - IndexClient.client.close() - throw new Exception("Failed performing bulk index") - } - } - } - - def analyseBulkResponse(bulkResponse: BulkResponse) = { - val successes = bulkResponse.items.filter(item => item.status == 201).map(item => item.id) - val failures = bulkResponse.items.filterNot(item => item.status == 201).map(item => item.id) - println(s"...added ${successes.length}/${bulkResponse.items.length} items in ${bulkResponse.took} ms (${failures.length} failures)") - if (failures.nonEmpty) println(s"......failure IDs: $failures") - } - - val hits = scroll.hits.hits - if(hits.nonEmpty) { - val bulkResponse = bulkFromHits(hits) - analyseBulkResponse(bulkResponse) - val scrollResponse = performScroll(scroll.scrollId.get, scrollTime) - _scroll(scrollResponse, currentBatch) - } else { - println("No more results found") - Future.successful[SearchResponse](scroll) - } - } - - def query(from: Option[DateTime]) : SearchResponse = { - val queryType = from.map(time => - rangeQuery("lastModified").gte(from.get.getMillis).lte(DateTime.now.getMillis) - ).getOrElse( - matchAllQuery() - ) - - val queryResponse = IndexClient.client.execute({ - search(currentIndex) -// .types(Mappings.dummyType) - .scroll(scrollTime) - .size(scrollSize) - .query(queryType) - }).await - - queryResponse.status match { - case 200 => queryResponse.result - case _ => { - IndexClient.client.close() - throw new Exception("Failed performing search query") - } - } - } - - // if no 'from' time parameter is passed, create a new index - if(from.isEmpty) { - IndexClient.createImageIndex(newIndex) - } else { - println(s"Reindexing documents modified since: ${from.toString}") - } - - val startTime = DateTime.now() - println(s"Reindex started at: $startTime") - println(s"Reindexing from: ${IndexClient.currentIndex} to: $newIndex") - val scrollResponse = query(from) - _scroll(scrollResponse) flatMap { case (response: SearchResponse) => - println(s"Pointing ${IndexClient.imagesCurrentAlias} to new index: $newIndex") - IndexClient.changeAliasTo(newIndex, currentIndex) - - val changedDocuments: Long = query(Option(startTime)).hits.total.value - println(s"$changedDocuments changed documents since start") - - if(changedDocuments > 0) { - println(s"Reindexing changes since start time: $startTime") - val recurseResponse = reindex(Option(startTime), esClient) - recurseResponse - } else { - Future.successful(response) - } - } - } - } - - def usageError: Nothing = { - System.err.println("Usage: Reindex error [NEW_INDEX=, FROM_TIME=]") - sys.exit(1) - } -} - -// TODO: add the ability to update a section of the mapping -object UpdateMapping extends EsScript { - - def run(esUrl: String, extraArgs: List[String]): Unit = { - - object MappingsClient extends EsClient(esUrl) { - def updateMappings(specifiedIndex: Option[String]): Unit = { - val index = specifiedIndex.getOrElse(imagesCurrentAlias) - println(s"Updating mapping on index: $index") - - val result = client.execute { - putMapping(Indexes(index)) as { - Mappings.imageMapping.properties - } - }.await - - result.status match { - case 200 => println(Json.prettyPrint(Json.parse(result.body.get))) - case _ => println(s"Failed updating mapping: ${result.error}") - } - - client.close - } - - } - - MappingsClient.updateMappings(extraArgs.headOption) - } - - def usageError: Nothing = { - System.err.println("Usage: UpdateMapping ") - sys.exit(1) - } -} - -object GetMapping extends EsScript { - - def run(esUrl: String, extraArgs: List[String]): Unit = { - - object MappingsClient extends EsClient(esUrl) { - def getMappings(specifiedIndex: Option[String]): Unit = { - val index = specifiedIndex.getOrElse(imagesCurrentAlias) - println(s"Getting mapping on index: $index") - - val result = Await.result(client.execute( - getMapping(index) - ), Duration(30, SECONDS)) - - result.status match { - case 200 => println(Json.prettyPrint(Json.parse(result.body.get))) - case _ => println(s"Failed getting mapping: ${result.error}") - } - - - client.close() - } - } - - MappingsClient.getMappings(extraArgs.headOption) - } - - def usageError: Nothing = { - System.err.println("Usage: GetMapping ") - sys.exit(1) - } -} - -object UpdateSettings extends EsScript { - - def run(esUrl: String, extraArgs: List[String]): Unit = { - - object SettingsClient extends EsClient(esUrl) { - if (!url.contains("localhost")) { - System.err.println(s"You can only run UpdateSettings on localhost, not '$esUrl'") - System.exit(1) - } - - def updateIdxSettings(specifiedIndex: Option[String]): Unit = { - - val index = specifiedIndex.getOrElse(imagesCurrentAlias) - println(s"Getting mapping on index: $index") - - val settingsToAdd: Map[String, String] = Map("max_result_window" -> "25000") - - val resultFut = for { - _ <- client.execute(closeIndex(index)) - result <- { - client.execute( - updateSettings(index) - .add(settingsToAdd) - ) - } - _ <- client.execute(openIndex(index)) - } yield result - - val result = Await.result(resultFut, Duration(30, SECONDS)) - - result.status match { - case 200 => println(Json.prettyPrint(Json.parse(result.body.get))) - case _ => println(s"Failed updating index settings: ${result.error}") - } - - client.close - } - } - - SettingsClient.updateIdxSettings(extraArgs.headOption) - } - - def usageError: Nothing = { - System.err.println("Usage: UpdateSettings ") - sys.exit(1) - } -} - -object GetSettings extends EsScript { - - def run(esUrl: String, extraArgs: List[String]): Unit = { - - object SettingsClient extends EsClient(esUrl) { - def getIdxSettings(specifiedIndex: Option[String]): Unit = { - val index = specifiedIndex.getOrElse(imagesCurrentAlias) - println(s"Getting settings on index: $index") - - val result = Await.result(client.execute( - getSettings(index) - ), Duration(30, SECONDS)) - - result.status match { - case 200 => println(Json.prettyPrint(Json.parse(result.body.get))) - case _ => println(s"Failed getting settings: ${result.error}") - } - - - client.close() - } - } - - SettingsClient.getIdxSettings(extraArgs.headOption) - } - - def usageError: Nothing = { - System.err.println("Usage: GetMapping ") - sys.exit(1) - } -} -object DownloadAllEsIds extends EsScript { - - def run(esUrl: String, extraArgs: List[String]): Unit = { - - println(s"Getting all ids from $esUrl") - - object Client extends EsClient(esUrl) { - - def writeAllEsIdsToFile(filename: String) = { - - val file = new File(filename) - - val writer = new PrintWriter(file) - - @tailrec - def processResponseAndStartNext(prevResponse: Response[SearchResponse]): Unit = { - if(prevResponse.status != 200) { - writer.close() - client.close() - throw new Exception("Failed performing search query") - } - - println(s"Adding ${prevResponse.result.hits.hits.length} IDs to ${file.getAbsolutePath}") - - prevResponse.result.hits.hits.map(_.id).foreach(writer.println) - - // can't be map/foreach if we want tail recursion (for efficiency) - prevResponse.result.scrollId match { - case None => () // done - case Some(scrollId) => - processResponseAndStartNext( - client.execute( - searchScroll(scrollId) - .keepAlive(10.seconds) - ).await - ) - } - } - - processResponseAndStartNext( - client.execute( - search(currentIndex) - .size(1000) - .storedFields("_id") // ensures we only return the id field - .scroll(1.minute) - ).await - ) - - writer.close() - } - - } - - Client.writeAllEsIdsToFile(extraArgs.headOption.getOrElse(throw new Exception("No filename specified"))) - - } - - - def usageError: Nothing = { - System.err.println("Usage: GetAllEsIds ") - sys.exit(1) - } -} - -abstract class EsScript { - // FIXME: Get from config (no can do as Config is coupled to Play) - final val esImagesAlias = "Images_Current" - final val esImagesReadAlias = "Images_Current" - final val esShards = 5 - final val esReplicas = 0 - - def log(msg: String) = System.out.println(s"[${getClass.getName}]: $msg") - - def apply(args: List[String]): Unit = { - // FIXME: Use Stage to get host (for some reason this isn't working) - val (esUrl, extraArgs) = args match { - case h :: t => (h, t) - case _ => usageError - } - - run(esUrl, extraArgs) - } - - class EsClient(val url: String) extends ElasticSearchClient { - override def imagesCurrentAlias = esImagesAlias - override def shards = esShards - override def replicas = esReplicas - - @deprecated(message = "Should not be used in scripts", since = "2021-Jul-07") - lazy val imagesMigrationAlias: String = "Images_Migration" - - lazy val indexResult: Response[Map[String, GetIndexResponse]] = client.execute { - getIndex(imagesCurrentAlias) - }.await - - lazy val currentIndex: String = indexResult.status match { - case 200 => { - indexResult.result.keySet.toList match { - case head::Nil => head - case _ => throw new Exception(s"There should only be one index associated with alias before reindexing, " + - s"however there was: ${indexResult.result.keys}") - } - } - case _ => throw new Exception(s"Failed updating mapping: ${indexResult.error}") - } - - } - - def run(esUrl: String, args: List[String]): Unit - def usageError: Nothing -} diff --git a/scripts/src/main/scala/com/gu/mediaservice/scripts/LoadFromS3Bucket.scala b/scripts/src/main/scala/com/gu/mediaservice/scripts/LoadFromS3Bucket.scala deleted file mode 100644 index 7e7752f8b9c..00000000000 --- a/scripts/src/main/scala/com/gu/mediaservice/scripts/LoadFromS3Bucket.scala +++ /dev/null @@ -1,44 +0,0 @@ -package com.gu.mediaservice.scripts - -import com.amazonaws.auth.profile.ProfileCredentialsProvider -import com.amazonaws.auth.{AWSCredentialsProviderChain, InstanceProfileCredentialsProvider} -import com.amazonaws.services.s3.AmazonS3ClientBuilder -import org.apache.http.client.methods.HttpPost -import org.apache.http.entity.{ContentType, InputStreamEntity} -import org.apache.http.impl.client.HttpClients - -import scala.jdk.CollectionConverters._ - -object LoadFromS3Bucket { - - def apply(args: List[String]): Unit = { - - val (bucket, loaderEndpoint) = args match { - case List(b, l) => (b, l) - case _ => sys.error("Usage: LoadFromS3Bucket ") - } - - lazy val awsCredentials = new AWSCredentialsProviderChain( - new ProfileCredentialsProvider("media-service"), - InstanceProfileCredentialsProvider.getInstance() - ) - - val client = AmazonS3ClientBuilder.standard().withCredentials(awsCredentials).build() - - val keys = client.listObjects(bucket).getObjectSummaries.asScala.map(_.getKey) - - val httpClient = HttpClients.createDefault - - for (key <- keys) { - val obj = client.getObject(bucket, key) - val postReq = new HttpPost(loaderEndpoint) - val length = obj.getObjectMetadata.getContentLength - val entity = new InputStreamEntity(obj.getObjectContent, length, ContentType.DEFAULT_BINARY) - postReq.setEntity(entity) - httpClient.execute(postReq).close() - println(s"Loaded image $key") - } - - } - -} diff --git a/scripts/src/main/scala/com/gu/mediaservice/scripts/Main.scala b/scripts/src/main/scala/com/gu/mediaservice/scripts/Main.scala deleted file mode 100644 index f7db6dc4d4a..00000000000 --- a/scripts/src/main/scala/com/gu/mediaservice/scripts/Main.scala +++ /dev/null @@ -1,25 +0,0 @@ -package com.gu.mediaservice.scripts - - -object Main extends App { - - args.toList match { - case "LoadFromS3Bucket" :: as => LoadFromS3Bucket(as) - case "Reindex" :: as => Reindex(as) - case "DownloadAllEsIds" :: as => DownloadAllEsIds(as) - case "GetMapping" :: as => GetMapping(as) - case "UpdateMapping" :: as => UpdateMapping(as) - case "GetSettings" :: as => GetSettings(as) - case "UpdateSettings" :: as => UpdateSettings(as) - case "ConvertConfig" :: as => ConvertConfig(as) - case "BucketMetadata" :: as => BucketMetadata(as) - case "DecodeComparator" :: as => DecodeComparator(as) - case "EnactS3Changes" :: as => EnactS3Changes(as) - case "BulkDeleteS3Files":: as => BulkDeleteS3Files(as) - case "EsMetadata" :: as => EsImageMetadata(as) - case "ProposeS3Changes" :: as => ProposeS3Changes(as) - case "BackfillEditLastModified" :: as => BackfillEditLastModified(as) - case a :: _ => sys.error(s"Unrecognised command: $a") - case Nil => sys.error("Usage: ") - } -} diff --git a/scripts/src/main/scala/com/gu/mediaservice/scripts/ProposeS3Changes.scala b/scripts/src/main/scala/com/gu/mediaservice/scripts/ProposeS3Changes.scala deleted file mode 100644 index f6a5a4d8e8d..00000000000 --- a/scripts/src/main/scala/com/gu/mediaservice/scripts/ProposeS3Changes.scala +++ /dev/null @@ -1,254 +0,0 @@ -package com.gu.mediaservice.scripts - -import com.gu.mediaservice.JsonDiff -import com.gu.mediaservice.lib.ImageStorageProps -import com.gu.mediaservice.lib.net.URI -import org.apache.commons.compress.compressors.bzip2.{BZip2CompressorInputStream, BZip2CompressorOutputStream} -import org.joda.time.DateTime -import play.api.libs.json.Json - -import java.io._ -import scala.collection.mutable -import scala.io.Source -import scala.language.postfixOps - -object ProposeS3Changes { - def apply(args: List[String]): Unit = { - args match { - case bucketMetadata :: esMetadata :: picdarCsv :: outputFile :: Nil => proposeS3Changes( - new File(bucketMetadata), - new File(esMetadata), - new File(picdarCsv), - new File(s"$outputFile.update.jsonl.bz2"), - new File(s"$outputFile.correct.jsonl.bz2"), - new File(s"$outputFile.esonlykeys.txt"), - new File(s"$outputFile.s3onlykeys.txt"), - new File(s"$outputFile.s3badkeys.txt"), - ) - case _ => throw new IllegalArgumentException("Usage: ProposeS3Changes ") - } - } - - def getBzipWriter(outputFile: File) = { - val fileOutputStream = new FileOutputStream(outputFile) - val compressOutputStream = new BZip2CompressorOutputStream(fileOutputStream) - val sw = new OutputStreamWriter(compressOutputStream) - new BufferedWriter(sw) - } - - def proposeS3Changes( - bucketMetadata: File, - esMetadata: File, - picdarCsv: File, - outputFileForJsonUpdate: File, - outputFileForJsonCorrect: File, - outputFileForESKeys: File, - outputFileForS3Keys: File, - outputFileForBadS3Keys: File) = { - val picdarData = readPicdarCsv(picdarCsv) - System.err.println(s"Completed reading ${picdarData.gridToPicdar.size} Picdar mappings") - - val s3Metadata = { - val (md, badKeys) = readS3Metadata(bucketMetadata) - System.err.println(s"Completed reading S3 metadata. ${md.size} records, ${badKeys.size} bad keys") - // write out the bad keys in this scope so they can then be GCd - val outputWriterForBadS3Keys = new FileWriter(outputFileForBadS3Keys) - try { - badKeys.foreach(k => outputWriterForBadS3Keys.append(s"$k\n")) - } finally { - outputWriterForBadS3Keys.close() - } - md - } - - var s3KeysNotYetSeenInEs = s3Metadata.keySet - var esKeysNotInS3 = Set.empty[String] - - System.err.println(s"Starting change proposals...") - val outputWriterForJsonUpdate = getBzipWriter(outputFileForJsonUpdate) - val outputWriterForJsonCorrect = getBzipWriter(outputFileForJsonCorrect) - val outputWriterForESKeys = new FileWriter(outputFileForESKeys) - val outputWriterForS3Keys = new FileWriter(outputFileForS3Keys) - try { - withSourceFromBzipFile(esMetadata){ source => - source - .getLines() - .flatMap(line => Json.fromJson[EsDocumentWithMetadata](Json.parse(line)).asOpt) - .zipWithIndex - .foreach { case (metadata, i) => - if (i % 10000 == 0) System.err.println(s"Processing ES metadata line $i") - val id = metadata.id - val maybeS3 = s3Metadata.get(id) - maybeS3 match { - case Some(s3Metadata) => - s3KeysNotYetSeenInEs -= id - val mergedMetadata = mergeMetadata(metadata, s3Metadata, picdarData) - if (mergedMetadata != mergeMetadata(metadata, mergedMetadata, picdarData)) { - System.err.println(s"Merged metadata for $id not idempotent") - } - if (mergedMetadata != s3Metadata) { - val jsS3 = Json.toJson(s3Metadata) - val jsMerged = Json.toJson(mergedMetadata) - val diff = JsonDiff.diff(jsS3, jsMerged) - outputWriterForJsonUpdate - .append(s"${Json.toJson(Json.obj( - "original" -> jsS3, - "proposed" -> jsMerged, - "diff" -> diff - )).toString()}\n") - } else { - outputWriterForJsonCorrect - .append(s"${Json.toJson(mergedMetadata).toString()}\n") - } - case None => - esKeysNotInS3 += id - } - } - } - - esKeysNotInS3.foreach(k => outputWriterForESKeys.append(s"$k\n")) - s3KeysNotYetSeenInEs.foreach(k => outputWriterForS3Keys.append(s"$k\n")) - } finally { - outputWriterForJsonUpdate.close() - outputWriterForJsonCorrect.close() - outputWriterForESKeys.close() - outputWriterForS3Keys.close() - } - } - - - def mergeMetadata(esMetadata: EsDocumentWithMetadata, s3Metadata: ObjectMetadata, picdarData: PicdarData): ObjectMetadata = { - /* If we can we should retain the legacy keys with _ in so that we don't have to touch the object */ - def bestKeyNameFor(dashVariant: String): String = { - val hasDashVariant = s3Metadata.metadata.contains(dashVariant) - // does this have underscore version of key? - val underscoreVariant = dashVariant.replace("-", "_") - val hasUnderscoreVariant = s3Metadata.metadata.contains(underscoreVariant) - if (hasDashVariant && hasUnderscoreVariant) { - System.err.println(s"Warning: both dash and underscore keys on ${s3Metadata.key}") - } - if (hasUnderscoreVariant) - underscoreVariant - else - dashVariant - } - - /* Find the "best" value, trying hard to detect values that haven't really changed despite encoding or format changes */ - def bestValue(maybeEsValue: Option[String], maybeS3Value: Option[String], isDate: Boolean = false): Option[String] = { - def isSame(esValue: String, s3Value: String): Boolean = { - val decodedS3 = URI.decode(s3Value) - if (!isDate) { - decodedS3 == esValue - } else { - val s3Date = DateTime.parse(decodedS3) - val esDate = DateTime.parse(esValue) - s3Date.equals(esDate) - } - } - (maybeEsValue, maybeS3Value) match { - case (None, s3Value) => s3Value - case (Some(esValue), Some(s3Value)) if isSame(esValue, s3Value) => Some(s3Value) - case (Some(esValue), _) => Some(URI.encode(esValue)) - } - } - - val filenameKey = bestKeyNameFor(ImageStorageProps.filenameMetadataKey) - val uploadedByKey = bestKeyNameFor(ImageStorageProps.uploadedByMetadataKey) - val uploadTimeKey = bestKeyNameFor(ImageStorageProps.uploadTimeMetadataKey) - - if (metadataEquivalent(esMetadata, s3Metadata)) { - s3Metadata - } else { - // filename: taken from ES if it exists, then from S3, otherwise empty - val fileName = bestValue(esMetadata.fileName, s3Metadata.metadata.get(filenameKey)) - .map(s => s.replaceAll(s" (${esMetadata.id})", "")) - // uploaded by: taken from ES if it exists, then from S3, otherwise empty - val uploadedBy = bestValue(esMetadata.uploadedBy, s3Metadata.metadata.get(uploadedByKey)) - // uploaded time: taken from ES if it exists, then from S3, otherwise empty - val uploadTime = bestValue(esMetadata.uploadTime, s3Metadata.metadata.get(uploadTimeKey), isDate = true) - - // Find ALL identifiers in elasticsearch (put "identifier!" on the front and make lowercase) - val esIdentifiers = esMetadata.identifiers - .map{ case (key, value ) => s"${ImageStorageProps.identifierMetadataKeyPrefix}$key".toLowerCase -> URI.encode(value)} - - // Find all OUR identifiers in S3 (must have "identifier!" on the front) - val s3Identifiers = s3Metadata.metadata - .filter{case (key, _) => key.startsWith(ImageStorageProps.identifierMetadataKeyPrefix)} - - val picdarIdEntry = picdarData.gridToPicdar.get(esMetadata.id).map(s"${ImageStorageProps.identifierMetadataKeyPrefix}picdarurn" ->) - - // Merge the two maps together with any picdar entry - val allIdentifierKeys = s3Identifiers.keySet ++ esIdentifiers.keySet - val identifiers = allIdentifierKeys.foldLeft(Map.empty[String, String]) { case (acc, key) => - acc ++ bestValue(esIdentifiers.get(key), s3Identifiers.get(key)).map(key ->) - } ++ picdarIdEntry - - ObjectMetadata( - key = s3Metadata.key, - lastModified = s3Metadata.lastModified, - metadata = identifiers - ++ fileName.map(fn => filenameKey -> fn) - ++ uploadedBy.map(ub => uploadedByKey -> ub) - ++ uploadTime.map(ut => uploadTimeKey -> ut) - ) - } - } - - def metadataEquivalent(metadata: EsDocumentWithMetadata, s3Metadata: ObjectMetadata): Boolean = { - metadata.uploadTime == s3Metadata.metadata.get(ImageStorageProps.uploadTimeMetadataKey) && - metadata.uploadedBy == s3Metadata.metadata.get(ImageStorageProps.uploadedByMetadataKey) && - metadata.fileName == s3Metadata.metadata.get(ImageStorageProps.filenameMetadataKey) && - metadata.identifiers.map{case (key, value) => - ImageStorageProps.identifierMetadataKeyPrefix + key.toLowerCase -> value - } == s3Metadata.metadata.filter{ case (key, _) => key.startsWith(ImageStorageProps.identifierMetadataKeyPrefix)} - } - - private val hex = """[0-9a-f]""" - private val GoodKey = s"""^($hex)/($hex)/($hex)/($hex)/($hex)/($hex)/(\\1\\2\\3\\4\\5\\6$hex{34})$$""".r - def readS3Metadata(bucketMetadata: File): (Map[String, ObjectMetadata], List[String]) = { - val (goodEntries, badValues) = withSourceFromBzipFile(bucketMetadata){ source => - source.getLines().zipWithIndex.foldLeft[(mutable.Builder[(String, ObjectMetadata), Map[String, ObjectMetadata]], List[String])] (Map.newBuilder[String, ObjectMetadata], Nil) { case ((goodEntries, badKeys), (line, i)) => - if (i % 10000 == 0) System.err.println(s"Loading index $i") - Json.fromJson[ObjectMetadata](Json.parse(line)).asOpt match { - case Some(objMd@ObjectMetadata(GoodKey(_, _, _, _, _, _, id), _, _)) => (goodEntries += (id -> objMd), badKeys) - case Some(ObjectMetadata(badKey, _, _)) => (goodEntries, badKey :: badKeys) - case None => (goodEntries, badKeys) - } - } - } - (goodEntries.result(), badValues) - } - - case class PicdarData(gridToPicdar: Map[String, String]) - - def readPicdarCsv(picdarCsvFile: File): PicdarData = { - val source = Source.fromFile(picdarCsvFile) - try { - val gridIdAndPicdarId = source.getLines().collect { - case line if line.contains(",") => - line.takeWhile(_ != ',') -> line.dropWhile(_ != ',').drop(1) - }.toList - val gridIds = gridIdAndPicdarId.map{ case (gridId, _) => gridId } - val picdarIds = gridIdAndPicdarId.map{ case (_, picdarId) => picdarId } - if (gridIds.length != gridIds.toSet.size) { - throw new IllegalArgumentException(s"Picdar CSV file contains duplicate IDs: ${gridIds.length} / ${gridIds.toSet.size} uniques") - } - val gridToPicdar = gridIdAndPicdarId.toMap - //val picdarToGrid = gridIdAndPicdarId.map(_.swap).toMap - PicdarData(gridToPicdar) - } finally { - source.close() - } - } - - private def withSourceFromBzipFile[T](file: File)(f: Source => T) = { - val fileInputStream = new FileInputStream(file) - val compressInputStream = new BZip2CompressorInputStream(fileInputStream) - val source = Source.fromInputStream(compressInputStream) - try { - f(source) - } finally { - source.close() - } - } -} diff --git a/thrall/app/ThrallComponents.scala b/thrall/app/ThrallComponents.scala index 80b80712185..49ef631081a 100644 --- a/thrall/app/ThrallComponents.scala +++ b/thrall/app/ThrallComponents.scala @@ -1,66 +1,86 @@ -import org.apache.pekko.Done -import org.apache.pekko.stream.scaladsl.Source import com.gu.kinesis.{KinesisRecord, KinesisSource, ConsumerConfig => KclPekkoStreamConfig} import com.gu.mediaservice.GridClient -import com.gu.mediaservice.lib.config.Services -import com.gu.mediaservice.lib.aws.{S3Ops, ThrallMessageSender} -import com.gu.mediaservice.lib.management.InnerServiceStatusCheckController +import com.gu.mediaservice.lib.aws.{S3, S3Vectors, ThrallMessageSender} +import com.gu.mediaservice.lib.instances.{Instances, InstancesClient} +import com.gu.mediaservice.lib.logging.MarkerMap import com.gu.mediaservice.lib.metadata.SoftDeletedMetadataTable import com.gu.mediaservice.lib.play.GridComponents import com.typesafe.scalalogging.StrictLogging import controllers.{AssetsComponents, HealthCheck, ReaperController, ThrallController} +import instances.{InstanceMessageSender, InstanceUsageMessage} import lib._ import lib.elasticsearch._ import lib.kinesis.{KinesisConfig, ThrallEventConsumer} +import org.apache.pekko.Done +import org.apache.pekko.stream.scaladsl.Source import play.api.ApplicationLoader.Context import router.Routes +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.sqs.SqsClient +import software.amazon.awssdk.services.sqs.model.GetQueueUrlRequest import scala.concurrent.duration._ import scala.concurrent.{Await, Future} import scala.language.postfixOps -import scala.util.{Failure, Success} -class ThrallComponents(context: Context) extends GridComponents(context, new ThrallConfig(_)) with StrictLogging with AssetsComponents { +class ThrallComponents(context: Context) extends GridComponents(context, new ThrallConfig(_)) with StrictLogging with AssetsComponents + with Instances { final override val buildInfo = utils.buildinfo.BuildInfo val store = new ThrallStore(config) val metadataEditorNotifications = new MetadataEditorNotifications(config) val thrallMetrics = new ThrallMetrics(config, actorSystem, applicationLifecycle) - val es = new ElasticSearch(config.esConfig, Some(thrallMetrics), actorSystem.scheduler) - es.ensureIndexExistsAndAliasAssigned() + val es = new ElasticSearch(config.esConfig, Some(thrallMetrics), actorSystem.scheduler, new InstancesClient(config, wsClient)) - val services: Services = new Services(config.domainRoot, config.serviceHosts, Set.empty) - val gridClient: GridClient = GridClient(services)(wsClient) + val gridClient: GridClient = GridClient(config.services)(wsClient) + + val sqsClient: SqsClient = SqsClient.builder() + .region(Region.EU_WEST_1) + .build() + + val instanceUsageQueueUrl: String = { + val getQueueRequest = GetQueueUrlRequest.builder() + .queueName(config.instanceUsageQueueName) + .build() + sqsClient.getQueueUrl(getQueueRequest).queueUrl + } + + private val instanceMessageSender = new InstanceMessageSender(sqsClient, instanceUsageQueueUrl) // before firing up anything to consume streams or say we are OK let's do the critical good to go check - private val goodToGoCheckResult = Await.ready(GoodToGoCheck.run(es), 30 seconds) - goodToGoCheckResult.value match { - case Some(Success(_)) => // all good - logger.info("Passed good to go") - case Some(Failure(exception)) => - logger.error("Failed good to go, aborting startup", exception) - throw exception - case other => - logger.warn(s"Result of good to go was $other, aborting as this result doesn't make sense") - throw new IllegalStateException("Good to go test didn't pass or throw exception") + def ensureIndexes(): Future[Unit] = { + logger.info("Ensuring indexes") + getInstances().map { instances => + instances.foreach{ instance => + logger.info("Ensuing index for: " + instance.id) + es.ensureIndexExistsAndAliasAssigned(alias = es.imagesCurrentAlias(instance), index = instance.id + "_index") + } + } } + Await.ready(ensureIndexes(), 60 seconds) val messageSender = new ThrallMessageSender(config.thrallKinesisStreamConfig) + val lowPriorityMessageSender = new ThrallMessageSender(config.thrallKinesisLowPriorityStreamConfig) val highPriorityKinesisConfig: KclPekkoStreamConfig = KinesisConfig.kinesisConfig(config.kinesisConfig) val lowPriorityKinesisConfig: KclPekkoStreamConfig = KinesisConfig.kinesisConfig(config.kinesisLowPriorityConfig) val uiSource: Source[KinesisRecord, Future[Done]] = KinesisSource(highPriorityKinesisConfig) val automationSource: Source[KinesisRecord, Future[Done]] = KinesisSource(lowPriorityKinesisConfig) - val migrationSourceWithSender: MigrationSourceWithSender = MigrationSourceWithSender(materializer, auth.innerServiceCall, es, gridClient, config.projectionParallelism) + val migrationSourceWithSender: MigrationSourceWithSender = new MigrationSourceWithSenderFactory(materializer, auth.innerServiceCall, es, gridClient, config.projectionParallelism, new InstancesClient(config, wsClient)).build() val thrallEventConsumer = new ThrallEventConsumer( es, thrallMetrics, store, metadataEditorNotifications, - actorSystem + actorSystem, + gridClient, + auth, + instanceMessageSender, + events, + messageSender ) val thrallStreamProcessor = new ThrallStreamProcessor( @@ -73,22 +93,40 @@ class ThrallComponents(context: Context) extends GridComponents(context, new Thr val streamRunning: Future[Done] = thrallStreamProcessor.run() - val s3 = S3Ops.buildS3Client(config) - val syncChecker = new SyncChecker( - s3, - es, - config.imageBucket, - actorSystem - ) - val syncCheckerStream: Future[Done] = syncChecker.run() + val s3 = new S3(config) + //val s3Vectors = new S3Vectors(config) + + Source.repeat(()).throttle(1, per = 5.minute).map(_ => { + implicit val logMarker: MarkerMap = MarkerMap() + getInstances().map { instances => + // Foreach instance; query elastic for number image and total file size + instances.foreach { instance => + logger.info("Checking usage for: " + instance) + implicit val i = instance + val eventualImagesCount = es.countTotal(isSoftedDeleted = false) + val eventualSoftDeletedCount = es.countTotal(isSoftedDeleted = true) + val eventualTotalImageSize = es.countTotalImageSize() + for { + imageCount <- eventualImagesCount + softDeletedCount <- eventualSoftDeletedCount + totalImageSize <- eventualTotalImageSize + } yield { + logger.info(s"Instance ${instance.id} has $imageCount/$softDeletedCount images with total size: " + totalImageSize) + instanceMessageSender.send(InstanceUsageMessage(instance = instance.id, imageCount = imageCount, softDeletedCount = softDeletedCount, totalImageSize = totalImageSize)) + } + } + } + // TODO Block? + + }).run() + val softDeletedMetadataTable = new SoftDeletedMetadataTable(config) val maybeCustomReapableEligibility = config.maybeReapableEligibilityClass(applicationLifecycle) - val thrallController = new ThrallController(es, store, migrationSourceWithSender.send, messageSender, actorSystem, auth, config.services, controllerComponents, gridClient) - val reaperController = new ReaperController(es, store, authorisation, config, actorSystem.scheduler, maybeCustomReapableEligibility, softDeletedMetadataTable, thrallMetrics, auth, config.services, controllerComponents) + val thrallController = new ThrallController(es, store, migrationSourceWithSender.send, messageSender, actorSystem, auth, config.services, controllerComponents, gridClient, s3, config.imageBucket, lowPriorityMessageSender) + val reaperController = new ReaperController(es, store, authorisation, config, actorSystem.scheduler, maybeCustomReapableEligibility, softDeletedMetadataTable, thrallMetrics, auth, config.services, controllerComponents, wsClient, events) val healthCheckController = new HealthCheck(es, streamRunning.isCompleted, config, controllerComponents) - val InnerServiceStatusCheckController = new InnerServiceStatusCheckController(auth, controllerComponents, config.services, wsClient) - override lazy val router = new Routes(httpErrorHandler, thrallController, reaperController, healthCheckController, management, InnerServiceStatusCheckController, assets) + override lazy val router = new Routes(httpErrorHandler, thrallController, reaperController, healthCheckController, management, assets).withPrefix("/thrall/") } diff --git a/thrall/app/controllers/HealthCheck.scala b/thrall/app/controllers/HealthCheck.scala index 5b23c764e0f..661f43418b2 100644 --- a/thrall/app/controllers/HealthCheck.scala +++ b/thrall/app/controllers/HealthCheck.scala @@ -14,7 +14,7 @@ class HealthCheck(elasticsearch: ElasticSearch, streamRunning: => Boolean, confi override def healthCheck = Action.async { elasticHealth.map { esHealth => - val problems = esHealth ++ streamRunningHealth ++ elasticsearch.migrationStatusRefresherHealth + val problems = esHealth ++ streamRunningHealth // TODO ++ elasticsearch.migrationStatusRefresherHealth if (problems.nonEmpty) { val problemsMessage = problems.mkString(",") logger.warn("Healthcheck failed with problems: " + problemsMessage) diff --git a/thrall/app/controllers/ReaperController.scala b/thrall/app/controllers/ReaperController.scala index cf2b786c124..e32f224cc21 100644 --- a/thrall/app/controllers/ReaperController.scala +++ b/thrall/app/controllers/ReaperController.scala @@ -1,31 +1,38 @@ package controllers -import org.apache.pekko.actor.Scheduler -import com.gu.mediaservice.lib.{DateTimeUtils, ImageIngestOperations} import com.gu.mediaservice.lib.auth.Permissions.DeleteImage import com.gu.mediaservice.lib.auth.{Authentication, Authorisation, BaseControllerWithLoginRedirects} -import com.gu.mediaservice.lib.config.Services +import com.gu.mediaservice.lib.aws.S3Vectors +import com.gu.mediaservice.lib.config.{InstanceForRequest, Services} import com.gu.mediaservice.lib.elasticsearch.ReapableEligibility +import com.gu.mediaservice.lib.events.UsageEvents +import com.gu.mediaservice.lib.instances.Instances import com.gu.mediaservice.lib.logging.{GridLogging, MarkerMap} import com.gu.mediaservice.lib.metadata.SoftDeletedMetadataTable -import com.gu.mediaservice.model.{ImageStatusRecord, SoftDeletedMetadata} -import lib.{BatchDeletionIds, ThrallConfig, ThrallMetrics, ThrallStore} +import com.gu.mediaservice.lib.{DateTimeUtils, ImageIngestOperations} +import com.gu.mediaservice.model.{ImageStatusRecord, Instance, SoftDeletedMetadata} import lib.elasticsearch.ElasticSearch +import lib.{BatchDeletionIds, ThrallConfig, ThrallMetrics, ThrallStore} +import org.apache.pekko.actor.Scheduler import org.joda.time.{DateTime, DateTimeZone} -import play.api.libs.json.{JsValue, Json} +import play.api.libs.json.{JsValue, Json, OWrites} +import play.api.libs.ws.WSClient import play.api.mvc.{Action, AnyContent, ControllerComponents} +import scala.concurrent.duration.DurationInt import scala.concurrent.{ExecutionContext, Future} import scala.jdk.CollectionConverters._ import scala.language.postfixOps import scala.util.control.NonFatal import scala.util.{Failure, Success} + class ReaperController( es: ElasticSearch, store: ThrallStore, + //s3Vectors: S3Vectors, authorisation: Authorisation, - config: ThrallConfig, + val config: ThrallConfig, scheduler: Scheduler, maybeCustomReapableEligibility: Option[ReapableEligibility], softDeletedMetadataTable: SoftDeletedMetadataTable, @@ -33,50 +40,64 @@ class ReaperController( override val auth: Authentication, override val services: Services, override val controllerComponents: ControllerComponents, -)(implicit val ec: ExecutionContext) extends BaseControllerWithLoginRedirects with GridLogging { - - private val CONTROL_FILE_NAME = "PAUSED" + val wsClient: WSClient, + usageEvents: UsageEvents +)(implicit val ec: ExecutionContext) extends BaseControllerWithLoginRedirects with GridLogging with InstanceForRequest with Instances { private val INTERVAL = config.reaperInterval //default 15 minutes, based on max of 1000 per reap, this interval will max out at 96,000 images per day + private val isPaused = config.reaperPaused implicit val logMarker: MarkerMap = MarkerMap() private val isReapable = maybeCustomReapableEligibility getOrElse { new ReapableEligibility { override val maybePersistOnlyTheseCollections: Option[Set[String]] = config.maybePersistOnlyTheseCollections - override val persistenceIdentifier: String = config.persistenceIdentifier } } - (config.maybeReaperBucket, config.maybeReaperCountPerRun) match { - case (Some(reaperBucket), Some(countOfImagesToReap)) => + config.maybeReaperCountPerRun match { + case Some(countOfImagesToReap) => + // We always want the reaps to occur at predictable times (e.g. on the hour, then 15, 30, 45 minutes past) + // However, if the first reap is imminent, then skip it, to avoid double reaps during deployment when both + // instances are up and running simultaneously + val delayUntilFirstReap = DateTimeUtils.timeUntilNextInterval(INTERVAL) match { + case delay if delay < 5.minutes => delay.plus(INTERVAL) + case delay => delay + } scheduler.scheduleAtFixedRate( - initialDelay = DateTimeUtils.timeUntilNextInterval(INTERVAL), // so we always start on multiples of the interval past the hour + initialDelay = delayUntilFirstReap, interval = INTERVAL, - ){ () => + ) { () => try { - if (store.client.doesObjectExist(reaperBucket, CONTROL_FILE_NAME)) { - logger.info("Reaper is paused") - es.countTotalSoftReapable(isReapable).map(metrics.softReapable.increment(Nil, _)) - es.countTotalHardReapable(isReapable, config.hardReapImagesAge).map(metrics.hardReapable.increment(Nil, _)) - } else { - val deletedBy = "reaper" - Future.sequence(Seq( - doBatchSoftReap(countOfImagesToReap, deletedBy), - doBatchHardReap(countOfImagesToReap, deletedBy) - )).onComplete { - case Success(_) => logger.info("Reap completed") - case Failure(e) => logger.error("Reap failed", e) + getInstances().map { instances => + instances.foreach { instance => + implicit val i: Instance = instance + if (isPaused) { + logger.info("Reaper is paused") + es.countTotalSoftReapable(isReapable).map(metrics.softReapable.increment(Nil, _)) + es.countTotalHardReapable(isReapable, config.hardReapImagesAge).map(metrics.hardReapable.increment(Nil, _)) + } else { + val deletedBy = "reaper" + logger.info(s"Reaper running for instance: ${instance.id}") + Future.sequence(Seq( + doBatchSoftReap(countOfImagesToReap, deletedBy, instance), + doBatchHardReap(countOfImagesToReap, deletedBy, instance) + )).onComplete { + case Success(_) => logger.info("Reap completed") + case Failure(e) => logger.error("Reap failed", e) + } + } } } } catch { case NonFatal(e) => logger.error("Reap failed", e) } } - case _ => logger.info("scheduled reaper will not run since 's3.reaper.bucket' and 'reaper.countPerRun' need to be configured in thrall.conf") + case _ => logger.info("scheduled reaper will not run because 'reaper.countPerRun' needs to be configured in thrall.conf") } - private def batchDeleteWrapper(count: Int)(func: (Int, String) => Future[JsValue]) = auth.async { request => + private def batchDeleteWrapper(count: Int)(func: (Int, String, Instance) => Future[JsValue]) = auth.async { request => + val instance = instanceOf(request) if (!authorisation.hasPermissionTo(DeleteImage)(request.user)) { Future.successful(Forbidden) } @@ -86,27 +107,34 @@ class ReaperController( else { func( count, - request.user.accessor.identity + request.user.accessor.identity, + instance ).map(Ok(_)) } } private def s3DirNameFromDate(date: DateTime) = date.toString("YYYY-MM-dd") - private def persistedBatchDeleteOperation(deleteType: String)(doBatchDelete: => Future[JsValue]) = config.maybeReaperBucket match { - case None => Future.failed(new Exception("Reaper bucket not configured")) - case Some(reaperBucket) => doBatchDelete.map { json => - val now = DateTime.now(DateTimeZone.UTC) - val key = s"$deleteType/${s3DirNameFromDate(now)}/$deleteType-${now.toString()}.json" - store.client.putObject(reaperBucket, key, json.toString()) - json - } + private def persistedBatchDeleteOperation(deleteType: String)(doBatchDelete: => Future[JsValue]) = { + doBatchDelete.map { json => + config.maybeReaperBucket match { + case None => { + logger.debug("Reaper bucket not configured; not persisting results: " + json.toString()) + json + } + case Some(reaperBucket) => + val now = DateTime.now(DateTimeZone.UTC) + val key = s"$deleteType/${s3DirNameFromDate(now)}/$deleteType-${now.toString()}.json" + store.putObject(reaperBucket, key, json.toString()) + json + } + } } def doBatchSoftReap(count: Int): Action[AnyContent] = batchDeleteWrapper(count)(doBatchSoftReap) - def doBatchSoftReap(count: Int, deletedBy: String): Future[JsValue] = persistedBatchDeleteOperation("soft"){ - + def doBatchSoftReap(count: Int, deletedBy: String, instance: Instance): Future[JsValue] = persistedBatchDeleteOperation("soft"){ + implicit val i: Instance = instance es.countTotalSoftReapable(isReapable).map(metrics.softReapable.increment(Nil, _)) logger.info(s"Soft deleting next $count images...") @@ -120,17 +148,20 @@ class ReaperController( _, deletedBy, deleteTime = deleteTime.toString, - isDeleted = true + isDeleted = true, + instance = instance.id ) )) + //s3VectorsDeletions <- s3Vectors.deleteEmbeddings(esIdsActuallySoftDeleted) } yield { metrics.softReaped.increment(n = esIdsActuallySoftDeleted.size) esIds.map { id => val wasSoftDeletedInES = esIdsActuallySoftDeleted.contains(id) - val detail = Map( + val detail = Json.obj( "ES" -> wasSoftDeletedInES, + //"s3Vectors" -> s3VectorsDeletions.get(id).map(_.toString) ) - logger.info(s"Soft deleted image $id : $detail") + logger.info(s"Soft deleted image $id : ${Json.stringify(detail)}") id -> detail }.toMap }).map(Json.toJson(_)) @@ -140,11 +171,11 @@ class ReaperController( def doBatchHardReap(count: Int): Action[AnyContent] = batchDeleteWrapper(count)(doBatchHardReap) - def doBatchHardReap(count: Int, deletedBy: String): Future[JsValue] = persistedBatchDeleteOperation("hard"){ - + def doBatchHardReap(count: Int, deletedBy: String, instance: Instance): Future[JsValue] = persistedBatchDeleteOperation("hard"){ + implicit val i: Instance = instance es.countTotalHardReapable(isReapable, config.hardReapImagesAge).map(metrics.hardReapable.increment(Nil, _)) - logger.info(s"Hard deleting next $count images...") + logger.info(s"Hard deleting next $count images for instance ${instance.id}...") (for { BatchDeletionIds(esIds, esIdsActuallyDeleted) <- es.hardDeleteNextBatchOfImages(isReapable, count, config.hardReapImagesAge) @@ -152,17 +183,22 @@ class ReaperController( thumbsS3Deletions <- store.deleteThumbnails(esIdsActuallyDeleted) pngsS3Deletions <- store.deletePNGs(esIdsActuallyDeleted) _ <- softDeletedMetadataTable.clearStatuses(esIdsActuallyDeleted) + // TODO No one has issued an image-deleted notification to metadata-editor? Metadata will persist forever? } yield { + logger.info(s"Hard delete actually deleted size for instance ${instance.id}: ${esIdsActuallyDeleted.size}") metrics.hardReaped.increment(n = esIdsActuallyDeleted.size) esIds.map { id => val wasHardDeletedFromES = esIdsActuallyDeleted.contains(id) - val detail = Map( - "ES" -> Some(wasHardDeletedFromES), + val detail = Json.obj( + "ES" -> wasHardDeletedFromES, "mainImage" -> mainImagesS3Deletions.get(ImageIngestOperations.fileKeyFromId(id)), "thumb" -> thumbsS3Deletions.get(ImageIngestOperations.fileKeyFromId(id)), - "optimisedPng" -> pngsS3Deletions.get(ImageIngestOperations.optimisedPngKeyFromId(id)), + "optimisedPng" -> pngsS3Deletions.get(ImageIngestOperations.optimisedPngKeyFromId(id)) ) - logger.info(s"Hard deleted image $id : $detail") + if (wasHardDeletedFromES) { + usageEvents.hardDeleteImage(instance = i, image = id) + } + logger.info(s"Hard deleted image $id : ${Json.stringify(detail)}") id -> detail }.toMap }).map(Json.toJson(_)) @@ -173,11 +209,10 @@ class ReaperController( case (None, _) => NotImplemented("'s3.reaper.bucket' not configured in thrall.conf") case (_, None) => NotImplemented("'reaper.countPerRun' not configured in thrall.conf") case (Some(reaperBucket), Some(countOfImagesToReap)) => - val isPaused = store.client.doesObjectExist(reaperBucket, CONTROL_FILE_NAME) val recentRecords = List(now, now.minusDays(1), now.minusDays(2)).flatMap { day => val s3DirName = s3DirNameFromDate(day) - store.client.listObjects(reaperBucket, s"soft/$s3DirName/").getObjectSummaries.asScala.toList ++ - store.client.listObjects(reaperBucket, s"hard/$s3DirName/").getObjectSummaries.asScala.toList + store.listObjects(reaperBucket, s"soft/$s3DirName/").getObjectSummaries.asScala.toList ++ + store.listObjects(reaperBucket, s"hard/$s3DirName/").getObjectSummaries.asScala.toList } val recentRecordKeys = recentRecords @@ -192,23 +227,23 @@ class ReaperController( def reaperRecord(key: String) = auth { config.maybeReaperBucket match { case None => NotImplemented("Reaper bucket not configured") case Some(reaperBucket) => - Ok( - store.client.getObjectAsString(reaperBucket, key) - ).as(JSON) + store.getObjectAsString(reaperBucket, key).map { record => + Ok(record).as(JSON) + }.getOrElse{ + NotFound + } }} - def pauseReaper = auth { config.maybeReaperBucket match { - case None => NotImplemented("Reaper bucket not configured") - case Some(reaperBucket) => - store.client.putObject(reaperBucket, CONTROL_FILE_NAME, "") - Redirect(routes.ReaperController.index) - }} + def conf() = Action.async { + val uvtc = UserVisableThrallConfig( + hardReapImagesAge = config.hardReapImagesAge, + reapableAfterMoreThanDaysOld = ReapableEligibility.ReapableAfterMoreThanDaysOld, + maybeUploadLimitInBytes = config.maybeUploadLimitInBytes + ) + implicit val uvtcw: OWrites[UserVisableThrallConfig] = Json.writes[UserVisableThrallConfig] + Future.successful(Ok(Json.toJson(uvtc))) + } - def resumeReaper = auth { config.maybeReaperBucket match { - case None => NotImplemented("Reaper bucket not configured") - case Some(reaperBucket) => - store.client.deleteObject(reaperBucket, CONTROL_FILE_NAME) - Redirect(routes.ReaperController.index) - }} + case class UserVisableThrallConfig(hardReapImagesAge: Int, reapableAfterMoreThanDaysOld: Int, maybeUploadLimitInBytes: Option[Int]) } diff --git a/thrall/app/controllers/ThrallController.scala b/thrall/app/controllers/ThrallController.scala index 72be443c96a..1911665dc97 100644 --- a/thrall/app/controllers/ThrallController.scala +++ b/thrall/app/controllers/ThrallController.scala @@ -1,25 +1,31 @@ package controllers -import org.apache.pekko.actor.ActorSystem -import org.apache.pekko.stream.Materializer -import org.apache.pekko.stream.scaladsl.{Sink, Source} +import com.amazonaws.services.s3.model.ListObjectsRequest import com.gu.mediaservice.GridClient import com.gu.mediaservice.lib.auth.{Authentication, BaseControllerWithLoginRedirects} -import com.gu.mediaservice.lib.aws.ThrallMessageSender -import com.gu.mediaservice.lib.config.Services +import com.gu.mediaservice.lib.aws.{S3, S3Bucket, ThrallMessageSender, UpdateMessage} +import com.gu.mediaservice.lib.config.{InstanceForRequest, Services} import com.gu.mediaservice.lib.elasticsearch.{NotRunning, Running} import com.gu.mediaservice.lib.logging.GridLogging -import com.gu.mediaservice.model.{CompleteMigrationMessage, CreateMigrationIndexMessage, UpsertFromProjectionMessage} +import com.gu.mediaservice.model._ +import com.gu.mediaservice.syntax.MessageSubjects.ReindexImage import lib.elasticsearch.ElasticSearch import lib.{MigrationRequest, OptionalFutureRunner, Paging, ThrallStore} +import org.apache.pekko.actor.ActorSystem +import org.apache.pekko.stream.Materializer +import org.apache.pekko.stream.scaladsl.{Sink, Source} import org.joda.time.{DateTime, DateTimeZone} import play.api.data.Form import play.api.data.Forms._ +import play.api.libs.Files import play.api.libs.json.Json -import play.api.mvc.{Action, AnyContent, ControllerComponents} +import play.api.mvc.{Action, AnyContent, ControllerComponents, MultipartFormData} +import java.util.Date +import scala.annotation.tailrec import scala.concurrent.duration.DurationInt import scala.concurrent.{Await, ExecutionContext, Future} +import scala.jdk.CollectionConverters.CollectionHasAsScala import scala.language.postfixOps case class MigrateSingleImageForm(id: String) @@ -33,40 +39,46 @@ class ThrallController( override val auth: Authentication, override val services: Services, override val controllerComponents: ControllerComponents, - gridClient: GridClient -)(implicit val ec: ExecutionContext) extends BaseControllerWithLoginRedirects with GridLogging { + gridClient: GridClient, + s3: S3, + imageBucket: S3Bucket, + lowPriorityMessageSender: ThrallMessageSender +)(implicit val ec: ExecutionContext) extends BaseControllerWithLoginRedirects with GridLogging with InstanceForRequest { private val numberFormatter: Long => String = java.text.NumberFormat.getIntegerInstance().format - def index = withLoginRedirectAsync { + def index = withLoginRedirectAsync { request => + implicit val instance: Instance = instanceOf(request) + val countDocsInIndex = OptionalFutureRunner.run(es.countImages) _ for { - currentIndex <- es.getIndexForAlias(es.imagesCurrentAlias) + currentIndex <- es.getIndexForAlias(es.imagesCurrentAlias(instance)) currentIndexName = currentIndex.map(_.name) currentIndexCount <- countDocsInIndex(currentIndexName) - migrationIndex <- es.getIndexForAlias(es.imagesMigrationAlias) + migrationIndex <- es.getIndexForAlias(es.imagesMigrationAlias(instance)) migrationIndexName = migrationIndex.map(_.name) migrationIndexCount <- countDocsInIndex(migrationIndexName) - historicalIndex <- es.getIndexForAlias(es.imagesHistoricalAlias) + historicalIndex <- es.getIndexForAlias(es.imagesHistoricalAlias(instance)) currentIndexCountFormatted = currentIndexCount.map(_.catCount).map(numberFormatter).getOrElse("!") migrationIndexCountFormatted = migrationIndexCount.map(_.catCount).map(numberFormatter).getOrElse("-") } yield { Ok(views.html.index( - currentAlias = es.imagesCurrentAlias, + currentAlias = es.imagesCurrentAlias(instance), currentIndex = currentIndexName.getOrElse("ERROR - No index found! Please investigate this!"), currentIndexCount = currentIndexCountFormatted, - migrationAlias = es.imagesMigrationAlias, + migrationAlias = es.imagesMigrationAlias(instance), migrationIndexCount = migrationIndexCountFormatted, migrationStatus = es.migrationStatus, - hasHistoricalIndex = historicalIndex.isDefined, + hasHistoricalIndex = historicalIndex.isDefined )) } } def upsertProjectPage(imageId: Option[String]) = withLoginRedirectAsync { implicit request => + implicit val instance: Instance = instanceOf(request) imageId match { case Some(id) if store.doesOriginalExist(id) => gridClient.getProjectionDiff(id, auth.innerServiceCall).map { @@ -78,51 +90,53 @@ class ThrallController( } } - def migrationFailuresOverview(): Action[AnyContent] = withLoginRedirectAsync { - es.migrationStatus match { + def migrationFailuresOverview(): Action[AnyContent] = withLoginRedirectAsync { request => + implicit val instance: Instance = instanceOf(request) + es.migrationStatus(instance) match { case running: Running => - es.getMigrationFailuresOverview(es.imagesCurrentAlias, running.migrationIndexName).map(failuresOverview => + es.getMigrationFailuresOverview(es.imagesCurrentAlias(instance), running.migrationIndexName).map(failuresOverview => Ok(views.html.migrationFailuresOverview( failuresOverview, - apiBaseUrl = services.apiBaseUri, - uiBaseUrl = services.kahunaBaseUri, + apiBaseUrl = services.apiBaseUri(instance), + uiBaseUrl = services.kahunaBaseUri(instance) )) ) case _ => for { - currentIndex <- es.getIndexForAlias(es.imagesCurrentAlias) - currentIndexName <- currentIndex.map(_.name).map(Future.successful).getOrElse(Future.failed(new Exception(s"No index found for '${es.imagesCurrentAlias}' alias"))) - failuresOverview <- es.getMigrationFailuresOverview(es.imagesHistoricalAlias, currentIndexName) + currentIndex <- es.getIndexForAlias(es.imagesCurrentAlias(instance)) + currentIndexName <- currentIndex.map(_.name).map(Future.successful).getOrElse(Future.failed(new Exception(s"No index found for '${es.imagesCurrentAlias(instance)}' alias"))) + failuresOverview <- es.getMigrationFailuresOverview(currentIndexName, es.imagesMigrationAlias(instance)) response = Ok(views.html.migrationFailuresOverview( failuresOverview, - apiBaseUrl = services.apiBaseUri, - uiBaseUrl = services.kahunaBaseUri, + apiBaseUrl = services.apiBaseUri(instance), + uiBaseUrl = services.kahunaBaseUri(instance), )) } yield response } } - def migrationFailures(filter: String, maybePage: Option[Int]): Action[AnyContent] = withLoginRedirectAsync { + def migrationFailures(filter: String, maybePage: Option[Int]): Action[AnyContent] = withLoginRedirectAsync { request => + implicit val instance: Instance = instanceOf(request) Paging.withPaging(maybePage) { paging => es.migrationStatus match { case running: Running => - es.getMigrationFailures(es.imagesCurrentAlias, running.migrationIndexName, paging.from, paging.pageSize, filter).map(failures => + es.getMigrationFailures(es.imagesCurrentAlias(instance), running.migrationIndexName, paging.from, paging.pageSize, filter).map(failures => Ok(views.html.migrationFailures( failures, - apiBaseUrl = services.apiBaseUri, - uiBaseUrl = services.kahunaBaseUri, + apiBaseUrl = services.apiBaseUri(instance), + uiBaseUrl = services.kahunaBaseUri(instance), filter, paging.page, shouldAllowReattempts = true )) ) case _ => for { - currentIndex <- es.getIndexForAlias(es.imagesCurrentAlias) - currentIndexName <- currentIndex.map(_.name).map(Future.successful).getOrElse(Future.failed(new Exception(s"No index found for '${es.imagesCurrentAlias}' alias"))) - failures <- es.getMigrationFailures(es.imagesHistoricalAlias, currentIndexName, paging.from, paging.pageSize, filter) + currentIndex <- es.getIndexForAlias(es.imagesCurrentAlias(instance)) + currentIndexName <- currentIndex.map(_.name).map(Future.successful).getOrElse(Future.failed(new Exception(s"No index found for '${es.imagesCurrentAlias(instance)}' alias"))) + failures <- es.getMigrationFailures(es.imagesHistoricalAlias(instance), currentIndexName, paging.from, paging.pageSize, filter) response = Ok(views.html.migrationFailures( failures, - apiBaseUrl = services.apiBaseUri, - uiBaseUrl = services.kahunaBaseUri, + apiBaseUrl = services.apiBaseUri(instance), + uiBaseUrl = services.kahunaBaseUri(instance), filter, paging.page, shouldAllowReattempts = false @@ -135,31 +149,33 @@ class ThrallController( implicit val pollingMaterializer: Materializer = Materializer.matFromSystem(actorSystem) def startMigration = withLoginRedirectAsync { implicit request => + val instance = instanceOf(request) - if(Form(single("start-confirmation" -> text)).bindFromRequest().get != "start"){ + if (Form(single("start-confirmation" -> text)).bindFromRequest().get != "start") { Future.successful(BadRequest("you did not enter 'start' in the text box")) } else { - val msgFailedToFetchIndex = s"Could not fetch ES index details for alias '${es.imagesMigrationAlias}'" - es.getIndexForAlias(es.imagesMigrationAlias) recover { case error: Throwable => + val msgFailedToFetchIndex = s"Could not fetch ES index details for alias '${es.imagesMigrationAlias(instance)}'" + es.getIndexForAlias(es.imagesMigrationAlias(instance)) recover { case error: Throwable => logger.error(msgFailedToFetchIndex, error) InternalServerError(msgFailedToFetchIndex) } map { case Some(index) => - BadRequest(s"There is already an index '${index}' for alias '${es.imagesMigrationAlias}', and thus a migration underway.") + BadRequest(s"There is already an index '$index' for alias '${es.imagesMigrationAlias(instance)}', and thus a migration underway.") case None => messageSender.publish(CreateMigrationIndexMessage( migrationStart = DateTime.now(DateTimeZone.UTC), - gitHash = utils.buildinfo.BuildInfo.gitCommitId + gitHash = utils.buildinfo.BuildInfo.gitCommitId, + instance )) // poll until images migration alias is created, giving up after 10 seconds Await.result( Source(1 to 20) .throttle(1, 500 millis) - .mapAsync(parallelism = 1)(_ => es.getIndexForAlias(es.imagesMigrationAlias)) + .mapAsync(parallelism = 1)(_ => es.getIndexForAlias(es.imagesMigrationAlias(instance))) .takeWhile(_.isEmpty, inclusive = true) .runWith(Sink.last) .map(_.fold { - val timedOutMessage = s"Still no index for alias '${es.imagesMigrationAlias}' after 10 seconds." + val timedOutMessage = s"Still no index for alias '${es.imagesMigrationAlias(instance)}' after 10 seconds." logger.error(timedOutMessage) InternalServerError(timedOutMessage) } { _ => @@ -176,19 +192,20 @@ class ThrallController( } def completeMigration(): Action[AnyContent] = withLoginRedirectAsync { implicit request => - - if(Form(single("complete-confirmation" -> text)).bindFromRequest().get != "complete"){ + val instance = instanceOf(request) + if (Form(single("complete-confirmation" -> text)).bindFromRequest().get != "complete") { Future.successful(BadRequest("you did not enter 'complete' in the text box")) } else { - es.refreshAndRetrieveMigrationStatus() match { + es.refreshAndRetrieveMigrationStatus(instance) match { case _: Running => messageSender.publish(CompleteMigrationMessage( lastModified = DateTime.now(DateTimeZone.UTC), + instance )) // poll until images migration status is not running or error, giving up after 10 seconds Source(1 to 20) .throttle(1, 500 millis) - .map(_ => es.refreshAndRetrieveMigrationStatus()) + .map(_ => es.refreshAndRetrieveMigrationStatus(instance)) .takeWhile(_.isInstanceOf[Running], inclusive = true) .runWith(Sink.last) .map { @@ -206,54 +223,65 @@ class ThrallController( } } - private def adjustMigration(action: () => Unit) = withLoginRedirect { - action() - es.refreshAndRetrieveMigrationStatus() + private def adjustMigration(action: Instance => Unit) = withLoginRedirect { request => + val instance = instanceOf(request) + action(instance) + es.refreshAndRetrieveMigrationStatus(instance) Redirect(routes.ThrallController.index) } - def pauseMigration = adjustMigration(es.pauseMigration _) - def resumeMigration = adjustMigration(es.resumeMigration _) - def previewMigrationCompletion = adjustMigration(es.previewMigrationCompletion _) - def unPreviewMigrationCompletion = adjustMigration(es.unPreviewMigrationCompletion _) + + def pauseMigration = { + adjustMigration(es.pauseMigration) + } + + def resumeMigration = adjustMigration(es.resumeMigration) + + def previewMigrationCompletion = adjustMigration(es.previewMigrationCompletion) + + def unPreviewMigrationCompletion = adjustMigration(es.unPreviewMigrationCompletion) def migrateSingleImage: Action[AnyContent] = withLoginRedirectAsync { implicit request => + implicit val instance: Instance = instanceOf(request) val imageId = migrateSingleImageFormReader.bindFromRequest().get.id es.getImageVersion(imageId) flatMap { case Some(version) => - sendMigrationRequest(MigrationRequest(imageId, version)).map { - case true => Ok(s"Image migration queued successfully with id:$imageId") - case false => InternalServerError(s"Failed to send migrate image message $imageId") + sendMigrationRequest(MigrationRequest(imageId, version, instance)).map { + case true => Ok(s"Image migration queued successfully with id:$imageId for instance ${instance.id}") + case false => InternalServerError(s"Failed to send migrate image message $imageId for instance ${instance.id}") } case None => - Future.successful(InternalServerError(s"Failed to send migrate image message $imageId")) + Future.successful(InternalServerError(s"Failed to send migrate image message $imageId for instance ${instance.id}")) } } def upsertFromProjectionSingleImage: Action[AnyContent] = withLoginRedirectAsync { implicit request => + implicit val instance: Instance = instanceOf(request) val imageId = migrateSingleImageFormReader.bindFromRequest().get.id for { maybeImage <- gridClient.getImageLoaderProjection(imageId, auth.innerServiceCall) } yield { maybeImage match { case Some(projectedImage) => - messageSender.publish(UpsertFromProjectionMessage(imageId, projectedImage, DateTime.now)) + messageSender.publish(UpsertFromProjectionMessage(imageId, projectedImage, DateTime.now, + instance)) Ok(s"upsert request for $imageId submitted") case None => NotFound("") }} } def restoreFromReplica: Action[AnyContent] = withLoginRedirect {implicit request => - Ok(views.html.restoreFromReplica(s"${services.loaderBaseUri}/images/restore")) //FIXME figure out imageId bit + Ok(views.html.restoreFromReplica(s"${services.loaderBaseUri(instanceOf(request))}/images/restore")) //FIXME figure out imageId bit } def reattemptMigrationFailures(filter: String, page: Int): Action[AnyContent] = withLoginRedirectAsync { implicit request => + implicit val instance: Instance = instanceOf(request) Paging.withPaging(Some(page)) { paging => es.migrationStatus match { case running: Running => - val migrationRequestsF = es.getMigrationFailures(es.imagesCurrentAlias, running.migrationIndexName, paging.from, paging.pageSize, filter).map(failures => - failures.details.map(detail => MigrationRequest(detail.imageId, detail.version)) + val migrationRequestsF = es.getMigrationFailures(es.imagesCurrentAlias(instance), running.migrationIndexName, paging.from, paging.pageSize, filter).map(failures => + failures.details.map(detail => MigrationRequest(detail.imageId, detail.version, instance)) ) for { migrationRequests <- migrationRequestsF @@ -276,4 +304,99 @@ class ThrallController( "id" -> text )(MigrateSingleImageForm.apply)(MigrateSingleImageForm.unapply) ) + + def reindex(): Action[AnyContent] = withLoginRedirect { implicit request => + Ok(views.html.reindexImage()) + } + + case class ReindexImageForm(id: String) + + val reindexImageFormReader: Form[ReindexImageForm] = Form( + mapping( + "id" -> text + )(ReindexImageForm.apply)(ReindexImageForm.unapply) + ) + + def reindexImage: Action[AnyContent] = withLoginRedirectAsync { implicit request => + implicit val instance: Instance = instanceOf(request) + val imageId = reindexImageFormReader.bindFromRequest().get.id + val reindexImageMessage = ReindexImageMessage(id = imageId, lastModified = DateTime.now(DateTimeZone.UTC), instance = instance) + messageSender.publish(reindexImageMessage) + Future.successful(Ok(s"reindex request for $imageId submitted")) + } + + def reindexAll(): Action[AnyContent] = withLoginRedirect { implicit request => + implicit val instance: Instance = instanceOf(request) + + @tailrec + def getMediaIdsFromS3(all: Seq[(String, Date)], nextMarker: Option[String])(implicit instance: Instance): Seq[(String, Date)] = { + logger.info("Paginating S3 from: " + nextMarker) + val baseRequest = new ListObjectsRequest().withBucketName(imageBucket.bucket).withPrefix(instance.id + "/") + val request = nextMarker.map { marker => + baseRequest.withMarker(marker) + }.getOrElse { + baseRequest + } + + val listing = s3.listObjects(imageBucket, request) + val keys = listing.getObjectSummaries.asScala.flatMap { s3Object => + logger.info("Found s3 object to reindex: " + s3Object.getKey + " / " + s3Object.getLastModified) + s3Object.getKey.split("/").lastOption.map( key => (key, s3Object.getLastModified)) + } + + if (listing.isTruncated) { + getMediaIdsFromS3(all ++ keys, Some(listing.getNextMarker)) + } else { + all ++ keys + } + } + + logger.info(s"Reindex requested for instance ${instance.id}") + val keys = getMediaIdsFromS3(Seq.empty, None) + logger.info(s"Queuing reindex requests for ${keys.size} images for instance ${instance.id}") + + val mediaIds = keys.sortBy(_._2).reverse.map(_._1) + + val batches = mediaIds.grouped(100) + batches.foreach { batch => + val messages = batch.map { mediaId => + UpdateMessage( + subject = ReindexImage, + id = Some(mediaId), + instance = instance + ) + } + lowPriorityMessageSender.publish(messages) + } + Ok("ok") + } + + def reindexFromCsvPage: Action[AnyContent] = withLoginRedirect { implicit request => + Ok(views.html.reindexFromCsv()) + } + + def reindexFromCsv: Action[MultipartFormData[Files.TemporaryFile]] = + Action(parse.multipartFormData).async { implicit request => + request.body + .file("csv") + .map { csv => + implicit val instance: Instance = instanceOf(request) + val imageIds: Seq[String] = scala.io.Source.fromFile(csv.ref.toFile).getLines().toList + imageIds.foreach { imageId => + val reindexImageMessage = ReindexImageMessage(id = imageId, lastModified = DateTime.now(DateTimeZone.UTC), instance = instance) + messageSender.publish(reindexImageMessage) + } + Future.successful { + Ok(s"reindex request for ${imageIds.size} images submitted") + } + } + .getOrElse { + Future.successful( + Redirect(routes.ThrallController.reindexFromCsvPage()) + .flashing("error" -> "Missing file") + ) + } + } + + } diff --git a/thrall/app/instances/InstanceMessageSender.scala b/thrall/app/instances/InstanceMessageSender.scala new file mode 100644 index 00000000000..0c3fb1e0965 --- /dev/null +++ b/thrall/app/instances/InstanceMessageSender.scala @@ -0,0 +1,43 @@ +package instances + +import play.api.libs.json.{JsObject, JsString, Json, OWrites} +import software.amazon.awssdk.services.sqs.SqsClient +import software.amazon.awssdk.services.sqs.model.SendMessageRequest + +class InstanceMessageSender(sqsClient: SqsClient, queueUrl: String) { + + def send(message: InstanceMessage): Unit = { + import play.api.libs.json._ + val wireFormatMessage = message match { + case statusMessage: InstanceStatusMessage => + Some(Json.obj( + "type" -> JsString("status"), + "body" -> Json.toJson(statusMessage) + )) + case usageMessage: InstanceUsageMessage => + Some(Json.obj( + "type" -> JsString("usage"), + "body" -> Json.toJson(usageMessage) + )) + } + + wireFormatMessage.foreach { message => + sqsClient.sendMessage(SendMessageRequest.builder.queueUrl(queueUrl).messageBody(Json.toJson(message).toString()).build) + } + } + +} + +trait InstanceMessage + +case class InstanceStatusMessage(instance: String, status: String) extends InstanceMessage + +object InstanceStatusMessage { + implicit val iumw: OWrites[InstanceStatusMessage] = Json.writes[InstanceStatusMessage] +} + +case class InstanceUsageMessage(instance: String, imageCount: Long, totalImageSize: Long, softDeletedCount: Long) extends InstanceMessage + +object InstanceUsageMessage { + implicit val iumw: OWrites[InstanceUsageMessage] = Json.writes[InstanceUsageMessage] +} diff --git a/thrall/app/lib/MetadataEditorNotifications.scala b/thrall/app/lib/MetadataEditorNotifications.scala index df855e5e9f6..b05161fc6ad 100644 --- a/thrall/app/lib/MetadataEditorNotifications.scala +++ b/thrall/app/lib/MetadataEditorNotifications.scala @@ -1,10 +1,15 @@ package lib import com.gu.mediaservice.lib.aws.SNS +import com.gu.mediaservice.model.Instance import play.api.libs.json.Json import scala.concurrent.ExecutionContext class MetadataEditorNotifications(config: ThrallConfig) extends SNS(config, config.metadataTopicArn) { - def publishImageDeletion(id: String)(implicit ec: ExecutionContext) = publish(Json.obj("id" -> id), "image-deleted") + def publishImageDeletion(id: String, instance: Instance)(implicit ec: ExecutionContext): Unit = { + logger.info(s"Publishing image-deleted message for image id $id") + + publish(Json.obj("id" -> id, "instance" -> instance.id), "image-deleted") + } } diff --git a/thrall/app/lib/MigrationSourceWithSender.scala b/thrall/app/lib/MigrationSourceWithSender.scala index 32f45af519e..48cbb29bfd1 100644 --- a/thrall/app/lib/MigrationSourceWithSender.scala +++ b/thrall/app/lib/MigrationSourceWithSender.scala @@ -5,16 +5,19 @@ import org.apache.pekko.stream.{Materializer, OverflowStrategy, QueueOfferResult import org.apache.pekko.{Done, NotUsed} import com.gu.mediaservice.GridClient import com.gu.mediaservice.lib.elasticsearch.{InProgress, Paused} +import com.gu.mediaservice.lib.instances.{InstancesClient, Instances} import com.gu.mediaservice.lib.logging.GridLogging -import com.gu.mediaservice.model.{MigrateImageMessage, MigrationMessage} +import com.gu.mediaservice.model.{Instance, MigrateImageMessage, MigrationMessage} +import com.sksamuel.elastic4s.requests.searches.SearchHit import lib.elasticsearch.{ElasticSearch, ScrolledSearchResults} import play.api.libs.ws.WSRequest import java.time.Instant -import scala.concurrent.duration.DurationInt -import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.duration.{Duration, DurationInt, SECONDS} +import scala.concurrent.{Await, ExecutionContext, Future} + +case class MigrationRequest(imageId: String, version: Long, instance: Instance) -case class MigrationRequest(imageId: String, version: Long) case class MigrationRecord(payload: MigrationMessage, approximateArrivalTimestamp: Instant) case class MigrationSourceWithSender( @@ -22,46 +25,50 @@ case class MigrationSourceWithSender( source: Source[MigrationRecord, Future[Done]], ) -object MigrationSourceWithSender extends GridLogging { - def apply( - materializer: Materializer, - innerServiceCall: WSRequest => WSRequest, - es: ElasticSearch, - gridClient: GridClient, - projectionParallelism: Int, - )(implicit ec: ExecutionContext): MigrationSourceWithSender = { +class MigrationSourceWithSenderFactory( + materializer: Materializer, + innerServiceCall: WSRequest => WSRequest, + es: ElasticSearch, + gridClient: GridClient, + projectionParallelism: Int, + val instancesClient: InstancesClient + ) extends GridLogging { + def build()(implicit ec: ExecutionContext): MigrationSourceWithSender = { // scroll through elasticsearch, finding image ids and versions to migrate // emits MigrationRequest val scrollingIdsSource = - Source.repeat(()) - .throttle(1, per = 1.second) - .statefulMapConcat(() => { - // This Pekko-provided stage is explicitly provided as a way to safely wrap around mutable state. - // Required here to keep a marker of the current search scroll. Scrolling prevents the - // next search from picking up the same image ids and inserting them into the flow and - // causing lots of version comparison failures. - // Alternatives: - // - Using the elastic4s `ElasticSource` - // (This would be ideal but is tricky due to dependency version conflicts, and it's also - // difficult (or impossible?) to change the query value once the stream has been materialized.) - // - Defining our own version of the ElasticSource using our desired library versions and a system to change - // the query value as desired. - // - Define an Pekko actor to handle the querying and wrap around the state. - var maybeScrollId: Option[String] = None - - def handleScrollResponse(resp: ScrolledSearchResults) = { - maybeScrollId = if (resp.hits.isEmpty) { - // close scroll with provided ID if it exists - resp.scrollId.foreach(es.closeScroll) - None - } else { - resp.scrollId - } - resp.hits + Source.repeat(()) + .throttle(1, per = 1.minute) + .statefulMapConcat(() => { + // This Pekko-provided stage is explicitly provided as a way to safely wrap around mutable state. + // Required here to keep a marker of the current search scroll. Scrolling prevents the + // next search from picking up the same image ids and inserting them into the flow and + // causing lots of version comparison failures. + // Alternatives: + // - Using the elastic4s `ElasticSource` + // (This would be ideal but is tricky due to dependency version conflicts, and it's also + // difficult (or impossible?) to change the query value once the stream has been materialized.) + // - Defining our own version of the ElasticSource using our desired library versions and a system to change + // the query value as desired. + // - Define an Pekko actor to handle the querying and wrap around the state. + var maybeScrollId: Option[String] = None + + def handleScrollResponse(resp: ScrolledSearchResults)(implicit instance: Instance) = { + maybeScrollId = if (resp.hits.isEmpty) { + // close scroll with provided ID if it exists + resp.scrollId.foreach(es.closeScroll) + None + } else { + resp.scrollId } + resp.hits.map(InstanceSearchHit(_, instance)) + } - _ => { + _ => { + val instances = Await.result(instancesClient.getInstances(), Duration(10, SECONDS)) + instances.flatMap { instance => + implicit val i: Instance = instance val nextIdsToMigrate = ((es.migrationStatus, maybeScrollId) match { case (Paused(_), _) => Future.successful(List.empty) case (InProgress(migrationIndexName), None) => @@ -77,17 +84,19 @@ object MigrationSourceWithSender extends GridLogging { } List(nextIdsToMigrate) } - }) - // flatten out the future - .mapAsync(1)(identity) - // flatten out the list of image ids - .mapConcat(searchHits => { - if (searchHits.nonEmpty) { - logger.info(s"Flattening ${searchHits.size} image ids to migrate") - } - searchHits.map(hit => MigrationRequest(hit.id, hit.version)) - }) - .filter(_ => es.migrationIsInProgress) + } + }) + + // flatten out the future + .mapAsync(1)(a => identity(a)) + // flatten out the list of image ids + .mapConcat(searchHits => { + if (searchHits.nonEmpty) { + logger.info(s"Flattening ${searchHits.size} image ids to migrate") + } + searchHits.map(instanceSearchHit => MigrationRequest(instanceSearchHit.hit.id, instanceSearchHit.hit.version, instanceSearchHit.instance)) + }) + .filter(hit => es.migrationIsInProgress(hit.instance)) // receive MigrationRequests to migrate from a manual source (failures retry page, single image migration form, etc.) val manualIdsSourceDeclaration = Source.queue[MigrationRequest](bufferSize = 2000) @@ -111,14 +120,15 @@ object MigrationSourceWithSender extends GridLogging { // project image from MigrationRequest, produce the MigrateImageMessage val projectedImageSource: Source[MigrationRecord, NotUsed] = idsSource.mapAsyncUnordered(projectionParallelism) { - case MigrationRequest(imageId, version) => + case MigrationRequest(imageId, version, instance) => + implicit val i: Instance = instance val migrateImageMessageFuture = ( for { maybeProjection <- gridClient.getImageLoaderProjection(mediaId = imageId, innerServiceCall) maybeVersion = Some(version) - } yield MigrateImageMessage(imageId, maybeProjection, maybeVersion) + } yield MigrateImageMessage(imageId, maybeProjection, maybeVersion, instance) ).recover { - case error => MigrateImageMessage(imageId, Left(error.toString)) + case error => MigrateImageMessage(imageId, Left(error.toString), instance) } migrateImageMessageFuture.map(message => MigrationRecord( payload = message, @@ -131,4 +141,7 @@ object MigrationSourceWithSender extends GridLogging { source = projectedImageSource.mapMaterializedValue(_ => Future.successful(Done)), ) } + + private case class InstanceSearchHit(hit: SearchHit, instance: Instance) + } diff --git a/thrall/app/lib/SyncChecker.scala b/thrall/app/lib/SyncChecker.scala index 959057ce581..e69de29bb2d 100644 --- a/thrall/app/lib/SyncChecker.scala +++ b/thrall/app/lib/SyncChecker.scala @@ -1,143 +0,0 @@ -package lib - -import org.apache.pekko.Done -import org.apache.pekko.actor.ActorSystem -import org.apache.pekko.stream.{Materializer, RestartSettings} -import org.apache.pekko.stream.scaladsl.{RestartSource, Source} -import com.amazonaws.services.s3.AmazonS3 -import com.amazonaws.services.s3.model.ListObjectsV2Request -import com.gu.mediaservice.lib.elasticsearch.InProgress -import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker, MarkerMap} -import lib.elasticsearch.ElasticSearch - -import scala.jdk.CollectionConverters._ -import scala.concurrent.duration.DurationInt -import scala.concurrent.{ExecutionContext, ExecutionContextExecutor, Future} -import scala.util.{Failure, Success} - -sealed trait SyncCheckJob {} -case class Prefix(prefix: String) extends SyncCheckJob -case object Other extends SyncCheckJob - -class SyncChecker( - s3: AmazonS3, - es: ElasticSearch, - imageBucketName: String, - actorSystem: ActorSystem -) extends GridLogging { - - private val mat = Materializer.matFromSystem(actorSystem) - private implicit val dispatcher: ExecutionContext = actorSystem.getDispatcher - - private val expectedAlphabet = "0123456789abcdef".split("").toSeq - private val prefixes: Seq[SyncCheckJob] = (for { - x <- expectedAlphabet - y <- expectedAlphabet - z <- expectedAlphabet - } yield Prefix(s"$x$y$z")) :+ Other - - private def paginatedListObjects(request: ListObjectsV2Request, maybeContinuationToken: Option[String] = None): Future[Seq[String]] = { - Future { - val fullRequest = maybeContinuationToken match { - case Some(token) => request.withContinuationToken(token) - case None => request - } - val result = s3.listObjectsV2(fullRequest) - val keys = result.getObjectSummaries.asScala.toSeq.map(_.getKey.split("/").last) - val nextContinuationToken = Option(result.getNextContinuationToken) - keys -> nextContinuationToken - } flatMap { - case (keys, None) => Future.successful(keys) - case (keys, Some(token)) => - for { extraKeys <- paginatedListObjects(request, Some(token)) } yield keys ++ extraKeys - } - } - - private def listFilesWithPrefix(prefix: String): Future[Seq[String]] = { - val request = new ListObjectsV2Request() - .withBucketName(imageBucketName) - .withPrefix(prefix) - paginatedListObjects(request) - } - private def listFilesAtRoot(): Future[Seq[String]] = { - val request = new ListObjectsV2Request() - .withBucketName(imageBucketName) - .withDelimiter("/") - paginatedListObjects(request) - } - - private def checkPrefix(prefix: String)(implicit logMarker: LogMarker): Future[Unit] = { - val esIdsFetch: Future[Set[String]] = es.listImageIdsWithPrefix(prefix).map(_.toSet) - - val s3Prefix = prefix.split("").mkString("/") - // TODO String? or more complex? - val s3IdsFetch: Future[Set[String]] = listFilesWithPrefix(s3Prefix).map(_.toSet) - - for { - esIds <- esIdsFetch - s3Ids <- s3IdsFetch - } yield { - val extrasInS3 = s3Ids diff esIds - batchedLogs(s"extra images in S3 under prefix $s3Prefix", extrasInS3) - - val extrasInEs = esIds diff s3Ids - batchedLogs(s"extra images in Elasticsearch under prefix $prefix", extrasInEs) - } - } - - private def checkUnprefixed()(implicit logMarker: LogMarker): Future[Unit] = { - val s3IdsAtRootFetch = listFilesAtRoot() - val s3IdsAtSlashFetch = listFilesWithPrefix("/") - // TODO fetch all objects at unexpected locations ie. not at /^([0-9a-f]/){6}[0-9a-f]{34}$/ - // but that's a lot of prefixes to trawl. better suited to something making use of the s3 inventory that we have? - - val esIdsBadFormatFetch = es.listImageIdsWithUnexpectedFormat() - - for { - s3IdsAtRoot <- s3IdsAtRootFetch - s3IdsAtSlash <- s3IdsAtSlashFetch - esIdsBadFormat <- esIdsBadFormatFetch - } yield { - batchedLogs("in S3 root", s3IdsAtRoot) - - batchedLogs("in S3 under '/'", s3IdsAtSlash) - - batchedLogs("in Elasticsearch with unexpected id formats", esIdsBadFormat) - } - } - - private def batchedLogs(problem: String, ids: Iterable[String])(implicit logMarker: LogMarker): Unit = { - ids.grouped(250).foreach(group => - logger.warn(logMarker, s"SyncChecker found ${group.size} images $problem: ${group.mkString(",")}") - ) - } - - private val restartSettings = RestartSettings( - minBackoff = 5.seconds, - maxBackoff = 30.minutes, - randomFactor = 0.1d - ) - - private def createStream() = RestartSource.onFailuresWithBackoff(restartSettings)(() => - Source.cycle(() => prefixes.iterator) - .throttle(1, per = 5.seconds) - .filterNot(_ => es.migrationIsInProgress) - .mapAsync(parallelism = 1) { - case Prefix(prefix) => checkPrefix(prefix)(MarkerMap()) - case Other => checkUnprefixed()(MarkerMap()) - } - ) - - def run(): Future[Done] = { - val stream = createStream().run()(mat) - - logger.info("SyncChecker stream started") - - stream.onComplete { - case Failure(exception) => logger.error("SyncChecker stream completed with failure", exception) - case Success(_) => logger.info("SyncChecker stream completed with done, probably shutting down") - } - - stream - } -} diff --git a/thrall/app/lib/ThrallConfig.scala b/thrall/app/lib/ThrallConfig.scala index a5eeab49d86..c6a816136dc 100644 --- a/thrall/app/lib/ThrallConfig.scala +++ b/thrall/app/lib/ThrallConfig.scala @@ -1,6 +1,6 @@ package lib -import com.gu.mediaservice.lib.aws.AwsClientV2BuilderUtils +import com.gu.mediaservice.lib.aws.{AwsClientV2BuilderUtils, S3, S3Bucket} import com.gu.mediaservice.lib.cleanup.ReapableEligibiltyResources import com.gu.mediaservice.lib.config.{CommonConfigWithElastic, GridConfigResources, ReapableEligibilityLoader} import com.gu.mediaservice.lib.elasticsearch.ReapableEligibility @@ -25,9 +25,10 @@ case class KinesisReceiverConfig( override val awsCredentialsV2: AwsCredentialsProvider, override val awsLocalEndpointUri: Option[URI], override val isDev: Boolean, + appName: String, streamName: String, rewindFrom: Option[DateTime], - metricsLevel: MetricsLevel = MetricsLevel.DETAILED + metricsLevel: MetricsLevel = MetricsLevel.NONE ) extends AwsClientV2BuilderUtils { lazy val kinesisClient: KinesisAsyncClient = { val clientBuilder = withAWSCredentialsV2(KinesisAsyncClient.builder()) @@ -43,22 +44,19 @@ case class KinesisReceiverConfig( } object KinesisReceiverConfig { - def apply(streamName: String, rewindFrom: Option[DateTime], thrallConfig: ThrallConfig): KinesisReceiverConfig = KinesisReceiverConfig( + def apply(appName: String, streamName: String, rewindFrom: Option[DateTime], thrallConfig: ThrallConfig): KinesisReceiverConfig = KinesisReceiverConfig( thrallConfig.awsRegionV2, thrallConfig.awsCredentialsV2, thrallConfig.awsLocalEndpointUri, thrallConfig.isDev, + appName, streamName, rewindFrom ) } class ThrallConfig(resources: GridConfigResources) extends CommonConfigWithElastic(resources) { - val imageBucket: String = string("s3.image.bucket") - - val thumbnailBucket: String = string("s3.thumb.bucket") - - val maybeReaperBucket: Option[String] = stringOpt("s3.reaper.bucket") + val maybeReaperBucket: Option[S3Bucket] = stringOpt("s3.reaper.bucket").map(S3Bucket(_, S3.AmazonAwsS3Endpoint, usesPathStyleURLs = false, clientFor(S3.AmazonAwsS3Endpoint))) val maybeReaperCountPerRun: Option[Int] = intOpt("reaper.countPerRun") val metadataTopicArn: String = string("indexed.image.sns.topic.arn") @@ -71,13 +69,17 @@ class ThrallConfig(resources: GridConfigResources) extends CommonConfigWithElast val projectionParallelism: Int = intDefault("thrall.projection.parallelism", 1) val reaperInterval: FiniteDuration = intDefault("reaper.interval", 15) minutes + val reaperPaused: Boolean = false val hardReapImagesAge: Int = intDefault("reaper.hard.daysInSoftDelete", 14) // soft deleted images age to be hard deleted by Reaper Controller - def kinesisConfig: KinesisReceiverConfig = KinesisReceiverConfig(thrallKinesisStream, rewindFrom, this) - def kinesisLowPriorityConfig: KinesisReceiverConfig = KinesisReceiverConfig(thrallKinesisLowPriorityStream, lowPriorityRewindFrom, this) + def kinesisConfig: KinesisReceiverConfig = KinesisReceiverConfig(appName = thrallAppName, streamName = thrallKinesisStream, rewindFrom, this) + def kinesisLowPriorityConfig: KinesisReceiverConfig = KinesisReceiverConfig(appName = thrallLowPriorityAppName, streamName = thrallKinesisLowPriorityStream, lowPriorityRewindFrom, this) def maybeReapableEligibilityClass(applicationLifecycle: ApplicationLifecycle): Option[ReapableEligibility] = { val configLoader = ReapableEligibilityLoader.singletonConfigLoader(ReapableEligibiltyResources(this, resources.actorSystem), applicationLifecycle) configuration.getOptional[ReapableEligibility]("reaper.provider")(configLoader) } + + val instanceUsageQueueName: String = string("instance.usage.queue.name") + } diff --git a/thrall/app/lib/ThrallStreamProcessor.scala b/thrall/app/lib/ThrallStreamProcessor.scala index 274d834ed87..b10850dcccc 100644 --- a/thrall/app/lib/ThrallStreamProcessor.scala +++ b/thrall/app/lib/ThrallStreamProcessor.scala @@ -1,17 +1,16 @@ package lib -import java.time.Instant -import org.apache.pekko.actor.ActorSystem -import org.apache.pekko.stream.scaladsl.{GraphDSL, MergePreferred, MergePrioritized, Source} -import org.apache.pekko.stream.{Materializer, SourceShape} -import org.apache.pekko.{Done, NotUsed} import com.gu.kinesis.KinesisRecord import com.gu.mediaservice.lib.DateTimeUtils -import com.gu.mediaservice.lib.aws.UpdateMessage import com.gu.mediaservice.lib.logging._ -import com.gu.mediaservice.model.{ExternalThrallMessage, InternalThrallMessage, ThrallMessage} -import lib.kinesis.{MessageTranslator, ThrallEventConsumer} +import com.gu.mediaservice.model.{ExternalThrallMessage, MigrationMessage, ThrallMessage} +import lib.kinesis.ThrallEventConsumer +import org.apache.pekko.actor.ActorSystem +import org.apache.pekko.stream.scaladsl.{GraphDSL, MergePreferred, Source} +import org.apache.pekko.stream.{Materializer, SourceShape} +import org.apache.pekko.{Done, NotUsed} +import java.time.Instant import scala.concurrent.{ExecutionContextExecutor, Future} import scala.util.{Failure, Success} @@ -61,25 +60,15 @@ class ThrallStreamProcessor( val mergedKinesisSource: Source[TaggedRecord[ThrallMessage], NotUsed] = Source.fromGraph(GraphDSL.create() { implicit graphBuilder => import GraphDSL.Implicits._ - val uiRecordSource = uiSource.map(kinesisRecord => + val uiRecordSource: Source[TaggedRecord[Array[Byte]], Future[Done]] = uiSource.map(kinesisRecord => TaggedRecord(kinesisRecord.data.toArray, kinesisRecord.approximateArrivalTimestamp, UiPriority, kinesisRecord.markProcessed)) - val automationRecordSource = automationSource.map(kinesisRecord => - TaggedRecord(kinesisRecord.data.toArray, kinesisRecord.approximateArrivalTimestamp, AutomationPriority, kinesisRecord.markProcessed)) - - val migrationMessagesSource = migrationSource.map { case MigrationRecord(internalThrallMessage, time) => + val migrationMessagesSource: Source[TaggedRecord[MigrationMessage], Future[Done]] = migrationSource.map { case MigrationRecord(internalThrallMessage, time) => TaggedRecord(internalThrallMessage, time, MigrationPriority, () => {}) } - // merge together ui and automation kinesis records - val uiAndAutomationRecordsMerge = graphBuilder.add(MergePreferred[TaggedRecord[Array[Byte]]](1)) - uiRecordSource ~> uiAndAutomationRecordsMerge.preferred - automationRecordSource ~> uiAndAutomationRecordsMerge.in(0) - // parse the kinesis records into thrall update messages (dropping those that fail) - val uiAndAutomationMessagesSource: PortOps[TaggedRecord[ExternalThrallMessage]] = - uiAndAutomationRecordsMerge.out - .map { taggedRecord => + val uiMessagesSource: Source[TaggedRecord[ExternalThrallMessage], Future[Done]] = uiRecordSource.map { taggedRecord => val parsedRecord = ThrallEventConsumer .parseRecord(taggedRecord.payload, taggedRecord.arrivalTimestamp) .map( @@ -98,35 +87,75 @@ class ThrallStreamProcessor( // merge in the re-ingestion source (preferring ui/automation) val mergePreferred = graphBuilder.add(MergePreferred[TaggedRecord[ThrallMessage]](1)) - uiAndAutomationMessagesSource ~> mergePreferred.preferred + uiMessagesSource ~> mergePreferred.preferred migrationMessagesSource ~> mergePreferred.in(0) - SourceShape(mergePreferred.out) + val out: SourceShape[TaggedRecord[ThrallMessage]] = SourceShape(mergePreferred.out) + out }) - def createStream(): Source[(TaggedRecord[ThrallMessage], Stopwatch, ThrallMessage), NotUsed] = { + val automationKinesisSource: Source[TaggedRecord[ThrallMessage], Future[Done]] = { + automationSource.map(kinesisRecord => + TaggedRecord(kinesisRecord.data.toArray, kinesisRecord.approximateArrivalTimestamp, AutomationPriority, kinesisRecord.markProcessed)) + .map { taggedRecord => + val parsedRecord = ThrallEventConsumer + .parseRecord(taggedRecord.payload, taggedRecord.arrivalTimestamp) + .map( + message => taggedRecord.copy(payload = message) + ) + // If we failed to parse the record (Left), we'll drop it below because we can't process it. + // However we still need to mark the record as processed, otherwise the kinesis stream can't progress + // and checkpoint will be stuck at this message forevermore. + parsedRecord.left.foreach(_ => taggedRecord.markProcessed()) + parsedRecord + } + // drop unparseable records + .collect { + case Right(taggedRecord) => taggedRecord + } + } + + def createUIStream(): Source[(TaggedRecord[ThrallMessage], Stopwatch, ThrallMessage), NotUsed] = { mergedKinesisSource.mapAsync(1) { result => val stopwatch = Stopwatch.start consumer.processMessage(result.payload) .recover { case _ => () } .map(_ => (result, stopwatch, result.payload)) - } - + } + } + def createAutomationStream(): Source[(TaggedRecord[ThrallMessage], Stopwatch, ThrallMessage), Future[Done]] = { + automationKinesisSource.mapAsync(20) { result => + val stopwatch = Stopwatch.start + consumer.processMessage(result.payload) + .recover { case _ => () } + .map(_ => (result, stopwatch, result.payload)) + } } + def run(): Future[Done] = { - val stream = this.createStream().runForeach { + val stream = this.createUIStream().runForeach { case (taggedRecord, stopwatch, _) => val markers = combineMarkers(taggedRecord, stopwatch.elapsed) logger.info(markers, "Record processed") taggedRecord.markProcessed() } - stream.onComplete { case Failure(exception) => logger.error("Thrall stream completed with failure", exception) case Success(_) => logger.info("Thrall stream completed with done, probably shutting down") } + val automationStream = this.createAutomationStream().runForeach { + case (taggedRecord, stopwatch, _) => + val markers = combineMarkers(taggedRecord, stopwatch.elapsed) + logger.info(markers, "Record processed") + taggedRecord.markProcessed() + } + automationStream.onComplete { + case Failure(exception) => logger.error("Thrall stream completed with failure", exception) + case Success(_) => logger.info("Thrall stream completed with done, probably shutting down") + } + stream } } diff --git a/thrall/app/lib/elasticsearch/ElasticSearch.scala b/thrall/app/lib/elasticsearch/ElasticSearch.scala index c82f53c6239..d888c71bbbe 100644 --- a/thrall/app/lib/elasticsearch/ElasticSearch.scala +++ b/thrall/app/lib/elasticsearch/ElasticSearch.scala @@ -2,25 +2,25 @@ package lib.elasticsearch import org.apache.pekko.actor.Scheduler import com.gu.mediaservice.lib.ImageFields -import com.gu.mediaservice.lib.elasticsearch.filters -import com.gu.mediaservice.lib.elasticsearch.{ElasticSearchClient, ElasticSearchConfig, ElasticSearchExecutions, ReapableEligibility, Running} +import com.gu.mediaservice.lib.elasticsearch.{filters, _} import com.gu.mediaservice.lib.formatting.printDateTime +import com.gu.mediaservice.lib.instances.InstancesClient import com.gu.mediaservice.lib.logging.{LogMarker, MarkerMap} import com.gu.mediaservice.model._ import com.gu.mediaservice.model.leases.MediaLease -import com.gu.mediaservice.model.usage.{Usage, UsageNotice} +import com.gu.mediaservice.model.usage.Usage import com.gu.mediaservice.syntax._ import com.sksamuel.elastic4s.ElasticDsl._ import com.sksamuel.elastic4s.requests.script.Script -import com.sksamuel.elastic4s.requests.searches.SearchResponse +import com.sksamuel.elastic4s.requests.searches.aggs.SumAggregation import com.sksamuel.elastic4s.requests.searches.queries.Query import com.sksamuel.elastic4s.requests.searches.queries.compound.BoolQuery import com.sksamuel.elastic4s.requests.searches.sort.SortOrder +import com.sksamuel.elastic4s.requests.searches.{SearchRequest, SearchResponse} import com.sksamuel.elastic4s.requests.update.UpdateRequest -import com.sksamuel.elastic4s.{ElasticDsl, Executor, Functor, Handler, Response} +import com.sksamuel.elastic4s._ import lib.{BatchDeletionIds, ThrallMetrics} import org.joda.time.DateTime -import play.api.libs.json.JsValue.jsValueToJsLookup import play.api.libs.json._ import scala.annotation.nowarn @@ -30,49 +30,48 @@ import scala.concurrent.{ExecutionContext, Future} object ImageNotDeletable extends Throwable("Image cannot be deleted") class ElasticSearch( - config: ElasticSearchConfig, + val elasticSearchConfig: ElasticSearchConfig, metrics: Option[ThrallMetrics], - val scheduler: Scheduler + val scheduler: Scheduler, + val instancesClient: InstancesClient ) extends ElasticSearchClient with ImageFields with ElasticSearchExecutions with ThrallMigrationClient { - - lazy val imagesCurrentAlias: String = config.aliases.current - lazy val imagesMigrationAlias: String = config.aliases.migration - lazy val url: String = config.url - lazy val shards: Int = config.shards - lazy val replicas: Int = config.replicas + lazy val url: String = elasticSearchConfig.url + lazy val shards: Int = elasticSearchConfig.shards + lazy val replicas: Int = elasticSearchConfig.replicas def migrationAwareUpdater[REQUEST, RESPONSE]( requestFromIndexName: String => REQUEST, logMessageFromIndexName: String => String, - notFoundSuccessful: Boolean = false, + notFoundSuccessful: Boolean = false )(implicit ex: ExecutionContext, functor: Functor[Future], executor: Executor[Future], handler: Handler[REQUEST, RESPONSE], manifest: Manifest[RESPONSE], - logMarkers: LogMarker + logMarkers: LogMarker, + instance: Instance ): Future[Response[RESPONSE]] = { // if doc does not exist in migration index, ignore (ie. mark as successful). // coalesce all other errors. - val runForCurrentIndex: Future[Option[Response[RESPONSE]]] = executeAndLog(requestFromIndexName(imagesCurrentAlias), logMessageFromIndexName(imagesCurrentAlias), notFoundSuccessful).map(Some(_)) + val runForCurrentIndex: Future[Option[Response[RESPONSE]]] = executeAndLog(requestFromIndexName(imagesCurrentAlias(instance)), logMessageFromIndexName(imagesCurrentAlias(instance)), notFoundSuccessful).map(Some(_)) // Update requests to the alias throw if the alias does not exist, but the exception is very generic and not cause is not obvious // ("index names must be all upper case") val runForMigrationIndex: Future[Option[Response[RESPONSE]]] = migrationStatus match { - case _: Running => executeAndLog(requestFromIndexName(imagesMigrationAlias), logMessageFromIndexName(imagesMigrationAlias), notFoundSuccessful = true).map(Some(_)) + case _: Running => executeAndLog(requestFromIndexName(imagesMigrationAlias(instance)), logMessageFromIndexName(imagesMigrationAlias(instance)), notFoundSuccessful = true).map(Some(_)) case _ => Future.successful(None) } // remove the optionality of the completed futures. runForCurrentIndex will always be Some, so there will always be a head. Future.sequence(List(runForCurrentIndex, runForMigrationIndex)).map(_.flatten.head) } - def setMigrationInfo(imageId: String, migrationInfo: MigrationInfo)(implicit ex: ExecutionContext, logMarker: LogMarker): Future[Response[Any]] = { + def setMigrationInfo(imageId: String, migrationInfo: MigrationInfo)(implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[Response[Any]] = { val esInfo = EsInfo(migration = Some(migrationInfo)) val container = Json.obj("esInfo" -> Json.toJson(esInfo)) - val request = updateById(imagesCurrentAlias, imageId) + val request = updateById(imagesCurrentAlias(instance), imageId) .doc(Json.stringify(container)) executeAndLog(request, s"Setting migration info on image id: ${imageId}") @@ -88,7 +87,7 @@ class ElasticSearch( def migrationAwareIndexImage(id: String, image: Image, lastModified: DateTime) - (implicit ex: ExecutionContext, logMarker: LogMarker): Future[ElasticSearchUpdateResponse] = { + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[ElasticSearchUpdateResponse] = { // On insert, we know we will not have a lastModified to consider, so we always take the one we get val insertImage = image.copy(lastModified = Some(lastModified)) @@ -132,9 +131,9 @@ class ElasticSearch( executeAndLog(indexRequest, s"ES6 indexing image $id into index aliased by '$indexAlias'") } - val runUpsertIntoMigrationIndexAndReturnEsInfoForCurrentIndex: Future[JsObject] = migrationStatus match { + def runUpsertIntoMigrationIndexAndReturnEsInfoForCurrentIndex()(implicit instance: Instance): Future[JsObject] = migrationStatus(instance) match { case running: Running => - runUpsertIntoIndex(imagesMigrationAlias, maybeEsInfo = None) + runUpsertIntoIndex(imagesMigrationAlias(instance), maybeEsInfo = None) .map(_ => EsInfo(Some(MigrationInfo(migratedTo = Some(running.migrationIndexName))))) .recover { case error => EsInfo(Some(MigrationInfo(failures = Some(Map( running.migrationIndexName -> error.getMessage @@ -144,13 +143,13 @@ class ElasticSearch( } for { - esInfo <- runUpsertIntoMigrationIndexAndReturnEsInfoForCurrentIndex - _ <- runUpsertIntoIndex(imagesCurrentAlias, maybeEsInfo = Some(esInfo)) + esInfo <- runUpsertIntoMigrationIndexAndReturnEsInfoForCurrentIndex() + _ <- runUpsertIntoIndex(imagesCurrentAlias(instance), maybeEsInfo = Some(esInfo)) } yield ElasticSearchUpdateResponse() } - def getImage(id: String)(implicit ex: ExecutionContext, logMarker: LogMarker): Future[Option[Image]] = { - executeAndLog(get(imagesCurrentAlias, id), s"ES6 get image by $id").map { r => + def getImage(id: String)(implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[Option[Image]] = { + executeAndLog(get(imagesCurrentAlias(instance), id), s"ES6 get image by $id").map { r => if (r.result.found) { Some(Json.parse(r.result.sourceAsString).as[Image]) } else { @@ -159,8 +158,8 @@ class ElasticSearch( } } - def getImageVersion(id: String)(implicit ex: ExecutionContext, logMarker: LogMarker = MarkerMap()): Future[Option[Long]] = { - executeAndLog(get(imagesCurrentAlias, id), s"ES6 get image version by $id").map { r => + def getImageVersion(id: String)(implicit ex: ExecutionContext, logMarker: LogMarker = MarkerMap(), instance: Instance): Future[Option[Long]] = { + executeAndLog(get(imagesCurrentAlias(instance), id), s"ES6 get image version by $id").map { r => if (r.result.found) { Some(r.result.version) } else { @@ -171,7 +170,7 @@ class ElasticSearch( def updateImageUsages(id: String, usages: Seq[Usage], lastModified: DateTime) - (implicit ex: ExecutionContext,logMarker: LogMarker): List[Future[ElasticSearchUpdateResponse]] = { + (implicit ex: ExecutionContext,logMarker: LogMarker, instance: Instance): List[Future[ElasticSearchUpdateResponse]] = { val replaceUsagesScript = loadUpdatingModificationPainless(s""" | def lastUpdatedDate = ctx._source.usagesLastModified != null ? Date.from(Instant.from(DateTimeFormatter.ISO_DATE_TIME.parse(ctx._source.usagesLastModified))) : null; | if (lastUpdatedDate == null || modificationDate.after(lastUpdatedDate)) { @@ -184,14 +183,14 @@ class ElasticSearch( val eventualUpdateResponse = migrationAwareUpdater( requestFromIndexName = indexName => prepareUpdateRequest(indexName, id, replaceUsagesScript, lastModified, ("usages", usagesParameter)), - logMessageFromIndexName = indexName => s"ES6 updating usages on image $id for index $indexName" + logMessageFromIndexName = indexName => s"ES6 updating usages on image $id for index $indexName", ).incrementOnFailure(metrics.map(_.failedUsagesUpdates)){case _ => true} List(eventualUpdateResponse.map(_ => ElasticSearchUpdateResponse())) } def updateImageSyndicationRights(id: String, rights: Option[SyndicationRights], lastModified: DateTime) - (implicit ex: ExecutionContext, logMarker: LogMarker): List[Future[ElasticSearchUpdateResponse]] = { + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): List[Future[ElasticSearchUpdateResponse]] = { val replaceSyndicationRightsScript = """ | ctx._source.syndicationRights = params.syndicationRights; @@ -203,12 +202,12 @@ class ElasticSearch( List(migrationAwareUpdater( requestFromIndexName = indexName => prepareUpdateRequest(indexName, id, scriptSource, lastModified, ("syndicationRights", rightsParameter)), - logMessageFromIndexName = indexName => s"ES6 updating syndicationRights on image $id in index $indexName with rights $rightsParameter" + logMessageFromIndexName = indexName => s"ES6 updating syndicationRights on image $id in index $indexName with rights $rightsParameter", ).map(_ => ElasticSearchUpdateResponse())) } def applyImageMetadataOverride(id: String, metadata: Edits, lastModified: DateTime) - (implicit ex: ExecutionContext, logMarker: LogMarker): List[Future[ElasticSearchUpdateResponse]] = { + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): List[Future[ElasticSearchUpdateResponse]] = { val photoshootSuggestionScript = """ | if (ctx._source.userMetadata.photoshoot != null) { @@ -265,7 +264,7 @@ class ElasticSearch( } def applySoftDelete(id: String, softDeletedMetadata: SoftDeletedMetadata, lastModified: DateTime) - (implicit ex: ExecutionContext, logMarker: LogMarker): List[Future[ElasticSearchUpdateResponse]] = { + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): List[Future[ElasticSearchUpdateResponse]] = { List(migrationAwareUpdater( requestFromIndexName = indexName => prepareUpdateRequest( @@ -278,7 +277,7 @@ class ElasticSearch( } def applyUnSoftDelete(id: String, lastModified: DateTime) - (implicit ex: ExecutionContext, logMarker: LogMarker): List[Future[ElasticSearchUpdateResponse]] = { + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): List[Future[ElasticSearchUpdateResponse]] = { val applyUnSoftDeleteScript = "ctx._source.remove(\"softDeletedMetadata\");" List(migrationAwareUpdater( @@ -288,14 +287,14 @@ class ElasticSearch( applyUnSoftDeleteScript, lastModified ), - logMessageFromIndexName = indexName => s"ES7 un soft delete image $id in $indexName" + logMessageFromIndexName = indexName => s"ES7 un soft delete image $id in $indexName", ).map(_ => ElasticSearchUpdateResponse())) } private def getNextBatchOfImageIdsForDeletion(query: Query, count: Int, deletionType: String) - (implicit ex: ExecutionContext, logMarker: LogMarker) = + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance) = executeAndLog( - ElasticDsl.search(imagesCurrentAlias) // current index is sufficient for producing the list of IDs to delete + ElasticDsl.search(imagesCurrentAlias(instance)) // current index is sufficient for producing the list of IDs to delete .query(query) .storedFields("_id") .sortByFieldAsc("uploadTime") @@ -304,21 +303,45 @@ class ElasticSearch( ).map(_.result.hits.hits.map(_.id).toSet) private def countTotalReapable(query: Query, deletionType: String) - (implicit ex: ExecutionContext, logMarker: LogMarker): Future[Long] = executeAndLog( - ElasticDsl.count(imagesCurrentAlias).query(query), + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[Long] = executeAndLog( + ElasticDsl.count(imagesCurrentAlias(instance)).query(query), s"counting '$deletionType' reapable images" - ).map(_.result.count) + ).map { response => + val count = response.result.count + logger.info(s"countTotalReapable ${instance.id} / $deletionType: $count") + count + } + + def countTotal(isSoftedDeleted: Boolean)(implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[Long] = { + executeAndLog( + ElasticDsl.count(imagesCurrentAlias(instance)).query(filters.existsOrMissing("softDeletedMetadata", exists = isSoftedDeleted)), + s"counting total images with soft deleted: $isSoftedDeleted" + ).map(_.result.count) + } + + def countTotalImageSize()(implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[Long] = { + val sourceSize: SumAggregation = ElasticDsl.sumAgg("sourceSize", "source.size") + val request: SearchRequest = search(imagesCurrentAlias(instance)).matchAllQuery() aggs sourceSize size 10 + executeAndLog(request, s"summing source.size").map { r => + if (r.result.aggs.contains("sourceSize")) { + r.result.aggs.getAgg("sourceSize").flatMap(_.dataAsMap.get("value")).map(_.asInstanceOf[Double].toLong).getOrElse(0L) + } else { + 0L + } + } + } private def softReapableQuery(isReapable: ReapableEligibility) = must( isReapable.query, filters.existsOrMissing("softDeletedMetadata", exists = false) // not already soft deleted ) - def countTotalSoftReapable(isReapable: ReapableEligibility)(implicit ex: ExecutionContext, logMarker: LogMarker): Future[Long] = + def countTotalSoftReapable(isReapable: ReapableEligibility) + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[Long] = countTotalReapable(softReapableQuery(isReapable), "soft") def softDeleteNextBatchOfImages(isReapable: ReapableEligibility, count: Int, softDeletedMetadata: SoftDeletedMetadata) - (implicit ex: ExecutionContext, logMarker: LogMarker): Future[BatchDeletionIds] = { + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[BatchDeletionIds] = { val query = softReapableQuery(isReapable) @@ -331,7 +354,7 @@ class ElasticSearch( updateById(indexName, _) .script(softDeletedMetadataAsPainlessScript(softDeletedMetadata)) )), - logMessageFromIndexName = indexName => s"ES7 soft delete ${ids.size} images in $indexName by ${softDeletedMetadata.deletedBy}" + logMessageFromIndexName = indexName => s"ES7 soft delete ${ids.size} images in $indexName by ${softDeletedMetadata.deletedBy}", ).map(_.result.items) } yield { if (ids.isEmpty) { @@ -344,17 +367,21 @@ class ElasticSearch( } } - private def hardReapableQuery(isReapable: ReapableEligibility, daysInSoftDeletedState: Int) = must( - isReapable.query, - filters.existsOrMissing("softDeletedMetadata", exists = true), // already soft deleted - rangeQuery("softDeletedMetadata.deleteTime").lt(DateTime.now.minusDays(daysInSoftDeletedState).toString) // soft deleted more than 2 weeks ago (default) - ) + private def hardReapableQuery(isReapable: ReapableEligibility, daysInSoftDeletedState: Int) = { + logger.info(s"Building hard reapable query for daysInSoftDeletedState $daysInSoftDeletedState") + must( + // isReapable.query, // TODO should be able to hard reap anything which got into a soft reaped state; other wise manual deletes will not work! + filters.existsOrMissing("softDeletedMetadata", exists = true), // already soft deleted + rangeQuery("softDeletedMetadata.deleteTime").lt(DateTime.now.minusDays(daysInSoftDeletedState).toString) // soft deleted more than 2 weeks ago (default) + ) + } - def countTotalHardReapable(isReapable: ReapableEligibility, daysInSoftDeletedState: Int)(implicit ex: ExecutionContext, logMarker: LogMarker): Future[Long] = + def countTotalHardReapable(isReapable: ReapableEligibility, daysInSoftDeletedState: Int) + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[Long] = countTotalReapable(hardReapableQuery(isReapable, daysInSoftDeletedState), "hard") def hardDeleteNextBatchOfImages(isReapable: ReapableEligibility, count: Int, daysInSoftDeletedState: Int) - (implicit ex: ExecutionContext, logMarker: LogMarker): Future[BatchDeletionIds] = { + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[BatchDeletionIds] = { val query = hardReapableQuery(isReapable, daysInSoftDeletedState) @@ -366,7 +393,7 @@ class ElasticSearch( bulk(ids.map( deleteById(indexName, _) )), - logMessageFromIndexName = indexName => s"ES7 hard delete ${ids.size} images in $indexName" + logMessageFromIndexName = indexName => s"ES7 hard delete ${ids.size} images in $indexName", ).map(_.result.items) } yield { if (ids.isEmpty) { @@ -380,7 +407,7 @@ class ElasticSearch( } def getInferredSyndicationRightsImages(photoshoot: Photoshoot, excludedImageId: Option[String]) - (implicit ex: ExecutionContext, logMarker: LogMarker): Future[List[Image]] = { // TODO could be a Seq + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[List[Image]] = { // TODO could be a Seq val inferredSyndicationRights = not(termQuery("syndicationRights.isInferred", false)) // Using 'not' to include nulls val filter = excludedImageId match { @@ -396,7 +423,7 @@ class ElasticSearch( filter ) - val request = search(imagesCurrentAlias) bool filteredMatches limit 200 // TODO no order? + val request = search(imagesCurrentAlias(instance)) bool filteredMatches limit 200 // TODO no order? executeAndLog(request, s"ES6 get images in photoshoot ${photoshoot.title} with inferred syndication rights (excluding $excludedImageId)").map { r => r.result.hits.hits.toList.map { h => @@ -406,7 +433,7 @@ class ElasticSearch( } def getLatestSyndicationRights(photoshoot: Photoshoot, excludedImageId: Option[String]) - (implicit ex: ExecutionContext, logMarker: LogMarker): Future[Option[Image]] = { + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): Future[Option[Image]] = { val nonInferredSyndicationRights = termQuery("syndicationRights.isInferred", false) val filter = excludedImageId match { @@ -424,7 +451,7 @@ class ElasticSearch( val syndicationRightsPublishedDescending = fieldSort("syndicationRights.published").order(SortOrder.DESC) - val request = search(imagesCurrentAlias) bool filteredMatches sortBy syndicationRightsPublishedDescending + val request = search(imagesCurrentAlias(instance)) bool filteredMatches sortBy syndicationRightsPublishedDescending executeAndLog(request, s"ES6 get image in photoshoot ${photoshoot.title} with latest rcs syndication rights (excluding $excludedImageId)").map { r => r.result.hits.hits.toList.headOption.map { h => @@ -446,7 +473,7 @@ class ElasticSearch( } def deleteImage(id: String) - (implicit ex: ExecutionContext, logMarker: LogMarker): List[Future[ElasticSearchDeleteResponse]] = { + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): List[Future[ElasticSearchDeleteResponse]] = { // search for the image first, and then only delete and succeed // this is because the delete query does not respond with anything useful // TODO: is there a more efficient way to do this? @@ -458,22 +485,22 @@ class ElasticSearch( ) (migrationStatus match { - case running: Running => List(imagesCurrentAlias, running.migrationIndexName) - case _ => List(imagesCurrentAlias) + case running: Running => List(imagesCurrentAlias(instance), running.migrationIndexName) + case _ => List(imagesCurrentAlias(instance)) }).map { index => deleteFromIndex(id, index, deletableImage).map { _ => ElasticSearchDeleteResponse() } } } def deleteSingleImageUsage( - id: String, usageId: String, lastModified: DateTime - )(implicit ex: ExecutionContext, logMarker: LogMarker): List[Future[ElasticSearchUpdateResponse]] = { + id: String, usageId: String, lastModified: DateTime) + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): List[Future[ElasticSearchUpdateResponse]] = { val deleteSingleUsageScript = loadUpdatingModificationPainless("ctx._source.usages.removeIf(usage -> usage.id == params.usageId);") val eventualUpdateResponse = migrationAwareUpdater( requestFromIndexName = indexName => prepareUpdateRequest(indexName, id, deleteSingleUsageScript, lastModified, "usageId" -> usageId), logMessageFromIndexName = indexName => s"ES6 removing usage $usageId on image $id in index $indexName", - notFoundSuccessful = true + notFoundSuccessful = true, ).incrementOnFailure(metrics.map(_.failedUsagesUpdates)) { case _ => true } List(eventualUpdateResponse.map(response => { @@ -486,13 +513,13 @@ class ElasticSearch( def deleteAllImageUsages( id: String, lastModified: DateTime - )(implicit ex: ExecutionContext, logMarker: LogMarker): List[Future[ElasticSearchUpdateResponse]] = { + )(implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): List[Future[ElasticSearchUpdateResponse]] = { val deleteUsagesScript = loadUpdatingModificationPainless("ctx._source.remove('usages');") val eventualUpdateResponse = migrationAwareUpdater( requestFromIndexName = indexName => prepareUpdateRequest(indexName, id, deleteUsagesScript, lastModified), logMessageFromIndexName = indexName => s"ES6 removing all usages on image $id in index $indexName", - notFoundSuccessful = true + notFoundSuccessful = true, ).incrementOnFailure(metrics.map(_.failedUsagesUpdates)){case _ => true} List(eventualUpdateResponse.map(response => { @@ -504,7 +531,7 @@ class ElasticSearch( } def updateUsageStatus(id: String, usages: Seq[Usage], lastModified: DateTime) - (implicit ex: ExecutionContext, logMarker: LogMarker): List[Future[ElasticSearchUpdateResponse]] = { + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): List[Future[ElasticSearchUpdateResponse]] = { val updateUsageStatusScript = s""" @@ -531,7 +558,7 @@ class ElasticSearch( } def deleteSyndicationRights(id: String, lastModified: DateTime) - (implicit ex: ExecutionContext, logMarker: LogMarker): List[Future[ElasticSearchUpdateResponse]] = { + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): List[Future[ElasticSearchUpdateResponse]] = { val deleteSyndicationRightsScript = s""" | $modificationDateFormatting | ctx._source.remove('syndicationRights'); @@ -548,7 +575,7 @@ class ElasticSearch( } def replaceImageLeases(id: String, leases: Seq[MediaLease], lastModified: DateTime) - (implicit ex: ExecutionContext, logMarker: LogMarker): List[Future[ElasticSearchUpdateResponse]] = { + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): List[Future[ElasticSearchUpdateResponse]] = { val replaceLeasesScript = """ | ctx._source.leases = ["leases": params.leases, "lastModified": params.lastModified]; @@ -560,7 +587,7 @@ class ElasticSearch( val eventualUpdateResponse = migrationAwareUpdater( requestFromIndexName = indexName => prepareUpdateRequest(indexName, id, scriptSource, lastModified, ("leases", leasesParameter)), - logMessageFromIndexName = indexName => s"ES6 updating all leases on image $id in index $indexName with: ${leases.toString}" + logMessageFromIndexName = indexName => s"ES6 updating all leases on image $id in index $indexName with: ${leases.toString}", ).incrementOnFailure(metrics.map(_.failedSyndicationRightsUpdates)){case _ => true} List(eventualUpdateResponse.map(_ => ElasticSearchUpdateResponse())) @@ -576,7 +603,7 @@ class ElasticSearch( prepareUpdateRequest(indexName, id, prepareScript(scriptSource, lastModified, params:_*)) def addImageLease(id: String, lease: MediaLease, lastModified: DateTime) - (implicit ex: ExecutionContext, logMarker: LogMarker): List[Future[ElasticSearchUpdateResponse]] = { + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): List[Future[ElasticSearchUpdateResponse]] = { val addLeaseScript = """| if (ctx._source.leases == null || ctx._source.leases.leases == null) { @@ -593,14 +620,14 @@ class ElasticSearch( val eventualUpdateResponse = migrationAwareUpdater( requestFromIndexName = indexName => prepareUpdateRequest(indexName, id, scriptSource, lastModified, ("lease", leaseParameter)), - logMessageFromIndexName = indexName => s"ES6 adding lease on image $id in index $indexName with: $leaseParameter" + logMessageFromIndexName = indexName => s"ES6 adding lease on image $id in index $indexName with: $leaseParameter", ).incrementOnFailure(metrics.map(_.failedUsagesUpdates)){case _ => true} List(eventualUpdateResponse.map(_ => ElasticSearchUpdateResponse())) } def removeImageLease(id: String, leaseId: Option[String], lastModified: DateTime) - (implicit ex: ExecutionContext, logMarker: LogMarker): List[Future[ElasticSearchUpdateResponse]] = { + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): List[Future[ElasticSearchUpdateResponse]] = { val removeLeaseScript = """| | for(int i = 0; i < ctx._source.leases.leases.size(); i++) { @@ -625,7 +652,7 @@ class ElasticSearch( } def updateImageExports(id: String, exports: Seq[Crop], lastModified: DateTime) - (implicit ex: ExecutionContext, logMarker: LogMarker): List[Future[ElasticSearchUpdateResponse]] = { + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): List[Future[ElasticSearchUpdateResponse]] = { val addExportsScript = """| if (ctx._source.exports == null) { | ctx._source.exports = params.exports; @@ -651,7 +678,7 @@ class ElasticSearch( } def deleteImageExports(id: String, lastModified: DateTime) - (implicit ex: ExecutionContext, logMarker: LogMarker): List[Future[ElasticSearchUpdateResponse]] = { + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): List[Future[ElasticSearchUpdateResponse]] = { val deleteExportsScript = "ctx._source.remove('exports');" val scriptSource = loadUpdatingModificationPainless(deleteExportsScript) @@ -666,7 +693,7 @@ class ElasticSearch( } def setImageCollections(id: String, collections: Seq[Collection], lastModified: DateTime) - (implicit ex: ExecutionContext, logMarker: LogMarker): List[Future[ElasticSearchUpdateResponse]] = { + (implicit ex: ExecutionContext, logMarker: LogMarker, instance: Instance): List[Future[ElasticSearchUpdateResponse]] = { val setImagesCollectionScript = "ctx._source.collections = params.collections;" val setImageCollectionsScript = loadUpdatingModificationPainless(setImagesCollectionScript) @@ -708,9 +735,9 @@ class ElasticSearch( } def listImageIdsWithPrefix(prefix: String)( - implicit ec: ExecutionContext, logMarker: LogMarker + implicit ec: ExecutionContext, logMarker: LogMarker, instance: Instance ): Future[Seq[String]] = { - val req = search(imagesCurrentAlias) + val req = search(imagesCurrentAlias(instance)) .size(scrollPageSize) .fetchSource(false) .scroll(60.seconds) @@ -719,8 +746,8 @@ class ElasticSearch( .flatMap(response => handleImageIdScrollResponse(s"listing ids with prefix $prefix", response)) } - def listImageIdsWithUnexpectedFormat()(implicit ec: ExecutionContext, logMarker: LogMarker) = { - val req = search(imagesCurrentAlias) + def listImageIdsWithUnexpectedFormat()(implicit ec: ExecutionContext, logMarker: LogMarker, instance: Instance) = { + val req = search(imagesCurrentAlias(instance)) .size(scrollPageSize) .fetchSource(false) .scroll(60.seconds) diff --git a/thrall/app/lib/elasticsearch/GoodToGoCheck.scala b/thrall/app/lib/elasticsearch/GoodToGoCheck.scala deleted file mode 100644 index 601d524a61e..00000000000 --- a/thrall/app/lib/elasticsearch/GoodToGoCheck.scala +++ /dev/null @@ -1,128 +0,0 @@ -package lib.elasticsearch - -import com.amazonaws.util.EC2MetadataUtils -import com.gu.mediaservice.lib.elasticsearch.{MappingTest, Running} -import com.gu.mediaservice.lib.logging.LogMarker -import com.gu.mediaservice.model.Image -import com.sksamuel.elastic4s.Response -import com.sksamuel.elastic4s.requests.delete.{DeleteByQueryResponse, DeleteResponse} -import com.typesafe.scalalogging.StrictLogging -import org.joda.time.{DateTime, Period} - -import scala.concurrent.{ExecutionContext, Future} -import scala.util.Success - -/** - * This implements a good to go check that validates when Thrall starts up that it can successfully write and read - * an image into the elasticsearch node. - */ -object GoodToGoCheck extends StrictLogging { - def run(es: ElasticSearch)(implicit ex: ExecutionContext): Future[Unit] = { - // make a test image with an ID that is unique to the node so that multiple nodes can run the check at once - val uniqueId = Option(EC2MetadataUtils.getInstanceId).map(_.stripPrefix("i-")).getOrElse("abcdef") - val checkTime = DateTime.now - val image: Image = MappingTest.testImage.copy( - id = s"${MappingTest.testImage.id}$uniqueId", - lastModified = Some(checkTime) // use now as the last modified to aid cleanup of old test images - ) - val lastModified = image.lastModified.get - implicit val marker: LogMarker = image ++ Map("goToGo" -> true) - - val indexResult: Future[Unit] = - for { - // Perhaps a test image wasn't previously cleaned up... this might happen locally or if a thrall process - // is being cycled by systemd on an instance. Let's err on the side of caution and check and deal with it. - _ <- Future.successful(logger.info(s"Ensuring test image ${image.id} doesn't exist")) - checkAbsent <- es.getImage(image.id) - _ <- Future.successful(if (checkAbsent.nonEmpty) logger.info("Deleting prior test image")) - _ <- if (checkAbsent.nonEmpty) deleteTestImage(es, image.id) else Future.successful(()) - // now index and retrieve the test image - _ <- Future.successful(logger.info(s"Indexing test image ${image.id}")) - indexResult <- es.migrationAwareIndexImage(image.id, image, lastModified) - _ <- Future.successful(logger.info(s"Retrieving test image ${image.id}")) - maybeRetrieveResult <- es.getImage(image.id) - _ <- Future.successful(logger.info(s"Validating test image")) - validation <- { - // check retrieving it is the same object - note: we use a string check here as the DateTimes don't compare - // usefully and we override the lastModified to avoid TZ issues in BST - val retrievedImageSameAsIndexed = - approximatelyEqual(maybeRetrieveResult, Some(image)) - if (!retrievedImageSameAsIndexed) logger.warn(s"Image mismatch: \n$maybeRetrieveResult\n${Some(image)}") - - if (retrievedImageSameAsIndexed) { - Future.successful(()) - } else { - Future.failed(new IllegalStateException(s"Failed to validate insertion [Indexed 1 image; retrieved image matches indexed image: $retrievedImageSameAsIndexed]")) - } - } - // once that's all done, let's delete the image again so it's not lying around - _ <- Future.successful(logger.info(s"Deleting test image")) - _ <- deleteTestImage(es, image.id) - // clean up any images from over an hour ago that were not deleted - cleanedUpCount <- deleteOldTestImages(es, checkTime) - _ <- Future.successful(if (cleanedUpCount > 0) logger.info(s"Deleted $cleanedUpCount old test image(s)")) - } yield validation - - // return the future for the healthcheck - indexResult - } - - def deleteTestImage(es: ElasticSearch, imageId: String)(implicit ex: ExecutionContext, lm: LogMarker) = { - import com.sksamuel.elastic4s.ElasticDsl._ - // this manipulates the underlying ES API as the built in delete API guards against deleting images that are in use - Future.sequence((es.migrationStatus match { - case running: Running => List(es.imagesCurrentAlias, running.migrationIndexName) - case _ => List(es.imagesCurrentAlias) - }).map { indexName => - es.executeAndLog(deleteById(indexName, imageId), s"Deleting good to go test image $imageId") - }) - } - - def deleteOldTestImages(es: ElasticSearch, now: DateTime)(implicit ex: ExecutionContext, lm: LogMarker): Future[Long] = { - import com.sksamuel.elastic4s.ElasticDsl._ - // only act on images from some time ago so we don't delete image - val olderThan = now.minus(Period.minutes(10)) - val query = must( - rangeQuery("lastModified").lte(olderThan.getMillis), // older than this - termQuery("uploadedBy", MappingTest.testUploader) // and belonging to the uploader - ) - - val indicesToDeleteFrom = es.migrationStatus match { - case running: Running => List(es.imagesCurrentAlias, running.migrationIndexName) - case _ => List(es.imagesCurrentAlias) - } - - val deleteRequest = deleteIn(indicesToDeleteFrom).by(query).copy(waitForCompletion = Some(true)) - - - // this manipulates the underlying ES API as the built in delete API guards against deleting images that are in use - val eventualResult = es.executeAndLog(deleteRequest, s"Deleting any old test images uploaded by ${MappingTest.testImage.uploadedBy}") - - eventualResult.map(_.result match { - case Left(deleteResponse) => deleteResponse.deleted - // The Right (createdTask) response doesn't make sense - we have wait for completion enabled, but elastic4s forces us to still consider this case - case Right(createdTask) => throw new IllegalArgumentException("Request to delete_by_query returned an asynchronous task, despite wait_for_completion requested") - }) - } - - /** This function computes an approximate equality for some objects which is fuzzy when it comes to Joda DateTimes - * and will say that two objects are equal even when they contain DateTimes within them that are the same point - * in time but expressed in two different ways (i.e. two different time zones) - * @param a The first object to compare - * @param b A second object to compare - * @return true if the objects are equal (aside from chronology/timezone in DateTimes) - */ - def approximatelyEqual(a: Any, b: Any): Boolean = { - (a, b) match { - case (aTrav: Iterable[_], bTrav: Iterable[_]) => - aTrav.zip(bTrav) - .forall { case (aVal, bVal) => approximatelyEqual(aVal, bVal) } - case (aProduct: Product, bProduct: Product) => - aProduct.productIterator - .zip(bProduct.productIterator) - .forall { case (aVal, bVal) => approximatelyEqual(aVal, bVal) } - case (aDateTime: DateTime, bDateTime: DateTime) => aDateTime.getMillis == bDateTime.getMillis - case (aOther, bOther) => aOther == bOther - } - } -} diff --git a/thrall/app/lib/elasticsearch/ThrallMigrationClient.scala b/thrall/app/lib/elasticsearch/ThrallMigrationClient.scala index bb5b96ee8ab..75b012d4b62 100644 --- a/thrall/app/lib/elasticsearch/ThrallMigrationClient.scala +++ b/thrall/app/lib/elasticsearch/ThrallMigrationClient.scala @@ -2,7 +2,7 @@ package lib.elasticsearch import com.gu.mediaservice.lib.elasticsearch.{CompletionPreview, ElasticSearchClient, InProgress, MigrationAlreadyRunningError, MigrationNotRunningError, MigrationStatus, MigrationStatusProvider, NotRunning, Paused, Running} import com.gu.mediaservice.lib.logging.{LogMarker, MarkerMap} -import com.gu.mediaservice.model.Image +import com.gu.mediaservice.model.{Image, Instance} import com.sksamuel.elastic4s.ElasticApi.{existsQuery, matchQuery, not} import com.sksamuel.elastic4s.ElasticDsl import com.sksamuel.elastic4s.ElasticDsl.{addAlias, aliases, removeAlias, _} @@ -24,9 +24,10 @@ trait ThrallMigrationClient extends MigrationStatusProvider { private val scrollKeepAlive = 5.minutes - def startScrollingImageIdsToMigrate(migrationIndexName: String)(implicit ex: ExecutionContext, logMarker: LogMarker = MarkerMap()) = { + def startScrollingImageIdsToMigrate(migrationIndexName: String) + (implicit ex: ExecutionContext, logMarker: LogMarker = MarkerMap(), instance: Instance) = { // TODO create constant for field name "esInfo.migration.migratedTo" - val query = search(imagesCurrentAlias).version(true).scroll(scrollKeepAlive).size(100) query not( + val query = search(imagesCurrentAlias(instance)).version(true).scroll(scrollKeepAlive).size(100) query not( matchQuery("esInfo.migration.migratedTo", migrationIndexName), existsQuery(s"esInfo.migration.failures.$migrationIndexName") ) @@ -47,9 +48,10 @@ trait ThrallMigrationClient extends MigrationStatusProvider { } } - private def adjustMigrationAlias(action: String)(handleIfApplicable: PartialFunction[MigrationStatus, Unit]): Unit = { + private def adjustMigrationAlias(action: String)(handleIfApplicable: PartialFunction[MigrationStatus, Unit]) + (implicit instance: Instance): Unit = { handleIfApplicable.applyOrElse( - refreshAndRetrieveMigrationStatus(), + refreshAndRetrieveMigrationStatus(instance), (currentStatus: MigrationStatus) => { logger.error(s"Could not $action migration when migration status is $currentStatus") throw new MigrationNotRunningError @@ -57,26 +59,40 @@ trait ThrallMigrationClient extends MigrationStatusProvider { ) } - def pauseMigration(): Unit = adjustMigrationAlias("pause") { - case InProgress(migrationIndexName) => - assignAliasTo(migrationIndexName, MigrationStatusProvider.PAUSED_ALIAS) + def pauseMigration(instance: Instance): Unit = { + implicit val i: Instance = instance + adjustMigrationAlias("pause") { + case InProgress(migrationIndexName) => + assignAliasTo(migrationIndexName, MigrationStatusProvider.PAUSED_ALIAS) + } } - def resumeMigration(): Unit = adjustMigrationAlias("resume") { - case Paused(migrationIndexName) => - removeAliasFrom(migrationIndexName, MigrationStatusProvider.PAUSED_ALIAS) + + def resumeMigration(instance: Instance): Unit = { + implicit val i: Instance = instance + adjustMigrationAlias("resume") { + case Paused(migrationIndexName) => + removeAliasFrom(migrationIndexName, MigrationStatusProvider.PAUSED_ALIAS) + } } - def previewMigrationCompletion(): Unit = adjustMigrationAlias("preview complete") { - case running: Running => - assignAliasTo(running.migrationIndexName, MigrationStatusProvider.COMPLETION_PREVIEW_ALIAS) + def previewMigrationCompletion(instance: Instance): Unit = { + implicit val i: Instance = instance + adjustMigrationAlias("preview complete") { + + case running: Running => + assignAliasTo(running.migrationIndexName, MigrationStatusProvider.COMPLETION_PREVIEW_ALIAS) + } } - def unPreviewMigrationCompletion(): Unit = adjustMigrationAlias("unpreview complete") { - case CompletionPreview(migrationIndexName) => - removeAliasFrom(migrationIndexName, MigrationStatusProvider.COMPLETION_PREVIEW_ALIAS) + def unPreviewMigrationCompletion(instance: Instance): Unit = { + implicit val i: Instance = instance + adjustMigrationAlias("unpreview complete") { + case CompletionPreview(migrationIndexName) => + removeAliasFrom(migrationIndexName, MigrationStatusProvider.COMPLETION_PREVIEW_ALIAS) + } } - def startMigration(newIndexName: String)(implicit logMarker: LogMarker): Unit = { - val currentStatus = refreshAndRetrieveMigrationStatus() + def startMigration(newIndexName: String)(implicit logMarker: LogMarker, instance: Instance): Unit = { + val currentStatus = refreshAndRetrieveMigrationStatus(instance) if (currentStatus != NotRunning) { logger.error(logMarker, s"Could not start migration to $newIndexName when migration status is $currentStatus") throw new MigrationAlreadyRunningError @@ -84,23 +100,23 @@ trait ThrallMigrationClient extends MigrationStatusProvider { for { _ <- createImageIndex(newIndexName) _ = logger.info(logMarker, s"Created index $newIndexName") - _ <- assignAliasTo(newIndexName, imagesMigrationAlias) - _ = logger.info(logMarker, s"Assigned migration index $imagesMigrationAlias to $newIndexName") - _ = refreshAndRetrieveMigrationStatus() + _ <- assignAliasTo(newIndexName, imagesMigrationAlias(instance)) + _ = logger.info(logMarker, s"Assigned migration index ${imagesMigrationAlias(instance)} to $newIndexName") + _ = refreshAndRetrieveMigrationStatus(instance) } yield () } - def completeMigration(logMarker: LogMarker)(implicit ec: ExecutionContext): Future[Unit] = { - val currentStatus = refreshAndRetrieveMigrationStatus() + def completeMigration(logMarker: LogMarker)(implicit ec: ExecutionContext, instance: Instance): Future[Unit] = { + val currentStatus = refreshAndRetrieveMigrationStatus(instance) currentStatus match { case completionPreview: CompletionPreview => for { - currentIndex <- getIndexForAlias(imagesCurrentAlias) - currentIndexName <- currentIndex.map(_.name).map(Future.successful).getOrElse(Future.failed(new Exception(s"No index found for '$imagesCurrentAlias' alias"))) + currentIndex <- getIndexForAlias(imagesCurrentAlias(instance)) + currentIndexName <- currentIndex.map(_.name).map(Future.successful).getOrElse(Future.failed(new Exception(s"No index found for '${imagesCurrentAlias(instance)}' alias"))) _ <- client.execute { aliases ( - removeAlias(imagesMigrationAlias, completionPreview.migrationIndexName), - removeAlias(imagesCurrentAlias, currentIndexName), - addAlias(imagesCurrentAlias, completionPreview.migrationIndexName), - addAlias(imagesHistoricalAlias, currentIndexName) + removeAlias(imagesMigrationAlias(instance), completionPreview.migrationIndexName), + removeAlias(imagesCurrentAlias(instance), currentIndexName), + addAlias(imagesCurrentAlias(instance), completionPreview.migrationIndexName), + addAlias(imagesHistoricalAlias(instance), currentIndexName) )}.transform { case Success(response) if response.result.success => Success(()) @@ -110,7 +126,7 @@ trait ThrallMigrationClient extends MigrationStatusProvider { Failure(new Exception("Failed to complete migration (alias switching failed)")) } _ = logger.info(logMarker, s"Completed Migration (by switching & removing aliases)") - _ = refreshAndRetrieveMigrationStatus() + _ = refreshAndRetrieveMigrationStatus(instance) } yield () case _ => logger.error(logMarker, s"Cannot complete migration when migration status is $currentStatus, migration must be in 'CompletionPreview' state") diff --git a/thrall/app/lib/kinesis/KinesisConfig.scala b/thrall/app/lib/kinesis/KinesisConfig.scala index a4ab1cb6ee1..d5e9dd1e2fd 100644 --- a/thrall/app/lib/kinesis/KinesisConfig.scala +++ b/thrall/app/lib/kinesis/KinesisConfig.scala @@ -28,9 +28,10 @@ object KinesisConfig extends GridLogging { val metricsConfig = new MetricsConfig(config.cloudwatchClient, config.streamName) .metricsLevel(config.metricsLevel) + logger.info(s"Creating consumer config for appName: ${config.appName}, streamName: ${config.streamName} with rewind ${config.rewindFrom} and initial position: $initialPosition") val clientConfig = ConsumerConfig( streamName = config.streamName, - appName = config.streamName, + appName = config.appName, workerId = workerId, kinesisClient = config.kinesisClient, dynamoClient = config.dynamoClient, diff --git a/thrall/app/lib/kinesis/MessageProcessor.scala b/thrall/app/lib/kinesis/MessageProcessor.scala index 03c7b4616bf..74449861e17 100644 --- a/thrall/app/lib/kinesis/MessageProcessor.scala +++ b/thrall/app/lib/kinesis/MessageProcessor.scala @@ -1,10 +1,16 @@ package lib.kinesis -import com.gu.mediaservice.lib.aws.EsResponse +import com.gu.mediaservice.GridClient +import com.gu.mediaservice.lib.auth.Authentication +import com.gu.mediaservice.lib.aws.{EsResponse, ThrallMessageSender, UpdateMessage} import com.gu.mediaservice.lib.elasticsearch.{ElasticNotFoundException, Running} +import com.gu.mediaservice.lib.events.UsageEvents import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker, combineMarkers} -import com.gu.mediaservice.model.{AddImageLeaseMessage, CreateMigrationIndexMessage, DeleteImageExportsMessage, DeleteImageMessage, DeleteUsagesMessage, ImageMessage, MigrateImageMessage, RemoveImageLeaseMessage, ReplaceImageLeasesMessage, SetImageCollectionsMessage, SoftDeleteImageMessage, UnSoftDeleteImageMessage, ThrallMessage, UpdateImageExportsMessage, UpdateImagePhotoshootMetadataMessage, UpdateImageSyndicationMetadataMessage, UpdateImageUsagesMessage, UpdateImageUserMetadataMessage} +import com.gu.mediaservice.model.{AddImageLeaseMessage, CreateMigrationIndexMessage, DeleteImageExportsMessage, DeleteImageMessage, DeleteUsagesMessage, ImageMessage, MigrateImageMessage, RemoveImageLeaseMessage, ReplaceImageLeasesMessage, SetImageCollectionsMessage, SoftDeleteImageMessage, ThrallMessage, UnSoftDeleteImageMessage, UpdateImageExportsMessage, UpdateImagePhotoshootMetadataMessage, UpdateImageSyndicationMetadataMessage, UpdateImageUsagesMessage, UpdateImageUserMetadataMessage} import com.gu.mediaservice.model.usage.{Usage, UsageNotice} +import com.gu.mediaservice.syntax.MessageSubjects.Image +import instances.{InstanceMessageSender, InstanceStatusMessage} +import org.joda.time.DateTime // import all except `Right`, which otherwise shadows the type used in `Either`s import com.gu.mediaservice.model.{Right => _, _} import com.gu.mediaservice.syntax.MessageSubjects @@ -26,6 +32,11 @@ class MessageProcessor( es: ElasticSearch, store: ThrallStore, metadataEditorNotifications: MetadataEditorNotifications, + gridClient: GridClient, + auth: Authentication, + instanceMessageSender: InstanceMessageSender, + usageEvents: UsageEvents, + messageSender: ThrallMessageSender ) extends GridLogging with MessageSubjects { def process(updateMessage: ThrallMessage, logMarker: LogMarker)(implicit ec: ExecutionContext): Future[Any] = { @@ -50,21 +61,28 @@ class MessageProcessor( case message: MigrateImageMessage => migrateImage(message, logMarker) case message: UpsertFromProjectionMessage => upsertImageFromProjection(message, logMarker) case message: UpdateUsageStatusMessage => updateUsageStatus(message, logMarker) - case _: CompleteMigrationMessage => completeMigration(logMarker) + case message: CompleteMigrationMessage => completeMigration(message, logMarker) + case message: CreateInstanceMessage => setupNewInstance(message, logMarker) + case message: ReindexImageMessage => reindexImage(message, logMarker) + case _ => + logger.info(s"Unmatched ThrallMessage type: ${updateMessage.subject}; ignoring") + Future.successful(()) } } def updateImageUsages(message: UpdateImageUsagesMessage, logMarker: LogMarker)(implicit ec: ExecutionContext): Future[List[ElasticSearchUpdateResponse]] = { + implicit val iw: OWrites[Instance] = Json.writes[Instance] implicit val unw: OWrites[UsageNotice] = Json.writes[UsageNotice] implicit val lm: LogMarker = combineMarkers(message, logMarker) val usages = message.usageNotice.usageJson.as[Seq[Usage]] + implicit val instance: Instance = message.instance Future.traverse(es.updateImageUsages(message.id, usages, message.lastModified))(_.recoverWith { case ElasticNotFoundException => Future.successful(ElasticSearchUpdateResponse()) }) } private def indexImage(message: ImageMessage, logMarker: LogMarker)(implicit ec: ExecutionContext) = - es.migrationAwareIndexImage(message.id, message.image, message.lastModified)(ec, logMarker) + es.migrationAwareIndexImage(message.id, message.image, message.lastModified)(ec, logMarker, message.instance) private def upsertImageFromProjection(message: UpsertFromProjectionMessage, logMarker: LogMarker)(implicit ec: ExecutionContext) = { implicit val implicitLogMarker: LogMarker = logMarker ++ Map("imageId" -> message.id) @@ -73,7 +91,7 @@ class MessageProcessor( // images-for-migration process find and migrate it. even if it has previously // been migrated, this directInsert will wipe out the esInfo marker, requeueing this image // for migration. - es.directInsert(message.image, es.imagesCurrentAlias) + es.directInsert(message.image, es.imagesCurrentAlias(message.instance)) .recover { case t: Throwable => logger.error(logMarker, s"Failed to directly upsert image ${message.image.id} from projection", t) @@ -83,6 +101,7 @@ class MessageProcessor( private def migrateImage(message: MigrateImageMessage, logMarker: LogMarker)(implicit ec: ExecutionContext) = { implicit val implicitLogMarker: LogMarker = logMarker ++ Map("imageId" -> message.id) + implicit val instance: Instance = message.instance val maybeStart = message.maybeImageWithVersion match { case Left(errorMessage) => Future.failed(ProjectionFailure(errorMessage)) @@ -101,7 +120,7 @@ class MessageProcessor( Future.failed(VersionComparisonFailure(s"Version comparison failed for image id: ${image.id} -> current = $currentVersion, expected = $expectedVersion")) } }.flatMap( - image => es.directInsert(image, es.imagesMigrationAlias).transform { + image => es.directInsert(image, es.imagesMigrationAlias(instance)).transform { case s@Success(_) => s case Failure(exception) => Failure(InsertImageFailure(exception.toString)) } @@ -122,38 +141,47 @@ class MessageProcessor( } } - private def updateImageExports(message: UpdateImageExportsMessage, logMarker: LogMarker)(implicit ec: ExecutionContext) = + private def updateImageExports(message: UpdateImageExportsMessage, logMarker: LogMarker)(implicit ec: ExecutionContext) = { Future.sequence( - es.updateImageExports(message.id, message.crops, message.lastModified)(ec, logMarker)) + es.updateImageExports(message.id, message.crops, message.lastModified)(ec, logMarker, message.instance)) + } - private def deleteImageExports(message: DeleteImageExportsMessage, logMarker: LogMarker)(implicit ec: ExecutionContext) = + private def deleteImageExports(message: DeleteImageExportsMessage, logMarker: LogMarker)(implicit ec: ExecutionContext) = { Future.sequence( - es.deleteImageExports(message.id, message.lastModified)(ec, logMarker)) + es.deleteImageExports(message.id, message.lastModified)(ec, logMarker, message.instance)) + } private def softDeleteImage(message: SoftDeleteImageMessage, logMarker: LogMarker)(implicit ec: ExecutionContext) = - Future.sequence(es.applySoftDelete(message.id, message.softDeletedMetadata, message.lastModified)(ec, logMarker)) + Future.sequence(es.applySoftDelete(message.id, message.softDeletedMetadata, message.lastModified)(ec, logMarker, message.instance)).map { r => + usageEvents.softDelete(instance = message.instance, image = message.id) + r + } private def unSoftDeleteImage(message: UnSoftDeleteImageMessage, logMarker: LogMarker)(implicit ec: ExecutionContext) = - Future.sequence(es.applyUnSoftDelete(message.id, message.lastModified)(ec, logMarker)) + Future.sequence(es.applyUnSoftDelete(message.id, message.lastModified)(ec, logMarker, message.instance)).map { r => + usageEvents.unsoftDelete(instance = message.instance, image = message.id) + r + } private def updateImageUserMetadata(message: UpdateImageUserMetadataMessage, logMarker: LogMarker)(implicit ec: ExecutionContext) = - Future.sequence(es.applyImageMetadataOverride(message.id, message.edits, message.lastModified)(ec, logMarker)) + Future.sequence(es.applyImageMetadataOverride(message.id, message.edits, message.lastModified)(ec, logMarker, message.instance)) private def replaceImageLeases(message: ReplaceImageLeasesMessage, logMarker: LogMarker)(implicit ec: ExecutionContext) = - Future.sequence(es.replaceImageLeases(message.id, message.leases, message.lastModified)(ec, logMarker)) + Future.sequence(es.replaceImageLeases(message.id, message.leases, message.lastModified)(ec, logMarker, message.instance)) private def addImageLease(message: AddImageLeaseMessage, logMarker: LogMarker)(implicit ec: ExecutionContext) = - Future.sequence(es.addImageLease(message.id, message.lease, message.lastModified)(ec, logMarker)) + Future.sequence(es.addImageLease(message.id, message.lease, message.lastModified)(ec, logMarker, message.instance)) private def removeImageLease(message: RemoveImageLeaseMessage, logMarker: LogMarker)(implicit ec: ExecutionContext): Future[List[ElasticSearchUpdateResponse]] = - Future.sequence(es.removeImageLease(message.id, Some(message.leaseId), message.lastModified)(ec, logMarker)) + Future.sequence(es.removeImageLease(message.id, Some(message.leaseId), message.lastModified)(ec, logMarker, message.instance)) private def setImageCollections(message: SetImageCollectionsMessage, logMarker: LogMarker)(implicit ec: ExecutionContext) = - Future.sequence(es.setImageCollections(message.id, message.collections, message.lastModified)(ec, logMarker)) + Future.sequence(es.setImageCollections(message.id, message.collections, message.lastModified)(ec, logMarker, message.instance)) private def deleteImage(message: DeleteImageMessage, logMarker: LogMarker)(implicit ec: ExecutionContext) = { Future.sequence({ implicit val marker: LogMarker = logMarker ++ imageIdMarker(ImageId(message.id)) + implicit val instance: Instance = message.instance // if we cannot delete the image as it's "protected", succeed and delete // the message anyway. logger.info(marker, "ES6 Deleting image: " + message.id) @@ -163,7 +191,8 @@ class MessageProcessor( store.deleteOriginal(message.id) store.deleteThumbnail(message.id) store.deletePNG(message.id) -// metadataEditorNotifications.publishImageDeletion(message.id) // let's not delete from Dynamo as user edits might be useful if we restore from replica + metadataEditorNotifications.publishImageDeletion(message.id, message.instance) + usageEvents.deleteImage(instance = message.instance, image = message.id) EsResponse(s"Image deleted: ${message.id}") } recoverWith { case ImageNotDeletable => @@ -177,22 +206,24 @@ class MessageProcessor( } private def deleteAllUsages(message: DeleteUsagesMessage, logMarker: LogMarker)(implicit ec: ExecutionContext) = - Future.sequence(es.deleteAllImageUsages(message.id, message.lastModified)(ec, logMarker)) + Future.sequence(es.deleteAllImageUsages(message.id, message.lastModified)(ec, logMarker, message.instance)) private def deleteSingleUsage(message: DeleteSingleUsageMessage, logMarker: LogMarker)(implicit ec: ExecutionContext) = { - Future.sequence(es.deleteSingleImageUsage(message.id, message.usageId, message.lastModified)(ec, logMarker)) + Future.sequence(es.deleteSingleImageUsage(message.id, message.usageId, message.lastModified)(ec, logMarker, message.instance)) } private def updateUsageStatus(message: UpdateUsageStatusMessage, logMarker: LogMarker)(implicit ec: ExecutionContext): Future[List[ElasticSearchUpdateResponse]] = { implicit val lm: LogMarker = combineMarkers(message, logMarker) val usage = message.usageNotice.usageJson.as[Seq[Usage]] - Future.traverse(es.updateUsageStatus(message.id, usage, message.lastModified ))(_.recoverWith { + implicit val instance: Instance = message.instance + Future.traverse(es.updateUsageStatus(message.id, usage, message.lastModified))(_.recoverWith { case ElasticNotFoundException => Future.successful(ElasticSearchUpdateResponse()) }) } def upsertSyndicationRightsOnly(message: UpdateImageSyndicationMetadataMessage, logMarker: LogMarker)(implicit ec: ExecutionContext): Future[Any] = { implicit val marker: LogMarker = logMarker ++ imageIdMarker(ImageId(message.id)) + implicit val instance: Instance = message.instance es.getImage(message.id) map { case Some(image) => val photoshoot = image.userMetadata.flatMap(_.photoshoot) @@ -204,20 +235,60 @@ class MessageProcessor( def updateImagePhotoshoot(message: UpdateImagePhotoshootMetadataMessage, logMarker: LogMarker)(implicit ec: ExecutionContext): Future[Unit] = { implicit val marker: LogMarker = logMarker ++ imageIdMarker(ImageId(message.id)) + implicit val instance: Instance = message.instance for { imageOpt <- es.getImage(message.id) prevPhotoshootOpt = imageOpt.flatMap(_.userMetadata.flatMap(_.photoshoot)) - _ <- updateImageUserMetadata(UpdateImageUserMetadataMessage(message.id, message.lastModified, message.edits), logMarker) + _ <- updateImageUserMetadata(UpdateImageUserMetadataMessage(message.id, message.lastModified, message.edits, message.instance), logMarker) } yield logger.info(marker, s"Moved image ${message.id} from $prevPhotoshootOpt to ${message.edits.photoshoot}") } def createMigrationIndex(message: CreateMigrationIndexMessage, logMarker: LogMarker)(implicit ec: ExecutionContext): Future[Unit] = { Future { - es.startMigration(message.newIndexName)(logMarker) + es.startMigration(message.newIndexName)(logMarker, message.instance) } } - def completeMigration(logMarker: LogMarker)(implicit ec: ExecutionContext): Future[Unit] = { + def completeMigration(message: CompleteMigrationMessage, logMarker: LogMarker)(implicit ec: ExecutionContext): Future[Unit] = { + implicit val instance: Instance = message.instance es.completeMigration(logMarker) } + + private def setupNewInstance(message: CreateInstanceMessage, logMarker: LogMarker)(implicit ec: ExecutionContext): Future[Unit] = { + val instance = message.instance + logger.info("Ensuring indexes for create instance message: " + instance) + Future.successful { + es.ensureIndexExistsAndAliasAssigned(alias = es.imagesCurrentAlias(instance), index = instance.id + "_index") + }.flatMap { _ => + logger.info("Creating Home collection for new instance: " + instance) + implicit val i: Instance = instance + gridClient.createCollection("Home", auth.innerServiceCall).map { r: Option[Collection] => + logger.info("Created collection for new instance: " + instance) + // Notify instances service that this instance can be marked as ready to use + logger.info(s"Sending instance ready message for ${instance.id}") + instanceMessageSender.send(InstanceStatusMessage(instance = instance.id, status = "ready")) + } + } + } + + private def reindexImage(message: ReindexImageMessage, logMarker: LogMarker)(implicit ec: ExecutionContext): Future[Boolean] = { + val mediaId = message.id + implicit val instance: Instance = message.instance + logger.info(s"Reindexing from s3 ${instance.id} / $mediaId") + + gridClient.getImageLoaderProjection(mediaId, auth.innerServiceCall).map { maybeImage => + logger.info(s"Projected ${instance.id} / $mediaId to ${maybeImage.map(_.id)}") + maybeImage.exists { image => + val updateMessage = UpsertFromProjectionMessage(image.id, image, DateTime.now, instance) + logger.info(s"Publishing projected image as a thrall image message: ${updateMessage.id}") + messageSender.publish(updateMessage) + true + } + }.recover { + case _: Throwable => + logger.warn(s"Error while reindexing ${instance.id} / $mediaId - Image has not been reindexed!") + false + } + } + } diff --git a/thrall/app/lib/kinesis/MessageTranslator.scala b/thrall/app/lib/kinesis/MessageTranslator.scala index 77cd14ff08d..ab3a88d47c8 100644 --- a/thrall/app/lib/kinesis/MessageTranslator.scala +++ b/thrall/app/lib/kinesis/MessageTranslator.scala @@ -10,71 +10,79 @@ object MessageTranslator extends GridLogging { updateMessage.subject match { case Image | ReingestImage | UpdateImage => updateMessage.image match { - case Some(image) => Right(ImageMessage(updateMessage.lastModified, image)) + case Some(image) => Right(ImageMessage(updateMessage.lastModified, image, updateMessage.instance)) case _ => Left(MissingFieldsException(updateMessage.subject)) } case DeleteImage => (updateMessage.id) match { - case (Some(id)) => Right(DeleteImageMessage(id, updateMessage.lastModified)) + case (Some(id)) => Right(DeleteImageMessage(id, updateMessage.lastModified, updateMessage.instance)) case _ => Left(MissingFieldsException(updateMessage.subject)) } case SoftDeleteImage => (updateMessage.id, updateMessage.softDeletedMetadata) match { - case (Some(id) , Some(deletedMetadata)) => Right(SoftDeleteImageMessage(id, updateMessage.lastModified, deletedMetadata)) + case (Some(id) , Some(deletedMetadata)) => Right(SoftDeleteImageMessage(id, updateMessage.lastModified, deletedMetadata, updateMessage.instance)) case _ => Left(MissingFieldsException(updateMessage.subject)) } case UnSoftDeleteImage => (updateMessage.id) match { - case (Some(id)) => Right(UnSoftDeleteImageMessage(id, updateMessage.lastModified)) + case (Some(id)) => Right(UnSoftDeleteImageMessage(id, updateMessage.lastModified, updateMessage.instance)) case _ => Left(MissingFieldsException(updateMessage.subject)) } case DeleteImageExports => (updateMessage.id) match { - case (Some(id)) => Right(DeleteImageExportsMessage(id, updateMessage.lastModified)) + case (Some(id)) => Right(DeleteImageExportsMessage(id, updateMessage.lastModified, updateMessage.instance)) case _ => Left(MissingFieldsException(updateMessage.subject)) } case UpdateImageExports => (updateMessage.id, updateMessage.crops) match { - case (Some(id), Some(crops)) => Right(UpdateImageExportsMessage(id, updateMessage.lastModified, crops)) + case (Some(id), Some(crops)) => Right(UpdateImageExportsMessage(id, updateMessage.lastModified, crops, updateMessage.instance)) case _ => Left(MissingFieldsException(updateMessage.subject)) } case UpdateImageUserMetadata => (updateMessage.id, updateMessage.edits) match { - case (Some(id), Some(edits)) => Right(UpdateImageUserMetadataMessage(id, updateMessage.lastModified, edits)) + case (Some(id), Some(edits)) => Right(UpdateImageUserMetadataMessage(id, updateMessage.lastModified, edits, updateMessage.instance)) case _ => Left(MissingFieldsException(updateMessage.subject)) } case UpdateImageUsages => (updateMessage.id, updateMessage.usageNotice) match { - case (Some(id), Some(usageNotice)) => Right(UpdateImageUsagesMessage(id, updateMessage.lastModified, usageNotice)) + case (Some(id), Some(usageNotice)) => Right(UpdateImageUsagesMessage(id, updateMessage.lastModified, usageNotice, updateMessage.instance)) case _ => Left(MissingFieldsException(updateMessage.subject)) } case ReplaceImageLeases => (updateMessage.id, updateMessage.leases) match { - case (Some(id), Some(leases)) => Right(ReplaceImageLeasesMessage(id, updateMessage.lastModified, leases)) + case (Some(id), Some(leases)) => Right(ReplaceImageLeasesMessage(id, updateMessage.lastModified, leases, updateMessage.instance)) case _ => Left(MissingFieldsException(updateMessage.subject)) } case AddImageLease => (updateMessage.id, updateMessage.mediaLease) match { - case (Some(id), Some(mediaLease)) => Right(AddImageLeaseMessage(id, updateMessage.lastModified, mediaLease)) + case (Some(id), Some(mediaLease)) => Right(AddImageLeaseMessage(id, updateMessage.lastModified, mediaLease, updateMessage.instance)) case _ => Left(MissingFieldsException(updateMessage.subject)) } case RemoveImageLease => (updateMessage.id, updateMessage.leaseId) match { - case (Some(id), Some(leaseId)) => Right(RemoveImageLeaseMessage(id, updateMessage.lastModified, leaseId)) + case (Some(id), Some(leaseId)) => Right(RemoveImageLeaseMessage(id, updateMessage.lastModified, leaseId, updateMessage.instance)) case _ => Left(MissingFieldsException(updateMessage.subject)) } case SetImageCollections => (updateMessage.id, updateMessage.collections) match { - case (Some(id), Some(collections)) => Right(SetImageCollectionsMessage(id, updateMessage.lastModified, collections)) + case (Some(id), Some(collections)) => Right(SetImageCollectionsMessage(id, updateMessage.lastModified, collections, updateMessage.instance)) case _ => Left(MissingFieldsException(updateMessage.subject)) } case DeleteUsages => (updateMessage.id) match { - case Some(id) => Right(DeleteUsagesMessage(id, updateMessage.lastModified)) + case Some(id) => Right(DeleteUsagesMessage(id, updateMessage.lastModified, updateMessage.instance)) case _ => Left(MissingFieldsException(updateMessage.subject)) } case DeleteSingleUsage => (updateMessage.id, updateMessage.usageId) match { - case (Some(id), Some(usageId)) => Right(DeleteSingleUsageMessage(id, updateMessage.lastModified, usageId)) + case (Some(id), Some(usageId)) => Right(DeleteSingleUsageMessage(id, updateMessage.lastModified, usageId, updateMessage.instance)) case _ => Left(MissingFieldsException(updateMessage.subject)) } case UpdateImageSyndicationMetadata => (updateMessage.id, updateMessage.syndicationRights) match { - case (Some(id), maybeSyndicationMetadata) => Right(UpdateImageSyndicationMetadataMessage(id, updateMessage.lastModified, maybeSyndicationMetadata)) + case (Some(id), maybeSyndicationMetadata) => Right(UpdateImageSyndicationMetadataMessage(id, updateMessage.lastModified, maybeSyndicationMetadata, updateMessage.instance)) case _ => Left(MissingFieldsException(updateMessage.subject)) } case UpdateImagePhotoshootMetadata => (updateMessage.id, updateMessage.edits) match { - case (Some(id), Some(edits)) => Right(UpdateImagePhotoshootMetadataMessage(id, updateMessage.lastModified, edits)) + case (Some(id), Some(edits)) => Right(UpdateImagePhotoshootMetadataMessage(id, updateMessage.lastModified, edits, updateMessage.instance)) case _ => Left(MissingFieldsException(updateMessage.subject)) } case UpdateUsageStatus => (updateMessage.id, updateMessage.usageNotice ) match { - case (Some(id), Some(usageNotice)) => Right(UpdateUsageStatusMessage(id, usageNotice, updateMessage.lastModified)) + case (Some(id), Some(usageNotice)) => Right(UpdateUsageStatusMessage(id, usageNotice, updateMessage.lastModified, updateMessage.instance)) + case _ => Left(MissingFieldsException(updateMessage.subject)) + } + case CreateInstance => (updateMessage.id) match { + case Some(id) => Right(CreateInstanceMessage(id, updateMessage.lastModified, updateMessage.instance)) + case _ => Left(MissingFieldsException(updateMessage.subject)) + } + case ReindexImage => (updateMessage.id) match { + case Some(id) => Right(ReindexImageMessage(id, updateMessage.lastModified, updateMessage.instance)) case _ => Left(MissingFieldsException(updateMessage.subject)) } case _ => Left(ProcessorNotFoundException(updateMessage.subject)) diff --git a/thrall/app/lib/kinesis/ThrallEventConsumer.scala b/thrall/app/lib/kinesis/ThrallEventConsumer.scala index 321df71f3ab..988eb2a43ec 100644 --- a/thrall/app/lib/kinesis/ThrallEventConsumer.scala +++ b/thrall/app/lib/kinesis/ThrallEventConsumer.scala @@ -1,10 +1,14 @@ package lib.kinesis import org.apache.pekko.actor.ActorSystem -import com.gu.mediaservice.lib.aws.UpdateMessage +import com.gu.mediaservice.GridClient +import com.gu.mediaservice.lib.auth.Authentication +import com.gu.mediaservice.lib.aws.{ThrallMessageSender, UpdateMessage} +import com.gu.mediaservice.lib.events.UsageEvents import com.gu.mediaservice.lib.json.{JsonByteArrayUtil, PlayJsonHelpers} import com.gu.mediaservice.lib.logging._ import com.gu.mediaservice.model.{ExternalThrallMessage, ThrallMessage} +import instances.InstanceMessageSender import lib._ import lib.elasticsearch._ @@ -18,7 +22,12 @@ class ThrallEventConsumer(es: ElasticSearch, thrallMetrics: ThrallMetrics, store: ThrallStore, metadataEditorNotifications: MetadataEditorNotifications, - actorSystem: ActorSystem + actorSystem: ActorSystem, + gridClient: GridClient, + auth: Authentication, + instanceMessageSender: InstanceMessageSender, + usageEvents: UsageEvents, + messageSender: ThrallMessageSender ) extends PlayJsonHelpers with GridLogging { private val attemptTimeout = FiniteDuration(20, SECONDS) @@ -26,7 +35,7 @@ class ThrallEventConsumer(es: ElasticSearch, private val attempts = 2 private val timeout = attemptTimeout * attempts + delay * (attempts - 1) - private val messageProcessor = new MessageProcessor(es, store, metadataEditorNotifications) + private val messageProcessor = new MessageProcessor(es, store, metadataEditorNotifications, gridClient, auth, instanceMessageSender, usageEvents, messageSender) private implicit val implicitActorSystem: ActorSystem = actorSystem diff --git a/thrall/app/views/index.scala.html b/thrall/app/views/index.scala.html index bbc8134d8a3..cbe3a494346 100644 --- a/thrall/app/views/index.scala.html +++ b/thrall/app/views/index.scala.html @@ -46,6 +46,10 @@