diff --git a/pom.xml b/pom.xml index d8bd026..7ef5ce1 100644 --- a/pom.xml +++ b/pom.xml @@ -66,7 +66,66 @@ HEAD + + + + + software.amazon.awssdk + bom + 2.41.10 + pom + import + + + + + + software.amazon.awssdk + s3 + + + software.amazon.awssdk + apache-client + + + + + com.kingsrook.qqq + qqq-middleware-javalin + test + + + + + com.kingsrook.qqq + qqq-backend-module-rdbms + test + + + com.kingsrook.qqq + qqq-backend-module-postgres + test + + + org.postgresql + postgresql + 42.7.9 + test + + + org.testcontainers + testcontainers-postgresql + 2.0.3 + test + + + + org.testcontainers + testcontainers + 2.0.3 + test + com.kingsrook.qqq qqq-backend-core @@ -87,6 +146,37 @@ + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.3.1 + + + com.puppycrawl.tools + checkstyle + 10.12.6 + + + + checkstyle/config.xml + checkstyle/license.txt + UTF-8 + true + false + true + warning + false + + + + validate + validate + + check + + + + org.jacoco jacoco-maven-plugin diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/ObjectStorageQBitConfig.java b/src/main/java/com/kingsrook/qbits/objectstorage/ObjectStorageQBitConfig.java index 27e7c1e..1b91698 100644 --- a/src/main/java/com/kingsrook/qbits/objectstorage/ObjectStorageQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/objectstorage/ObjectStorageQBitConfig.java @@ -1,11 +1,15 @@ /* - * Copyright 2026 Kingsrook, LLC + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -17,17 +21,30 @@ package com.kingsrook.qbits.objectstorage; +import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import com.kingsrook.qbits.objectstorage.api.ObjectHeaderPolicy; +import com.kingsrook.qbits.objectstorage.api.PublicUrlResolver; +import com.kingsrook.qbits.objectstorage.api.StorageAlertSink; +import com.kingsrook.qbits.objectstorage.api.StorageReferenceProvider; +import com.kingsrook.qbits.objectstorage.config.BucketRole; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig; +import com.kingsrook.qbits.objectstorage.internal.SecretRefs; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig; import com.kingsrook.qqq.backend.core.utils.StringUtils; -/******************************************************************************* - ** Host-facing configuration for the Object Storage QBit. - ** - ** Tables are named object_storage_ (lowercase snake_case, backend name equal - ** to the table name); permissions are object_storage.. +/******************************************************************************** + ** Host-facing configuration for the Object Storage QBit. Code is the source of + ** ** truth: connections and buckets are declared here and upserted into the + ** ** object_storage_connection and object_storage_bucket tables. + ** ** + ** ** Tables are named object_storage_ (lowercase snake_case, backend name equal + ** ** to the table name); permissions are object_storage.. *******************************************************************************/ public class ObjectStorageQBitConfig implements QBitConfig { @@ -35,16 +52,814 @@ public class ObjectStorageQBitConfig implements QBitConfig public static final String PERMISSION_BASE_NAME = "object_storage"; private String backendName; + private List connections = new ArrayList<>(); + private List buckets = new ArrayList<>(); + private ObjectHeaderPolicy headerPolicy; + private PublicUrlResolver publicUrlResolver; + private List referenceProviders = new ArrayList<>(); + private StorageAlertSink alertSink; + private Boolean adminApp = false; + private String appName = "objectStorageAdmin"; + private String appLabel = "Storage"; + private String parentAppName; + private List allowedSecretRefPatterns = new ArrayList<>(SecretRefs.DEFAULT_ALLOWED_PATTERNS); + private Integer bulkCap = 10_000; + private Boolean bucketScoping = true; + private Boolean openWhenAnonymous = false; + private Integer maxPresignsPerUserPerHour = 100; + private Double scanRemovalSafetyShare = 0.5; + private Long indexMaxKeys = 5_000_000L; + private String schedulerName; + private Integer nightlyIntervalSeconds = 86400; + private Integer headBacklogPerRun = 2000; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public ObjectStorageQBitConfig() + { + } + + + + /******************************************************************************** + ** Getter for backendName. + *******************************************************************************/ + public String getBackendName() + { + return (this.backendName); + } + + + + /******************************************************************************** + ** Setter for backendName. + *******************************************************************************/ + public void setBackendName(String backendName) + { + this.backendName = backendName; + } + + + + /******************************************************************************** + ** Fluent setter for backendName. + *******************************************************************************/ + public ObjectStorageQBitConfig withBackendName(String backendName) + { + this.backendName = backendName; + return (this); + } + + + + /******************************************************************************** + ** Getter for connections. + *******************************************************************************/ + public List getConnections() + { + return (this.connections); + } + + + + /******************************************************************************** + ** Setter for connections. + *******************************************************************************/ + public void setConnections(List connections) + { + this.connections = connections; + } + + + + /******************************************************************************** + ** Fluent setter for connections. + *******************************************************************************/ + public ObjectStorageQBitConfig withConnections(List connections) + { + this.connections = connections; + return (this); + } + + + + /******************************************************************************** + ** Getter for buckets. + *******************************************************************************/ + public List getBuckets() + { + return (this.buckets); + } + + + + /******************************************************************************** + ** Setter for buckets. + *******************************************************************************/ + public void setBuckets(List buckets) + { + this.buckets = buckets; + } + + + + /******************************************************************************** + ** Fluent setter for buckets. + *******************************************************************************/ + public ObjectStorageQBitConfig withBuckets(List buckets) + { + this.buckets = buckets; + return (this); + } + + + + /******************************************************************************** + ** Getter for headerPolicy. + *******************************************************************************/ + public ObjectHeaderPolicy getHeaderPolicy() + { + return (this.headerPolicy); + } + + + + /******************************************************************************** + ** Setter for headerPolicy. + *******************************************************************************/ + public void setHeaderPolicy(ObjectHeaderPolicy headerPolicy) + { + this.headerPolicy = headerPolicy; + } + + + + /******************************************************************************** + ** Fluent setter for headerPolicy. + *******************************************************************************/ + public ObjectStorageQBitConfig withHeaderPolicy(ObjectHeaderPolicy headerPolicy) + { + this.headerPolicy = headerPolicy; + return (this); + } + + + + /******************************************************************************** + ** Getter for publicUrlResolver. + *******************************************************************************/ + public PublicUrlResolver getPublicUrlResolver() + { + return (this.publicUrlResolver); + } + + + + /******************************************************************************** + ** Setter for publicUrlResolver. + *******************************************************************************/ + public void setPublicUrlResolver(PublicUrlResolver publicUrlResolver) + { + this.publicUrlResolver = publicUrlResolver; + } + + + + /******************************************************************************** + ** Fluent setter for publicUrlResolver. + *******************************************************************************/ + public ObjectStorageQBitConfig withPublicUrlResolver(PublicUrlResolver publicUrlResolver) + { + this.publicUrlResolver = publicUrlResolver; + return (this); + } + + + + /******************************************************************************** + ** Getter for referenceProviders. + *******************************************************************************/ + public List getReferenceProviders() + { + return (this.referenceProviders); + } + + + + /******************************************************************************** + ** Setter for referenceProviders. + *******************************************************************************/ + public void setReferenceProviders(List referenceProviders) + { + this.referenceProviders = referenceProviders; + } + + + + /******************************************************************************** + ** Fluent setter for referenceProviders. + *******************************************************************************/ + public ObjectStorageQBitConfig withReferenceProviders(List referenceProviders) + { + this.referenceProviders = referenceProviders; + return (this); + } + + + + /******************************************************************************** + ** Getter for alertSink. + *******************************************************************************/ + public StorageAlertSink getAlertSink() + { + return (this.alertSink); + } + + + + /******************************************************************************** + ** Setter for alertSink. + *******************************************************************************/ + public void setAlertSink(StorageAlertSink alertSink) + { + this.alertSink = alertSink; + } + + + + /******************************************************************************** + ** Fluent setter for alertSink. + *******************************************************************************/ + public ObjectStorageQBitConfig withAlertSink(StorageAlertSink alertSink) + { + this.alertSink = alertSink; + return (this); + } + + + + /******************************************************************************** + ** Getter for adminApp. + *******************************************************************************/ + public Boolean getAdminApp() + { + return (this.adminApp); + } + + + + /******************************************************************************** + ** Setter for adminApp. + *******************************************************************************/ + public void setAdminApp(Boolean adminApp) + { + this.adminApp = adminApp; + } + + + + /******************************************************************************** + ** Fluent setter for adminApp. + *******************************************************************************/ + public ObjectStorageQBitConfig withAdminApp(Boolean adminApp) + { + this.adminApp = adminApp; + return (this); + } + + + + /******************************************************************************** + ** Getter for appName. + *******************************************************************************/ + public String getAppName() + { + return (this.appName); + } + + + + /******************************************************************************** + ** Setter for appName. + *******************************************************************************/ + public void setAppName(String appName) + { + this.appName = appName; + } + + + + /******************************************************************************** + ** Fluent setter for appName. + *******************************************************************************/ + public ObjectStorageQBitConfig withAppName(String appName) + { + this.appName = appName; + return (this); + } + + + + /******************************************************************************** + ** Getter for appLabel. + *******************************************************************************/ + public String getAppLabel() + { + return (this.appLabel); + } + + + + /******************************************************************************** + ** Setter for appLabel. + *******************************************************************************/ + public void setAppLabel(String appLabel) + { + this.appLabel = appLabel; + } + + + + /******************************************************************************** + ** Fluent setter for appLabel. + *******************************************************************************/ + public ObjectStorageQBitConfig withAppLabel(String appLabel) + { + this.appLabel = appLabel; + return (this); + } + + + + /******************************************************************************** + ** Getter for parentAppName. + *******************************************************************************/ + public String getParentAppName() + { + return (this.parentAppName); + } + + + + /******************************************************************************** + ** Setter for parentAppName. + *******************************************************************************/ + public void setParentAppName(String parentAppName) + { + this.parentAppName = parentAppName; + } + + + + /******************************************************************************** + ** Fluent setter for parentAppName. + *******************************************************************************/ + public ObjectStorageQBitConfig withParentAppName(String parentAppName) + { + this.parentAppName = parentAppName; + return (this); + } + + + + /******************************************************************************** + ** Getter for allowedSecretRefPatterns. + *******************************************************************************/ + public List getAllowedSecretRefPatterns() + { + return (this.allowedSecretRefPatterns); + } + + + + /******************************************************************************** + ** Setter for allowedSecretRefPatterns. + *******************************************************************************/ + public void setAllowedSecretRefPatterns(List allowedSecretRefPatterns) + { + this.allowedSecretRefPatterns = allowedSecretRefPatterns; + } + + + + /******************************************************************************** + ** Fluent setter for allowedSecretRefPatterns. + *******************************************************************************/ + public ObjectStorageQBitConfig withAllowedSecretRefPatterns(List allowedSecretRefPatterns) + { + this.allowedSecretRefPatterns = allowedSecretRefPatterns; + return (this); + } + + + + /******************************************************************************** + ** Getter for bulkCap. + *******************************************************************************/ + public Integer getBulkCap() + { + return (this.bulkCap); + } + + + + /******************************************************************************** + ** Setter for bulkCap. + *******************************************************************************/ + public void setBulkCap(Integer bulkCap) + { + this.bulkCap = bulkCap; + } + + + + /******************************************************************************** + ** Fluent setter for bulkCap. + *******************************************************************************/ + public ObjectStorageQBitConfig withBulkCap(Integer bulkCap) + { + this.bulkCap = bulkCap; + return (this); + } + + + + /******************************************************************************** + ** Getter for bucketScoping. + *******************************************************************************/ + public Boolean getBucketScoping() + { + return (this.bucketScoping); + } + + + + /******************************************************************************** + ** Setter for bucketScoping. + *******************************************************************************/ + public void setBucketScoping(Boolean bucketScoping) + { + this.bucketScoping = bucketScoping; + } + + + + /******************************************************************************** + ** Fluent setter for bucketScoping. + *******************************************************************************/ + public ObjectStorageQBitConfig withBucketScoping(Boolean bucketScoping) + { + this.bucketScoping = bucketScoping; + return (this); + } + + + + /******************************************************************************** + ** Getter for openWhenAnonymous. + *******************************************************************************/ + public Boolean getOpenWhenAnonymous() + { + return (this.openWhenAnonymous); + } + + + + /******************************************************************************** + ** Setter for openWhenAnonymous. + *******************************************************************************/ + public void setOpenWhenAnonymous(Boolean openWhenAnonymous) + { + this.openWhenAnonymous = openWhenAnonymous; + } + + + + /******************************************************************************** + ** Fluent setter for openWhenAnonymous. + *******************************************************************************/ + public ObjectStorageQBitConfig withOpenWhenAnonymous(Boolean openWhenAnonymous) + { + this.openWhenAnonymous = openWhenAnonymous; + return (this); + } + + + + /******************************************************************************** + ** Getter for maxPresignsPerUserPerHour. + *******************************************************************************/ + public Integer getMaxPresignsPerUserPerHour() + { + return (this.maxPresignsPerUserPerHour); + } + + + + /******************************************************************************** + ** Setter for maxPresignsPerUserPerHour. + *******************************************************************************/ + public void setMaxPresignsPerUserPerHour(Integer maxPresignsPerUserPerHour) + { + this.maxPresignsPerUserPerHour = maxPresignsPerUserPerHour; + } + + + + /******************************************************************************** + ** Fluent setter for maxPresignsPerUserPerHour. + *******************************************************************************/ + public ObjectStorageQBitConfig withMaxPresignsPerUserPerHour(Integer maxPresignsPerUserPerHour) + { + this.maxPresignsPerUserPerHour = maxPresignsPerUserPerHour; + return (this); + } + + + + /******************************************************************************** + ** Getter for scanRemovalSafetyShare. + *******************************************************************************/ + public Double getScanRemovalSafetyShare() + { + return (this.scanRemovalSafetyShare); + } + + + + /******************************************************************************** + ** Setter for scanRemovalSafetyShare. + *******************************************************************************/ + public void setScanRemovalSafetyShare(Double scanRemovalSafetyShare) + { + this.scanRemovalSafetyShare = scanRemovalSafetyShare; + } + + + + /******************************************************************************** + ** Fluent setter for scanRemovalSafetyShare. + *******************************************************************************/ + public ObjectStorageQBitConfig withScanRemovalSafetyShare(Double scanRemovalSafetyShare) + { + this.scanRemovalSafetyShare = scanRemovalSafetyShare; + return (this); + } + + + + /******************************************************************************** + ** Getter for indexMaxKeys. + *******************************************************************************/ + public Long getIndexMaxKeys() + { + return (this.indexMaxKeys); + } + + + + /******************************************************************************** + ** Setter for indexMaxKeys. + *******************************************************************************/ + public void setIndexMaxKeys(Long indexMaxKeys) + { + this.indexMaxKeys = indexMaxKeys; + } + + + + /******************************************************************************** + ** Fluent setter for indexMaxKeys. + *******************************************************************************/ + public ObjectStorageQBitConfig withIndexMaxKeys(Long indexMaxKeys) + { + this.indexMaxKeys = indexMaxKeys; + return (this); + } + + + + /******************************************************************************** + ** Getter for schedulerName. + *******************************************************************************/ + public String getSchedulerName() + { + return (this.schedulerName); + } + + + + /******************************************************************************** + ** Setter for schedulerName. + *******************************************************************************/ + public void setSchedulerName(String schedulerName) + { + this.schedulerName = schedulerName; + } + + + + /******************************************************************************** + ** Fluent setter for schedulerName. + *******************************************************************************/ + public ObjectStorageQBitConfig withSchedulerName(String schedulerName) + { + this.schedulerName = schedulerName; + return (this); + } + + + + /******************************************************************************** + ** Getter for nightlyIntervalSeconds. + *******************************************************************************/ + public Integer getNightlyIntervalSeconds() + { + return (this.nightlyIntervalSeconds); + } + + + + /******************************************************************************** + ** Setter for nightlyIntervalSeconds. + *******************************************************************************/ + public void setNightlyIntervalSeconds(Integer nightlyIntervalSeconds) + { + this.nightlyIntervalSeconds = nightlyIntervalSeconds; + } + + + + /******************************************************************************** + ** Fluent setter for nightlyIntervalSeconds. + *******************************************************************************/ + public ObjectStorageQBitConfig withNightlyIntervalSeconds(Integer nightlyIntervalSeconds) + { + this.nightlyIntervalSeconds = nightlyIntervalSeconds; + return (this); + } + + + + /******************************************************************************** + ** Getter for headBacklogPerRun. + *******************************************************************************/ + public Integer getHeadBacklogPerRun() + { + return (this.headBacklogPerRun); + } + + + + /******************************************************************************** + ** Setter for headBacklogPerRun. + *******************************************************************************/ + public void setHeadBacklogPerRun(Integer headBacklogPerRun) + { + this.headBacklogPerRun = headBacklogPerRun; + } + + + + /******************************************************************************** + ** Fluent setter for headBacklogPerRun. + *******************************************************************************/ + public ObjectStorageQBitConfig withHeadBacklogPerRun(Integer headBacklogPerRun) + { + this.headBacklogPerRun = headBacklogPerRun; + return (this); + } /******************************************************************************* - ** The backend must be named. + ** Add a connection. + *******************************************************************************/ + public ObjectStorageQBitConfig withConnection(StorageConnectionConfig connection) + { + this.connections.add(connection); + return (this); + } + + + + /******************************************************************************* + ** Add a bucket. + *******************************************************************************/ + public ObjectStorageQBitConfig withBucket(StorageBucketConfig bucket) + { + this.buckets.add(bucket); + return (this); + } + + + + /******************************************************************************* + ** Add a reference provider. + *******************************************************************************/ + public ObjectStorageQBitConfig withReferenceProvider(StorageReferenceProvider provider) + { + this.referenceProviders.add(provider); + return (this); + } + + + + /******************************************************************************* + ** The configured connection of a name, or null. + *******************************************************************************/ + public StorageConnectionConfig findConnection(String name) + { + for(StorageConnectionConfig connection : connections) + { + if(connection.getName() != null && connection.getName().equals(name)) + { + return (connection); + } + } + return (null); + } + + + + /******************************************************************************* + ** The configured bucket of an alias, or null. + *******************************************************************************/ + public StorageBucketConfig findBucket(String alias) + { + for(StorageBucketConfig bucket : buckets) + { + if(bucket.getAlias() != null && bucket.getAlias().equals(alias)) + { + return (bucket); + } + } + return (null); + } + + + + /******************************************************************************* + ** Validate: names, references to connections, the BACKUP and trash rules, and + ** the secret-reference allow-list. *******************************************************************************/ @Override public void validate(QInstance qInstance, List errors) { assertCondition(StringUtils.hasContent(backendName), "backendName is required for the Object Storage QBit", errors); + + Set connectionNames = new HashSet<>(); + for(StorageConnectionConfig connection : connections) + { + if(!assertCondition(StringUtils.hasContent(connection.getName()), "Every storage connection needs a name", errors)) + { + continue; + } + assertCondition(connectionNames.add(connection.getName()), "Duplicate storage connection name: " + connection.getName(), errors); + for(String ref : new String[] { connection.getAccessKeyRef(), connection.getSecretKeyRef(), connection.getSessionTokenRef(), + connection.getPresignAccessKeyRef(), connection.getPresignSecretKeyRef(), connection.getCaBundleRef() }) + { + assertCondition(SecretRefs.isAllowed(ref, allowedSecretRefPatterns), + "Connection " + connection.getName() + ": a secret reference is not an allowed reference (allowed: " + allowedSecretRefPatterns + ")", errors); + } + if(Boolean.FALSE.equals(connection.getTlsVerify()) && StringUtils.hasContent(connection.getEndpoint())) + { + assertCondition(connection.getEndpoint().startsWith("http://") || connection.getEndpoint().startsWith("${"), + "Connection " + connection.getName() + ": tlsVerify=false is allowed only for http:// development endpoints", errors); + } + } + + Set aliases = new HashSet<>(); + for(StorageBucketConfig bucket : buckets) + { + if(!assertCondition(StringUtils.hasContent(bucket.getAlias()), "Every storage bucket needs an alias", errors)) + { + continue; + } + assertCondition(aliases.add(bucket.getAlias()), "Duplicate storage bucket alias: " + bucket.getAlias(), errors); + assertCondition(connectionNames.contains(bucket.getConnection()), "Bucket " + bucket.getAlias() + " names an unknown connection: " + bucket.getConnection(), errors); + assertCondition(StringUtils.hasContent(bucket.getBucketName()), "Bucket " + bucket.getAlias() + " needs a bucketName", errors); + if(bucket.getRole() == BucketRole.BACKUP) + { + assertCondition(!StringUtils.hasContent(bucket.getPublicBaseUrl()), "Bucket " + bucket.getAlias() + " has role BACKUP, which is always private: it cannot have a public base URL", errors); + } + if(Boolean.TRUE.equals(bucket.getTrashEnabled())) + { + StorageBucketConfig trashTarget = StringUtils.hasContent(bucket.getTrashBucket()) ? findBucket(bucket.getTrashBucket()) : bucket; + if(trashTarget == null) + { + errors.add("Bucket " + bucket.getAlias() + " names an unknown trash bucket: " + bucket.getTrashBucket()); + } + else + { + assertCondition(trashTarget.getRole() != BucketRole.PUBLIC && !StringUtils.hasContent(trashTarget.getPublicBaseUrl()), + "Bucket " + bucket.getAlias() + ": the trash location must be a private bucket (a trash prefix in a public bucket would leave deleted files readable)", errors); + } + } + } } @@ -58,35 +873,4 @@ public class ObjectStorageQBitConfig implements QBitConfig return (backendName); } - - - /******************************************************************************* - ** Getter for backendName - *******************************************************************************/ - public String getBackendName() - { - return (this.backendName); - } - - - - /******************************************************************************* - ** Setter for backendName - *******************************************************************************/ - public void setBackendName(String backendName) - { - this.backendName = backendName; - } - - - - /******************************************************************************* - ** Fluent setter for backendName - *******************************************************************************/ - public ObjectStorageQBitConfig withBackendName(String backendName) - { - this.backendName = backendName; - return (this); - } - } diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/ObjectStorageQBitProducer.java b/src/main/java/com/kingsrook/qbits/objectstorage/ObjectStorageQBitProducer.java index f7cb924..1ff7001 100644 --- a/src/main/java/com/kingsrook/qbits/objectstorage/ObjectStorageQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/objectstorage/ObjectStorageQBitProducer.java @@ -1,11 +1,15 @@ /* - * Copyright 2026 Kingsrook, LLC + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -17,13 +21,34 @@ package com.kingsrook.qbits.objectstorage; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.objectstorage.app.StorageWidgets; +import com.kingsrook.qbits.objectstorage.internal.ConfigSync; +import com.kingsrook.qbits.objectstorage.internal.DefaultObjectHeaderPolicy; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qbits.objectstorage.processes.ObjectStorageProcesses; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer; +import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; /******************************************************************************* - ** Produces the Object Storage QBit. Component producers in this package are found by - ** package scanning. + ** Produces the Object Storage qbit: the object_storage_* tables and possible + ** values, the security key types for per-bucket and per-connection scoping, the + ** processes (table and record actions), and, when withAdminApp(true), the + ** Admin -> Storage app with its home widget. Without the admin app a headless + ** consumer (for example the CMS qbit) still gets the service and the tables. *******************************************************************************/ public class ObjectStorageQBitProducer implements QBitMetaDataProducer { @@ -32,7 +57,7 @@ public class ObjectStorageQBitProducer implements QBitMetaDataProducer tables = new ArrayList<>(); + for(QTableMetaData table : ObjectStorageTables.tables(qBitConfig)) + { + table.setSourceQBitName(qBit.getName()); + tables.add(table); + output.add(table); + } + for(QProcessMetaData process : ObjectStorageProcesses.processes(qBitConfig)) + { + process.setSourceQBitName(qBit.getName()); + output.add(process); + } + if(Boolean.TRUE.equals(qBitConfig.getAdminApp())) + { + for(QWidgetMetaData widget : StorageWidgets.widgets()) + { + output.add(widget); + } + QAppMetaData app = StorageWidgets.app(qBitConfig, tables); + if(qBitConfig.getParentAppName() != null) + { + app.setParentAppName(qBitConfig.getParentAppName()); + } + output.add(app); + } + output.addSelfToInstance(qInstance); + if(Boolean.TRUE.equals(qBitConfig.getAdminApp()) && qBitConfig.getParentAppName() != null && qInstance.getApp(qBitConfig.getParentAppName()) != null) + { + /////////////////////////////////////////////////////////////////////////////// + // Admin -> Storage: the parent app lists the Storage app in one of its sections // + /////////////////////////////////////////////////////////////////////////////// + qInstance.getApp(qBitConfig.getParentAppName()).withChild(qInstance.getApp(qBitConfig.getAppName())); + } + return (new MetaDataProducerMultiOutput()); + } } diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/BucketKey.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/BucketKey.java new file mode 100644 index 0000000..7e1ae8d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/BucketKey.java @@ -0,0 +1,114 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +/******************************************************************************** + ** A bucket alias and key, the result of parsing a public URL. + *******************************************************************************/ +public class BucketKey +{ + private String bucketAlias; + private String key; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public BucketKey() + { + } + + + + /******************************************************************************** + ** Getter for bucketAlias. + *******************************************************************************/ + public String getBucketAlias() + { + return (this.bucketAlias); + } + + + + /******************************************************************************** + ** Setter for bucketAlias. + *******************************************************************************/ + public void setBucketAlias(String bucketAlias) + { + this.bucketAlias = bucketAlias; + } + + + + /******************************************************************************** + ** Fluent setter for bucketAlias. + *******************************************************************************/ + public BucketKey withBucketAlias(String bucketAlias) + { + this.bucketAlias = bucketAlias; + return (this); + } + + + + /******************************************************************************** + ** Getter for key. + *******************************************************************************/ + public String getKey() + { + return (this.key); + } + + + + /******************************************************************************** + ** Setter for key. + *******************************************************************************/ + public void setKey(String key) + { + this.key = key; + } + + + + /******************************************************************************** + ** Fluent setter for key. + *******************************************************************************/ + public BucketKey withKey(String key) + { + this.key = key; + return (this); + } + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + public BucketKey(String bucketAlias, String key) + { + this.bucketAlias = bucketAlias; + this.key = key; + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/Capability.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/Capability.java new file mode 100644 index 0000000..baadb22 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/Capability.java @@ -0,0 +1,47 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +/******************************************************************************** + ** A feature a connection or bucket may or may not support. The probe fills the status. + *******************************************************************************/ +public enum Capability +{ + LIST_V2, + PUT_GET_DELETE, + COPY_REPLACE_METADATA, + BATCH_DELETE, + MULTIPART, + PRESIGNED_GET, + PRESIGNED_PUT, + PRESIGNED_POST, + CONDITIONAL_WRITE, + VERSIONING, + OBJECT_LOCK, + LIFECYCLE, + BUCKET_POLICY, + CORS, + OBJECT_TAGGING, + ACL, + USAGE_ADMIN_API, + QUOTA_ADMIN_API +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/CapabilityStatus.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/CapabilityStatus.java new file mode 100644 index 0000000..9281723 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/CapabilityStatus.java @@ -0,0 +1,34 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +/******************************************************************************** + ** Probe result for a capability. + *******************************************************************************/ +public enum CapabilityStatus +{ + SUPPORTED, + UNSUPPORTED, + NOT_PERMITTED, + PARTIAL, + UNKNOWN +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/ChangeOperation.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/ChangeOperation.java new file mode 100644 index 0000000..53220cb --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/ChangeOperation.java @@ -0,0 +1,34 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +/******************************************************************************** + ** The kind of write a change listener is told about. + *******************************************************************************/ +public enum ChangeOperation +{ + PUT, + COPY, + HEADERS, + DELETE, + RESTORE +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/CopyRequest.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/CopyRequest.java new file mode 100644 index 0000000..f870551 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/CopyRequest.java @@ -0,0 +1,220 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +/******************************************************************************** + ** A copy of one object to another key or bucket. + *******************************************************************************/ +public class CopyRequest +{ + private String sourceKey; + private String destinationKey; + private String destinationBucket; + private MetadataDirective directive = MetadataDirective.COPY; + private ObjectHeaders headers; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public CopyRequest() + { + } + + + + /******************************************************************************** + ** Getter for sourceKey. + *******************************************************************************/ + public String getSourceKey() + { + return (this.sourceKey); + } + + + + /******************************************************************************** + ** Setter for sourceKey. + *******************************************************************************/ + public void setSourceKey(String sourceKey) + { + this.sourceKey = sourceKey; + } + + + + /******************************************************************************** + ** Fluent setter for sourceKey. + *******************************************************************************/ + public CopyRequest withSourceKey(String sourceKey) + { + this.sourceKey = sourceKey; + return (this); + } + + + + /******************************************************************************** + ** Getter for destinationKey. + *******************************************************************************/ + public String getDestinationKey() + { + return (this.destinationKey); + } + + + + /******************************************************************************** + ** Setter for destinationKey. + *******************************************************************************/ + public void setDestinationKey(String destinationKey) + { + this.destinationKey = destinationKey; + } + + + + /******************************************************************************** + ** Fluent setter for destinationKey. + *******************************************************************************/ + public CopyRequest withDestinationKey(String destinationKey) + { + this.destinationKey = destinationKey; + return (this); + } + + + + /******************************************************************************** + ** Getter for destinationBucket. + *******************************************************************************/ + public String getDestinationBucket() + { + return (this.destinationBucket); + } + + + + /******************************************************************************** + ** Setter for destinationBucket. + *******************************************************************************/ + public void setDestinationBucket(String destinationBucket) + { + this.destinationBucket = destinationBucket; + } + + + + /******************************************************************************** + ** Fluent setter for destinationBucket. + *******************************************************************************/ + public CopyRequest withDestinationBucket(String destinationBucket) + { + this.destinationBucket = destinationBucket; + return (this); + } + + + + /******************************************************************************** + ** Getter for directive. + *******************************************************************************/ + public MetadataDirective getDirective() + { + return (this.directive); + } + + + + /******************************************************************************** + ** Setter for directive. + *******************************************************************************/ + public void setDirective(MetadataDirective directive) + { + this.directive = directive; + } + + + + /******************************************************************************** + ** Fluent setter for directive. + *******************************************************************************/ + public CopyRequest withDirective(MetadataDirective directive) + { + this.directive = directive; + return (this); + } + + + + /******************************************************************************** + ** Getter for headers. + *******************************************************************************/ + public ObjectHeaders getHeaders() + { + return (this.headers); + } + + + + /******************************************************************************** + ** Setter for headers. + *******************************************************************************/ + public void setHeaders(ObjectHeaders headers) + { + this.headers = headers; + } + + + + /******************************************************************************** + ** Fluent setter for headers. + *******************************************************************************/ + public CopyRequest withHeaders(ObjectHeaders headers) + { + this.headers = headers; + return (this); + } + + + + /******************************************************************************* + ** A copy from one key to another in the same bucket. + *******************************************************************************/ + public static CopyRequest of(String sourceKey, String destinationKey) + { + return (new CopyRequest().withSourceKey(sourceKey).withDestinationKey(destinationKey)); + } + + + + /******************************************************************************* + ** Set the metadata directive. + *******************************************************************************/ + public CopyRequest withMetadata(MetadataDirective directive) + { + this.directive = directive; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/DeleteMode.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/DeleteMode.java new file mode 100644 index 0000000..fc81af1 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/DeleteMode.java @@ -0,0 +1,34 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +/******************************************************************************** + ** What a delete actually did. + *******************************************************************************/ +public enum DeleteMode +{ + SOFT_DELETE_MARKER, + SOFT_TRASH, + PERMANENT, + SKIPPED_OBJECT_LOCK, + NOT_FOUND +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/DeleteRequest.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/DeleteRequest.java new file mode 100644 index 0000000..b84eb2d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/DeleteRequest.java @@ -0,0 +1,156 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +/******************************************************************************** + ** A delete of one object key. + *******************************************************************************/ +public class DeleteRequest +{ + private String key; + private Boolean permanent = false; + private Boolean allowPermanentWhenNoSoftDelete = false; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public DeleteRequest() + { + } + + + + /******************************************************************************** + ** Getter for key. + *******************************************************************************/ + public String getKey() + { + return (this.key); + } + + + + /******************************************************************************** + ** Setter for key. + *******************************************************************************/ + public void setKey(String key) + { + this.key = key; + } + + + + /******************************************************************************** + ** Fluent setter for key. + *******************************************************************************/ + public DeleteRequest withKey(String key) + { + this.key = key; + return (this); + } + + + + /******************************************************************************** + ** Getter for permanent. + *******************************************************************************/ + public Boolean getPermanent() + { + return (this.permanent); + } + + + + /******************************************************************************** + ** Setter for permanent. + *******************************************************************************/ + public void setPermanent(Boolean permanent) + { + this.permanent = permanent; + } + + + + /******************************************************************************** + ** Fluent setter for permanent. + *******************************************************************************/ + public DeleteRequest withPermanent(Boolean permanent) + { + this.permanent = permanent; + return (this); + } + + + + /******************************************************************************** + ** Getter for allowPermanentWhenNoSoftDelete. + *******************************************************************************/ + public Boolean getAllowPermanentWhenNoSoftDelete() + { + return (this.allowPermanentWhenNoSoftDelete); + } + + + + /******************************************************************************** + ** Setter for allowPermanentWhenNoSoftDelete. + *******************************************************************************/ + public void setAllowPermanentWhenNoSoftDelete(Boolean allowPermanentWhenNoSoftDelete) + { + this.allowPermanentWhenNoSoftDelete = allowPermanentWhenNoSoftDelete; + } + + + + /******************************************************************************** + ** Fluent setter for allowPermanentWhenNoSoftDelete. + *******************************************************************************/ + public DeleteRequest withAllowPermanentWhenNoSoftDelete(Boolean allowPermanentWhenNoSoftDelete) + { + this.allowPermanentWhenNoSoftDelete = allowPermanentWhenNoSoftDelete; + return (this); + } + + + + /******************************************************************************* + ** A delete of a key. + *******************************************************************************/ + public static DeleteRequest of(String key) + { + return (new DeleteRequest().withKey(key)); + } + + + + /******************************************************************************* + ** Permanently delete every version and delete marker of the key (system callers only). + *******************************************************************************/ + public DeleteRequest permanent() + { + this.permanent = true; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/DeleteResult.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/DeleteResult.java new file mode 100644 index 0000000..084ec80 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/DeleteResult.java @@ -0,0 +1,167 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +/******************************************************************************** + ** The result of a delete. + *******************************************************************************/ +public class DeleteResult +{ + private String key; + private DeleteMode mode; + private Integer versionsDeleted = 0; + private String detail; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public DeleteResult() + { + } + + + + /******************************************************************************** + ** Getter for key. + *******************************************************************************/ + public String getKey() + { + return (this.key); + } + + + + /******************************************************************************** + ** Setter for key. + *******************************************************************************/ + public void setKey(String key) + { + this.key = key; + } + + + + /******************************************************************************** + ** Fluent setter for key. + *******************************************************************************/ + public DeleteResult withKey(String key) + { + this.key = key; + return (this); + } + + + + /******************************************************************************** + ** Getter for mode. + *******************************************************************************/ + public DeleteMode getMode() + { + return (this.mode); + } + + + + /******************************************************************************** + ** Setter for mode. + *******************************************************************************/ + public void setMode(DeleteMode mode) + { + this.mode = mode; + } + + + + /******************************************************************************** + ** Fluent setter for mode. + *******************************************************************************/ + public DeleteResult withMode(DeleteMode mode) + { + this.mode = mode; + return (this); + } + + + + /******************************************************************************** + ** Getter for versionsDeleted. + *******************************************************************************/ + public Integer getVersionsDeleted() + { + return (this.versionsDeleted); + } + + + + /******************************************************************************** + ** Setter for versionsDeleted. + *******************************************************************************/ + public void setVersionsDeleted(Integer versionsDeleted) + { + this.versionsDeleted = versionsDeleted; + } + + + + /******************************************************************************** + ** Fluent setter for versionsDeleted. + *******************************************************************************/ + public DeleteResult withVersionsDeleted(Integer versionsDeleted) + { + this.versionsDeleted = versionsDeleted; + return (this); + } + + + + /******************************************************************************** + ** Getter for detail. + *******************************************************************************/ + public String getDetail() + { + return (this.detail); + } + + + + /******************************************************************************** + ** Setter for detail. + *******************************************************************************/ + public void setDetail(String detail) + { + this.detail = detail; + } + + + + /******************************************************************************** + ** Fluent setter for detail. + *******************************************************************************/ + public DeleteResult withDetail(String detail) + { + this.detail = detail; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/FieldReferenceProvider.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/FieldReferenceProvider.java new file mode 100644 index 0000000..405ef63 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/FieldReferenceProvider.java @@ -0,0 +1,162 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; +import com.kingsrook.qbits.objectstorage.internal.Db; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** A reference provider that needs no code: it reads a table field that holds an + ** object key, a key prefix (for example a set of renditions), or a public URL + ** (parsed back to bucket and key with the PublicUrlResolver). + ** + **
+ **   .withReferenceProvider(FieldReferenceProvider.keyPrefix("files", "media", "storageKey"))
+ ** 
+ *******************************************************************************/ +public class FieldReferenceProvider implements StorageReferenceProvider +{ + /******************************************************************************* + ** What the field holds. + *******************************************************************************/ + private enum Holds + { + KEY, + KEY_PREFIX, + URL + } + + private final String bucketAlias; + private final String tableName; + private final String fieldName; + private final Holds holds; + private String labelField; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + private FieldReferenceProvider(String bucketAlias, String tableName, String fieldName, Holds holds) + { + this.bucketAlias = bucketAlias; + this.tableName = tableName; + this.fieldName = fieldName; + this.holds = holds; + } + + + + /******************************************************************************* + ** The field holds one object key. + *******************************************************************************/ + public static FieldReferenceProvider key(String bucketAlias, String tableName, String fieldName) + { + return (new FieldReferenceProvider(bucketAlias, tableName, fieldName, Holds.KEY)); + } + + + + /******************************************************************************* + ** The field holds a key prefix: every object under it is referenced. + *******************************************************************************/ + public static FieldReferenceProvider keyPrefix(String bucketAlias, String tableName, String fieldName) + { + return (new FieldReferenceProvider(bucketAlias, tableName, fieldName, Holds.KEY_PREFIX)); + } + + + + /******************************************************************************* + ** The field holds a public URL. + *******************************************************************************/ + public static FieldReferenceProvider url(String bucketAlias, String tableName, String fieldName) + { + return (new FieldReferenceProvider(bucketAlias, tableName, fieldName, Holds.URL)); + } + + + + /******************************************************************************* + ** Use another field as the label of the referencing record (default: the table + ** name and id). + *******************************************************************************/ + public FieldReferenceProvider withLabelField(String labelField) + { + this.labelField = labelField; + return (this); + } + + + + /******************************************************************************* + ** Every reference this provider knows of in a bucket. + *******************************************************************************/ + @Override + public Stream references(String alias) throws QException + { + if(!bucketAlias.equals(alias) && holds != Holds.URL) + { + return (Stream.empty()); + } + List references = new ArrayList<>(); + QQueryFilter filter = new QQueryFilter(new QFilterCriteria(fieldName, QCriteriaOperator.IS_NOT_BLANK)); + Db.forEachPage(tableName, filter, 2000, rows -> + { + for(QRecord row : rows) + { + String value = row.getValueString(fieldName); + if(!StringUtils.hasContent(value)) + { + continue; + } + String label = labelField != null && row.getValueString(labelField) != null ? row.getValueString(labelField) : tableName + " #" + row.getValue("id"); + if(holds == Holds.URL) + { + Optional parsed = ObjectStorageRuntime.publicUrlResolver().parse(value); + if(parsed.isPresent() && parsed.get().getBucketAlias().equals(alias)) + { + references.add(new StorageReference().withBucketAlias(alias).withKeyOrPrefix(parsed.get().getKey()).withMatchMode(MatchMode.KEY).withTableName(tableName) + .withRecordId(String.valueOf(row.getValue("id"))).withFieldName(fieldName).withLabel(label)); + } + } + else + { + references.add(new StorageReference().withBucketAlias(alias).withKeyOrPrefix(StorageKeys.normalizePrefix(value)).withMatchMode(holds == Holds.KEY_PREFIX ? MatchMode.PREFIX : MatchMode.KEY) + .withTableName(tableName).withRecordId(String.valueOf(row.getValue("id"))).withFieldName(fieldName).withLabel(label)); + } + } + }); + return (references.stream()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/ListRequest.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/ListRequest.java new file mode 100644 index 0000000..a2a3ffd --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/ListRequest.java @@ -0,0 +1,253 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +/******************************************************************************** + ** A listing request: prefix, optional delimiter, page size and continuation token. + *******************************************************************************/ +public class ListRequest +{ + private String prefix = ""; + private String delimiter; + private Integer pageSize = 1000; + private String after; + private String startAfter; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public ListRequest() + { + } + + + + /******************************************************************************** + ** Getter for prefix. + *******************************************************************************/ + public String getPrefix() + { + return (this.prefix); + } + + + + /******************************************************************************** + ** Setter for prefix. + *******************************************************************************/ + public void setPrefix(String prefix) + { + this.prefix = prefix; + } + + + + /******************************************************************************** + ** Fluent setter for prefix. + *******************************************************************************/ + public ListRequest withPrefix(String prefix) + { + this.prefix = prefix; + return (this); + } + + + + /******************************************************************************** + ** Getter for delimiter. + *******************************************************************************/ + public String getDelimiter() + { + return (this.delimiter); + } + + + + /******************************************************************************** + ** Setter for delimiter. + *******************************************************************************/ + public void setDelimiter(String delimiter) + { + this.delimiter = delimiter; + } + + + + /******************************************************************************** + ** Fluent setter for delimiter. + *******************************************************************************/ + public ListRequest withDelimiter(String delimiter) + { + this.delimiter = delimiter; + return (this); + } + + + + /******************************************************************************** + ** Getter for pageSize. + *******************************************************************************/ + public Integer getPageSize() + { + return (this.pageSize); + } + + + + /******************************************************************************** + ** Setter for pageSize. + *******************************************************************************/ + public void setPageSize(Integer pageSize) + { + this.pageSize = pageSize; + } + + + + /******************************************************************************** + ** Fluent setter for pageSize. + *******************************************************************************/ + public ListRequest withPageSize(Integer pageSize) + { + this.pageSize = pageSize; + return (this); + } + + + + /******************************************************************************** + ** Getter for after. + *******************************************************************************/ + public String getAfter() + { + return (this.after); + } + + + + /******************************************************************************** + ** Setter for after. + *******************************************************************************/ + public void setAfter(String after) + { + this.after = after; + } + + + + /******************************************************************************** + ** Fluent setter for after. + *******************************************************************************/ + public ListRequest withAfter(String after) + { + this.after = after; + return (this); + } + + + + /******************************************************************************** + ** Getter for startAfter. + *******************************************************************************/ + public String getStartAfter() + { + return (this.startAfter); + } + + + + /******************************************************************************** + ** Setter for startAfter. + *******************************************************************************/ + public void setStartAfter(String startAfter) + { + this.startAfter = startAfter; + } + + + + /******************************************************************************** + ** Fluent setter for startAfter. + *******************************************************************************/ + public ListRequest withStartAfter(String startAfter) + { + this.startAfter = startAfter; + return (this); + } + + + + /******************************************************************************* + ** A request for a prefix. + *******************************************************************************/ + public static ListRequest prefix(String prefix) + { + return (new ListRequest().withPrefix(prefix)); + } + + + + /******************************************************************************* + ** Short-form fluent setter for delimiter. + *******************************************************************************/ + public ListRequest delimiter(String delimiter) + { + this.delimiter = delimiter; + return (this); + } + + + + /******************************************************************************* + ** Short-form fluent setter for pageSize. + *******************************************************************************/ + public ListRequest pageSize(Integer pageSize) + { + this.pageSize = pageSize; + return (this); + } + + + + /******************************************************************************* + ** Short-form fluent setter for after. + *******************************************************************************/ + public ListRequest after(String after) + { + this.after = after; + return (this); + } + + + + /******************************************************************************* + ** Short-form fluent setter for startAfter. + *******************************************************************************/ + public ListRequest startAfter(String startAfter) + { + this.startAfter = startAfter; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/LockMode.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/LockMode.java new file mode 100644 index 0000000..e5af482 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/LockMode.java @@ -0,0 +1,31 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +/******************************************************************************** + ** S3 object lock retention mode. + *******************************************************************************/ +public enum LockMode +{ + GOVERNANCE, + COMPLIANCE +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/MatchMode.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/MatchMode.java new file mode 100644 index 0000000..95f28ce --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/MatchMode.java @@ -0,0 +1,31 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +/******************************************************************************** + ** Whether a reference points at one key or at every key under a prefix. + *******************************************************************************/ +public enum MatchMode +{ + KEY, + PREFIX +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/MetadataDirective.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/MetadataDirective.java new file mode 100644 index 0000000..ab60424 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/MetadataDirective.java @@ -0,0 +1,31 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +/******************************************************************************** + ** Whether a copy keeps or replaces the source metadata. + *******************************************************************************/ +public enum MetadataDirective +{ + COPY, + REPLACE +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/ObjectHeaderPolicy.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/ObjectHeaderPolicy.java new file mode 100644 index 0000000..9277ad6 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/ObjectHeaderPolicy.java @@ -0,0 +1,39 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; + + +/******************************************************************************* + ** Decides the headers for a new or rewritten object. The default + ** implementation reads the object_storage_header_rule table; an app can plug + ** in its own (for example one driven by per-media-type settings it keeps). + *******************************************************************************/ +public interface ObjectHeaderPolicy +{ + /******************************************************************************* + ** The headers (Cache-Control, Content-Disposition) the object should have, or + ** empty headers when the policy has no opinion. + *******************************************************************************/ + ObjectHeaders headersFor(StorageBucketConfig bucket, String key, String contentType); +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/ObjectHeaders.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/ObjectHeaders.java new file mode 100644 index 0000000..6eac5ce --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/ObjectHeaders.java @@ -0,0 +1,246 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import java.util.Map; +import java.util.Objects; + + +/******************************************************************************** + ** The HTTP headers and user metadata stored with an object. + *******************************************************************************/ +public class ObjectHeaders +{ + private String cacheControl; + private String contentType; + private String contentDisposition; + private String contentEncoding; + private Map userMetadata; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public ObjectHeaders() + { + } + + + + /******************************************************************************** + ** Getter for cacheControl. + *******************************************************************************/ + public String getCacheControl() + { + return (this.cacheControl); + } + + + + /******************************************************************************** + ** Setter for cacheControl. + *******************************************************************************/ + public void setCacheControl(String cacheControl) + { + this.cacheControl = cacheControl; + } + + + + /******************************************************************************** + ** Fluent setter for cacheControl. + *******************************************************************************/ + public ObjectHeaders withCacheControl(String cacheControl) + { + this.cacheControl = cacheControl; + return (this); + } + + + + /******************************************************************************** + ** Getter for contentType. + *******************************************************************************/ + public String getContentType() + { + return (this.contentType); + } + + + + /******************************************************************************** + ** Setter for contentType. + *******************************************************************************/ + public void setContentType(String contentType) + { + this.contentType = contentType; + } + + + + /******************************************************************************** + ** Fluent setter for contentType. + *******************************************************************************/ + public ObjectHeaders withContentType(String contentType) + { + this.contentType = contentType; + return (this); + } + + + + /******************************************************************************** + ** Getter for contentDisposition. + *******************************************************************************/ + public String getContentDisposition() + { + return (this.contentDisposition); + } + + + + /******************************************************************************** + ** Setter for contentDisposition. + *******************************************************************************/ + public void setContentDisposition(String contentDisposition) + { + this.contentDisposition = contentDisposition; + } + + + + /******************************************************************************** + ** Fluent setter for contentDisposition. + *******************************************************************************/ + public ObjectHeaders withContentDisposition(String contentDisposition) + { + this.contentDisposition = contentDisposition; + return (this); + } + + + + /******************************************************************************** + ** Getter for contentEncoding. + *******************************************************************************/ + public String getContentEncoding() + { + return (this.contentEncoding); + } + + + + /******************************************************************************** + ** Setter for contentEncoding. + *******************************************************************************/ + public void setContentEncoding(String contentEncoding) + { + this.contentEncoding = contentEncoding; + } + + + + /******************************************************************************** + ** Fluent setter for contentEncoding. + *******************************************************************************/ + public ObjectHeaders withContentEncoding(String contentEncoding) + { + this.contentEncoding = contentEncoding; + return (this); + } + + + + /******************************************************************************** + ** Getter for userMetadata. + *******************************************************************************/ + public Map getUserMetadata() + { + return (this.userMetadata); + } + + + + /******************************************************************************** + ** Setter for userMetadata. + *******************************************************************************/ + public void setUserMetadata(Map userMetadata) + { + this.userMetadata = userMetadata; + } + + + + /******************************************************************************** + ** Fluent setter for userMetadata. + *******************************************************************************/ + public ObjectHeaders withUserMetadata(Map userMetadata) + { + this.userMetadata = userMetadata; + return (this); + } + + + + /******************************************************************************* + ** Headers with only a Cache-Control. + *******************************************************************************/ + public static ObjectHeaders cacheControl(String cacheControl) + { + return (new ObjectHeaders().withCacheControl(cacheControl)); + } + + + + /******************************************************************************* + ** Whether no header or metadata is set. + *******************************************************************************/ + public boolean isEmpty() + { + return (cacheControl == null && contentType == null && contentDisposition == null && contentEncoding == null && (userMetadata == null || userMetadata.isEmpty())); + } + + + + /******************************************************************************* + ** Equality on the headers that matter for policy checks (Cache-Control and + ** Content-Disposition; null and blank are the same). + *******************************************************************************/ + public boolean policyEquals(ObjectHeaders other) + { + return (other != null + && Objects.equals(blankToNull(cacheControl), blankToNull(other.cacheControl)) + && Objects.equals(blankToNull(contentDisposition), blankToNull(other.contentDisposition))); + } + + + + /******************************************************************************* + ** Blank to null. + *******************************************************************************/ + private static String blankToNull(String s) + { + return (s == null || s.isBlank() ? null : s.trim()); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/ObjectStorage.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/ObjectStorage.java new file mode 100644 index 0000000..8efa0d4 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/ObjectStorage.java @@ -0,0 +1,93 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import com.kingsrook.qbits.objectstorage.internal.ConfigSync; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; + + +/******************************************************************************* + ** Entry point of the storage API. Application code and other qbits call + ** ObjectStorage.bucket("alias") instead of building their own S3 client. + ** + **
+ **   StorageBucket files = ObjectStorage.bucket("files");
+ **   files.put(PutRequest.of("media/42/hero.jpg", bytes).withContentType("image/jpeg"));
+ **   URL link = files.presignGet(PresignGet.of("media/42/hero.jpg"));
+ **
+ **   // a calling qbit with its own permission check (for example backups):
+ **   ObjectStorage.asSystem("backups", runId).bucket("backups").put(...);
+ ** 
+ *******************************************************************************/ +public final class ObjectStorage +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private ObjectStorage() + { + } + + + + /******************************************************************************* + ** The bucket behind an alias from config, called as the current session user. + *******************************************************************************/ + public static StorageBucket bucket(String alias) + { + ConfigSync.ensureSynced(); + return (new StorageBucket(alias, StorageCaller.user())); + } + + + + /******************************************************************************* + ** A facade whose calls run as a system caller: tagged with the calling qbit + ** and its run, not subject to the user's object_storage.* permissions (the + ** calling qbit must have checked its own permission first), and logged as + ** "system: backups run 1234 (started by user X)". + *******************************************************************************/ + public static SystemStorage asSystem(String callerName, String runId) + { + return (new SystemStorage(StorageCaller.system(callerName, runId))); + } + + + + /******************************************************************************* + ** Register a change listener (the CDN qbit plans purges with one). + *******************************************************************************/ + public static void addChangeListener(StorageChangeListener listener) + { + ObjectStorageRuntime.addChangeListener(listener); + } + + + + /******************************************************************************* + ** Replace the public URL resolver (the CDN qbit returns its hostnames). + *******************************************************************************/ + public static void setPublicUrlResolver(PublicUrlResolver resolver) + { + ObjectStorageRuntime.setPublicUrlResolver(resolver); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/ObjectStream.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/ObjectStream.java new file mode 100644 index 0000000..6da756a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/ObjectStream.java @@ -0,0 +1,136 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import java.io.IOException; +import java.io.InputStream; +import java.time.Instant; + + +/******************************************************************************* + ** The content of an object plus its basic metadata. Close it when done. + *******************************************************************************/ +public class ObjectStream implements AutoCloseable +{ + private final InputStream inputStream; + private final Long contentLength; + private final String contentType; + private final String etag; + private final Instant lastModified; + private final ObjectHeaders headers; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + public ObjectStream(InputStream inputStream, Long contentLength, String contentType, String etag, Instant lastModified, ObjectHeaders headers) + { + this.inputStream = inputStream; + this.contentLength = contentLength; + this.contentType = contentType; + this.etag = etag; + this.lastModified = lastModified; + this.headers = headers; + } + + + + /******************************************************************************* + ** The content. + *******************************************************************************/ + public InputStream getInputStream() + { + return (inputStream); + } + + + + /******************************************************************************* + ** Read everything into memory (small objects only). + *******************************************************************************/ + public byte[] readAllBytes() throws IOException + { + return (inputStream.readAllBytes()); + } + + + + /******************************************************************************* + ** Getter for contentLength. + *******************************************************************************/ + public Long getContentLength() + { + return (contentLength); + } + + + + /******************************************************************************* + ** Getter for contentType. + *******************************************************************************/ + public String getContentType() + { + return (contentType); + } + + + + /******************************************************************************* + ** Getter for etag. + *******************************************************************************/ + public String getEtag() + { + return (etag); + } + + + + /******************************************************************************* + ** Getter for lastModified. + *******************************************************************************/ + public Instant getLastModified() + { + return (lastModified); + } + + + + /******************************************************************************* + ** Getter for headers. + *******************************************************************************/ + public ObjectHeaders getHeaders() + { + return (headers); + } + + + + /******************************************************************************* + ** Close the stream. + *******************************************************************************/ + @Override + public void close() throws IOException + { + inputStream.close(); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/ObjectSummary.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/ObjectSummary.java new file mode 100644 index 0000000..e5bba76 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/ObjectSummary.java @@ -0,0 +1,202 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import java.time.Instant; + + +/******************************************************************************** + ** One object as reported by a listing (no headers). + *******************************************************************************/ +public class ObjectSummary +{ + private String key; + private Long size; + private String etag; + private Instant lastModified; + private String storageClass; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public ObjectSummary() + { + } + + + + /******************************************************************************** + ** Getter for key. + *******************************************************************************/ + public String getKey() + { + return (this.key); + } + + + + /******************************************************************************** + ** Setter for key. + *******************************************************************************/ + public void setKey(String key) + { + this.key = key; + } + + + + /******************************************************************************** + ** Fluent setter for key. + *******************************************************************************/ + public ObjectSummary withKey(String key) + { + this.key = key; + return (this); + } + + + + /******************************************************************************** + ** Getter for size. + *******************************************************************************/ + public Long getSize() + { + return (this.size); + } + + + + /******************************************************************************** + ** Setter for size. + *******************************************************************************/ + public void setSize(Long size) + { + this.size = size; + } + + + + /******************************************************************************** + ** Fluent setter for size. + *******************************************************************************/ + public ObjectSummary withSize(Long size) + { + this.size = size; + return (this); + } + + + + /******************************************************************************** + ** Getter for etag. + *******************************************************************************/ + public String getEtag() + { + return (this.etag); + } + + + + /******************************************************************************** + ** Setter for etag. + *******************************************************************************/ + public void setEtag(String etag) + { + this.etag = etag; + } + + + + /******************************************************************************** + ** Fluent setter for etag. + *******************************************************************************/ + public ObjectSummary withEtag(String etag) + { + this.etag = etag; + return (this); + } + + + + /******************************************************************************** + ** Getter for lastModified. + *******************************************************************************/ + public Instant getLastModified() + { + return (this.lastModified); + } + + + + /******************************************************************************** + ** Setter for lastModified. + *******************************************************************************/ + public void setLastModified(Instant lastModified) + { + this.lastModified = lastModified; + } + + + + /******************************************************************************** + ** Fluent setter for lastModified. + *******************************************************************************/ + public ObjectSummary withLastModified(Instant lastModified) + { + this.lastModified = lastModified; + return (this); + } + + + + /******************************************************************************** + ** Getter for storageClass. + *******************************************************************************/ + public String getStorageClass() + { + return (this.storageClass); + } + + + + /******************************************************************************** + ** Setter for storageClass. + *******************************************************************************/ + public void setStorageClass(String storageClass) + { + this.storageClass = storageClass; + } + + + + /******************************************************************************** + ** Fluent setter for storageClass. + *******************************************************************************/ + public ObjectSummary withStorageClass(String storageClass) + { + this.storageClass = storageClass; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/Page.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/Page.java new file mode 100644 index 0000000..3c7920d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/Page.java @@ -0,0 +1,139 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import java.util.ArrayList; +import java.util.List; + + +/******************************************************************************** + ** One page of results plus a continuation token (null at the end) and, for delimiter listings, the common prefixes. + *******************************************************************************/ +public class Page +{ + private List items = new ArrayList<>(); + private List prefixes = new ArrayList<>(); + private String nextToken; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public Page() + { + } + + + + /******************************************************************************** + ** Getter for items. + *******************************************************************************/ + public List getItems() + { + return (this.items); + } + + + + /******************************************************************************** + ** Setter for items. + *******************************************************************************/ + public void setItems(List items) + { + this.items = items; + } + + + + /******************************************************************************** + ** Fluent setter for items. + *******************************************************************************/ + public Page withItems(List items) + { + this.items = items; + return (this); + } + + + + /******************************************************************************** + ** Getter for prefixes. + *******************************************************************************/ + public List getPrefixes() + { + return (this.prefixes); + } + + + + /******************************************************************************** + ** Setter for prefixes. + *******************************************************************************/ + public void setPrefixes(List prefixes) + { + this.prefixes = prefixes; + } + + + + /******************************************************************************** + ** Fluent setter for prefixes. + *******************************************************************************/ + public Page withPrefixes(List prefixes) + { + this.prefixes = prefixes; + return (this); + } + + + + /******************************************************************************** + ** Getter for nextToken. + *******************************************************************************/ + public String getNextToken() + { + return (this.nextToken); + } + + + + /******************************************************************************** + ** Setter for nextToken. + *******************************************************************************/ + public void setNextToken(String nextToken) + { + this.nextToken = nextToken; + } + + + + /******************************************************************************** + ** Fluent setter for nextToken. + *******************************************************************************/ + public Page withNextToken(String nextToken) + { + this.nextToken = nextToken; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/PresignGet.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/PresignGet.java new file mode 100644 index 0000000..aeb5ac9 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/PresignGet.java @@ -0,0 +1,170 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import java.time.Duration; + + +/******************************************************************************** + ** A presigned download link request. + *******************************************************************************/ +public class PresignGet +{ + private String key; + private Duration ttl; + private String downloadAs; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public PresignGet() + { + } + + + + /******************************************************************************** + ** Getter for key. + *******************************************************************************/ + public String getKey() + { + return (this.key); + } + + + + /******************************************************************************** + ** Setter for key. + *******************************************************************************/ + public void setKey(String key) + { + this.key = key; + } + + + + /******************************************************************************** + ** Fluent setter for key. + *******************************************************************************/ + public PresignGet withKey(String key) + { + this.key = key; + return (this); + } + + + + /******************************************************************************** + ** Getter for ttl. + *******************************************************************************/ + public Duration getTtl() + { + return (this.ttl); + } + + + + /******************************************************************************** + ** Setter for ttl. + *******************************************************************************/ + public void setTtl(Duration ttl) + { + this.ttl = ttl; + } + + + + /******************************************************************************** + ** Fluent setter for ttl. + *******************************************************************************/ + public PresignGet withTtl(Duration ttl) + { + this.ttl = ttl; + return (this); + } + + + + /******************************************************************************** + ** Getter for downloadAs. + *******************************************************************************/ + public String getDownloadAs() + { + return (this.downloadAs); + } + + + + /******************************************************************************** + ** Setter for downloadAs. + *******************************************************************************/ + public void setDownloadAs(String downloadAs) + { + this.downloadAs = downloadAs; + } + + + + /******************************************************************************** + ** Fluent setter for downloadAs. + *******************************************************************************/ + public PresignGet withDownloadAs(String downloadAs) + { + this.downloadAs = downloadAs; + return (this); + } + + + + /******************************************************************************* + ** A presigned GET for a key. + *******************************************************************************/ + public static PresignGet of(String key) + { + return (new PresignGet().withKey(key)); + } + + + + /******************************************************************************* + ** Short-form fluent setter for ttl. + *******************************************************************************/ + public PresignGet ttl(Duration ttl) + { + this.ttl = ttl; + return (this); + } + + + + /******************************************************************************* + ** Short-form fluent setter for downloadAs. + *******************************************************************************/ + public PresignGet downloadAs(String downloadAs) + { + this.downloadAs = downloadAs; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/PresignPost.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/PresignPost.java new file mode 100644 index 0000000..ca2fa5d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/PresignPost.java @@ -0,0 +1,202 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import java.time.Duration; + + +/******************************************************************************** + ** A presigned POST policy request (browser form upload with a size range). + *******************************************************************************/ +public class PresignPost +{ + private String key; + private Duration ttl; + private String contentType; + private Long minBytes = 0L; + private Long maxBytes; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public PresignPost() + { + } + + + + /******************************************************************************** + ** Getter for key. + *******************************************************************************/ + public String getKey() + { + return (this.key); + } + + + + /******************************************************************************** + ** Setter for key. + *******************************************************************************/ + public void setKey(String key) + { + this.key = key; + } + + + + /******************************************************************************** + ** Fluent setter for key. + *******************************************************************************/ + public PresignPost withKey(String key) + { + this.key = key; + return (this); + } + + + + /******************************************************************************** + ** Getter for ttl. + *******************************************************************************/ + public Duration getTtl() + { + return (this.ttl); + } + + + + /******************************************************************************** + ** Setter for ttl. + *******************************************************************************/ + public void setTtl(Duration ttl) + { + this.ttl = ttl; + } + + + + /******************************************************************************** + ** Fluent setter for ttl. + *******************************************************************************/ + public PresignPost withTtl(Duration ttl) + { + this.ttl = ttl; + return (this); + } + + + + /******************************************************************************** + ** Getter for contentType. + *******************************************************************************/ + public String getContentType() + { + return (this.contentType); + } + + + + /******************************************************************************** + ** Setter for contentType. + *******************************************************************************/ + public void setContentType(String contentType) + { + this.contentType = contentType; + } + + + + /******************************************************************************** + ** Fluent setter for contentType. + *******************************************************************************/ + public PresignPost withContentType(String contentType) + { + this.contentType = contentType; + return (this); + } + + + + /******************************************************************************** + ** Getter for minBytes. + *******************************************************************************/ + public Long getMinBytes() + { + return (this.minBytes); + } + + + + /******************************************************************************** + ** Setter for minBytes. + *******************************************************************************/ + public void setMinBytes(Long minBytes) + { + this.minBytes = minBytes; + } + + + + /******************************************************************************** + ** Fluent setter for minBytes. + *******************************************************************************/ + public PresignPost withMinBytes(Long minBytes) + { + this.minBytes = minBytes; + return (this); + } + + + + /******************************************************************************** + ** Getter for maxBytes. + *******************************************************************************/ + public Long getMaxBytes() + { + return (this.maxBytes); + } + + + + /******************************************************************************** + ** Setter for maxBytes. + *******************************************************************************/ + public void setMaxBytes(Long maxBytes) + { + this.maxBytes = maxBytes; + } + + + + /******************************************************************************** + ** Fluent setter for maxBytes. + *******************************************************************************/ + public PresignPost withMaxBytes(Long maxBytes) + { + this.maxBytes = maxBytes; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/PresignPut.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/PresignPut.java new file mode 100644 index 0000000..67f091f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/PresignPut.java @@ -0,0 +1,245 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import java.time.Duration; + + +/******************************************************************************** + ** A presigned upload link request; the content type and length are signed. + *******************************************************************************/ +public class PresignPut +{ + private String key; + private Duration ttl; + private String contentType; + private Long contentLength; + private String cacheControl; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public PresignPut() + { + } + + + + /******************************************************************************** + ** Getter for key. + *******************************************************************************/ + public String getKey() + { + return (this.key); + } + + + + /******************************************************************************** + ** Setter for key. + *******************************************************************************/ + public void setKey(String key) + { + this.key = key; + } + + + + /******************************************************************************** + ** Fluent setter for key. + *******************************************************************************/ + public PresignPut withKey(String key) + { + this.key = key; + return (this); + } + + + + /******************************************************************************** + ** Getter for ttl. + *******************************************************************************/ + public Duration getTtl() + { + return (this.ttl); + } + + + + /******************************************************************************** + ** Setter for ttl. + *******************************************************************************/ + public void setTtl(Duration ttl) + { + this.ttl = ttl; + } + + + + /******************************************************************************** + ** Fluent setter for ttl. + *******************************************************************************/ + public PresignPut withTtl(Duration ttl) + { + this.ttl = ttl; + return (this); + } + + + + /******************************************************************************** + ** Getter for contentType. + *******************************************************************************/ + public String getContentType() + { + return (this.contentType); + } + + + + /******************************************************************************** + ** Setter for contentType. + *******************************************************************************/ + public void setContentType(String contentType) + { + this.contentType = contentType; + } + + + + /******************************************************************************** + ** Fluent setter for contentType. + *******************************************************************************/ + public PresignPut withContentType(String contentType) + { + this.contentType = contentType; + return (this); + } + + + + /******************************************************************************** + ** Getter for contentLength. + *******************************************************************************/ + public Long getContentLength() + { + return (this.contentLength); + } + + + + /******************************************************************************** + ** Setter for contentLength. + *******************************************************************************/ + public void setContentLength(Long contentLength) + { + this.contentLength = contentLength; + } + + + + /******************************************************************************** + ** Fluent setter for contentLength. + *******************************************************************************/ + public PresignPut withContentLength(Long contentLength) + { + this.contentLength = contentLength; + return (this); + } + + + + /******************************************************************************** + ** Getter for cacheControl. + *******************************************************************************/ + public String getCacheControl() + { + return (this.cacheControl); + } + + + + /******************************************************************************** + ** Setter for cacheControl. + *******************************************************************************/ + public void setCacheControl(String cacheControl) + { + this.cacheControl = cacheControl; + } + + + + /******************************************************************************** + ** Fluent setter for cacheControl. + *******************************************************************************/ + public PresignPut withCacheControl(String cacheControl) + { + this.cacheControl = cacheControl; + return (this); + } + + + + /******************************************************************************* + ** A presigned PUT for a key. + *******************************************************************************/ + public static PresignPut of(String key) + { + return (new PresignPut().withKey(key)); + } + + + + /******************************************************************************* + ** Fluent setter for the content type (short form). + *******************************************************************************/ + public PresignPut contentType(String contentType) + { + this.contentType = contentType; + return (this); + } + + + + /******************************************************************************* + ** Short-form fluent setter for contentLength. + *******************************************************************************/ + public PresignPut contentLength(Long contentLength) + { + this.contentLength = contentLength; + return (this); + } + + + + /******************************************************************************* + ** Short-form fluent setter for ttl. + *******************************************************************************/ + public PresignPut ttl(Duration ttl) + { + this.ttl = ttl; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/PresignedPost.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/PresignedPost.java new file mode 100644 index 0000000..3a3f61a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/PresignedPost.java @@ -0,0 +1,107 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import java.util.LinkedHashMap; +import java.util.Map; + + +/******************************************************************************** + ** The URL and form fields for a presigned POST. + *******************************************************************************/ +public class PresignedPost +{ + private String url; + private Map fields = new LinkedHashMap<>(); + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public PresignedPost() + { + } + + + + /******************************************************************************** + ** Getter for url. + *******************************************************************************/ + public String getUrl() + { + return (this.url); + } + + + + /******************************************************************************** + ** Setter for url. + *******************************************************************************/ + public void setUrl(String url) + { + this.url = url; + } + + + + /******************************************************************************** + ** Fluent setter for url. + *******************************************************************************/ + public PresignedPost withUrl(String url) + { + this.url = url; + return (this); + } + + + + /******************************************************************************** + ** Getter for fields. + *******************************************************************************/ + public Map getFields() + { + return (this.fields); + } + + + + /******************************************************************************** + ** Setter for fields. + *******************************************************************************/ + public void setFields(Map fields) + { + this.fields = fields; + } + + + + /******************************************************************************** + ** Fluent setter for fields. + *******************************************************************************/ + public PresignedPost withFields(Map fields) + { + this.fields = fields; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/PublicUrlResolver.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/PublicUrlResolver.java new file mode 100644 index 0000000..b098465 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/PublicUrlResolver.java @@ -0,0 +1,46 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import java.util.Optional; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; + + +/******************************************************************************* + ** Maps (bucket, key) to the URL the world uses, and back. The default is + ** publicBaseUrl + "/" + encodedKey; the CDN qbit supplies one that returns the + ** CDN hostname. + *******************************************************************************/ +public interface PublicUrlResolver +{ + /******************************************************************************* + ** The public URL of a key, or null when the bucket has no public URL. + *******************************************************************************/ + String resolve(StorageBucketConfig bucket, String key); + + + + /******************************************************************************* + ** The reverse mapping, used by reference providers that store URLs. + *******************************************************************************/ + Optional parse(String url); +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/PutRequest.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/PutRequest.java new file mode 100644 index 0000000..8d3b403 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/PutRequest.java @@ -0,0 +1,460 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import java.io.InputStream; +import java.time.Instant; +import java.util.Map; + + +/******************************************************************************** + ** A put of one object from bytes or a stream. + *******************************************************************************/ +public class PutRequest +{ + private String key; + private byte[] bytes; + private InputStream stream; + private Long contentLength; + private String contentType; + private String cacheControl; + private String contentDisposition; + private Map metadata; + private LockMode lockMode; + private Instant lockRetainUntil; + private String storageClass; + private Boolean ifNoneMatch = false; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public PutRequest() + { + } + + + + /******************************************************************************** + ** Getter for key. + *******************************************************************************/ + public String getKey() + { + return (this.key); + } + + + + /******************************************************************************** + ** Setter for key. + *******************************************************************************/ + public void setKey(String key) + { + this.key = key; + } + + + + /******************************************************************************** + ** Fluent setter for key. + *******************************************************************************/ + public PutRequest withKey(String key) + { + this.key = key; + return (this); + } + + + + /******************************************************************************** + ** Getter for bytes. + *******************************************************************************/ + public byte[] getBytes() + { + return (this.bytes); + } + + + + /******************************************************************************** + ** Setter for bytes. + *******************************************************************************/ + public void setBytes(byte[] bytes) + { + this.bytes = bytes; + } + + + + /******************************************************************************** + ** Fluent setter for bytes. + *******************************************************************************/ + public PutRequest withBytes(byte[] bytes) + { + this.bytes = bytes; + return (this); + } + + + + /******************************************************************************** + ** Getter for stream. + *******************************************************************************/ + public InputStream getStream() + { + return (this.stream); + } + + + + /******************************************************************************** + ** Setter for stream. + *******************************************************************************/ + public void setStream(InputStream stream) + { + this.stream = stream; + } + + + + /******************************************************************************** + ** Fluent setter for stream. + *******************************************************************************/ + public PutRequest withStream(InputStream stream) + { + this.stream = stream; + return (this); + } + + + + /******************************************************************************** + ** Getter for contentLength. + *******************************************************************************/ + public Long getContentLength() + { + return (this.contentLength); + } + + + + /******************************************************************************** + ** Setter for contentLength. + *******************************************************************************/ + public void setContentLength(Long contentLength) + { + this.contentLength = contentLength; + } + + + + /******************************************************************************** + ** Fluent setter for contentLength. + *******************************************************************************/ + public PutRequest withContentLength(Long contentLength) + { + this.contentLength = contentLength; + return (this); + } + + + + /******************************************************************************** + ** Getter for contentType. + *******************************************************************************/ + public String getContentType() + { + return (this.contentType); + } + + + + /******************************************************************************** + ** Setter for contentType. + *******************************************************************************/ + public void setContentType(String contentType) + { + this.contentType = contentType; + } + + + + /******************************************************************************** + ** Fluent setter for contentType. + *******************************************************************************/ + public PutRequest withContentType(String contentType) + { + this.contentType = contentType; + return (this); + } + + + + /******************************************************************************** + ** Getter for cacheControl. + *******************************************************************************/ + public String getCacheControl() + { + return (this.cacheControl); + } + + + + /******************************************************************************** + ** Setter for cacheControl. + *******************************************************************************/ + public void setCacheControl(String cacheControl) + { + this.cacheControl = cacheControl; + } + + + + /******************************************************************************** + ** Fluent setter for cacheControl. + *******************************************************************************/ + public PutRequest withCacheControl(String cacheControl) + { + this.cacheControl = cacheControl; + return (this); + } + + + + /******************************************************************************** + ** Getter for contentDisposition. + *******************************************************************************/ + public String getContentDisposition() + { + return (this.contentDisposition); + } + + + + /******************************************************************************** + ** Setter for contentDisposition. + *******************************************************************************/ + public void setContentDisposition(String contentDisposition) + { + this.contentDisposition = contentDisposition; + } + + + + /******************************************************************************** + ** Fluent setter for contentDisposition. + *******************************************************************************/ + public PutRequest withContentDisposition(String contentDisposition) + { + this.contentDisposition = contentDisposition; + return (this); + } + + + + /******************************************************************************** + ** Getter for metadata. + *******************************************************************************/ + public Map getMetadata() + { + return (this.metadata); + } + + + + /******************************************************************************** + ** Setter for metadata. + *******************************************************************************/ + public void setMetadata(Map metadata) + { + this.metadata = metadata; + } + + + + /******************************************************************************** + ** Fluent setter for metadata. + *******************************************************************************/ + public PutRequest withMetadata(Map metadata) + { + this.metadata = metadata; + return (this); + } + + + + /******************************************************************************** + ** Getter for lockMode. + *******************************************************************************/ + public LockMode getLockMode() + { + return (this.lockMode); + } + + + + /******************************************************************************** + ** Setter for lockMode. + *******************************************************************************/ + public void setLockMode(LockMode lockMode) + { + this.lockMode = lockMode; + } + + + + /******************************************************************************** + ** Fluent setter for lockMode. + *******************************************************************************/ + public PutRequest withLockMode(LockMode lockMode) + { + this.lockMode = lockMode; + return (this); + } + + + + /******************************************************************************** + ** Getter for lockRetainUntil. + *******************************************************************************/ + public Instant getLockRetainUntil() + { + return (this.lockRetainUntil); + } + + + + /******************************************************************************** + ** Setter for lockRetainUntil. + *******************************************************************************/ + public void setLockRetainUntil(Instant lockRetainUntil) + { + this.lockRetainUntil = lockRetainUntil; + } + + + + /******************************************************************************** + ** Fluent setter for lockRetainUntil. + *******************************************************************************/ + public PutRequest withLockRetainUntil(Instant lockRetainUntil) + { + this.lockRetainUntil = lockRetainUntil; + return (this); + } + + + + /******************************************************************************** + ** Getter for storageClass. + *******************************************************************************/ + public String getStorageClass() + { + return (this.storageClass); + } + + + + /******************************************************************************** + ** Setter for storageClass. + *******************************************************************************/ + public void setStorageClass(String storageClass) + { + this.storageClass = storageClass; + } + + + + /******************************************************************************** + ** Fluent setter for storageClass. + *******************************************************************************/ + public PutRequest withStorageClass(String storageClass) + { + this.storageClass = storageClass; + return (this); + } + + + + /******************************************************************************** + ** Getter for ifNoneMatch. + *******************************************************************************/ + public Boolean getIfNoneMatch() + { + return (this.ifNoneMatch); + } + + + + /******************************************************************************** + ** Setter for ifNoneMatch. + *******************************************************************************/ + public void setIfNoneMatch(Boolean ifNoneMatch) + { + this.ifNoneMatch = ifNoneMatch; + } + + + + /******************************************************************************** + ** Fluent setter for ifNoneMatch. + *******************************************************************************/ + public PutRequest withIfNoneMatch(Boolean ifNoneMatch) + { + this.ifNoneMatch = ifNoneMatch; + return (this); + } + + + + /******************************************************************************* + ** A put of bytes. + *******************************************************************************/ + public static PutRequest of(String key, byte[] bytes) + { + return (new PutRequest().withKey(key).withBytes(bytes).withContentLength((long) bytes.length)); + } + + + + /******************************************************************************* + ** A put of a stream. + *******************************************************************************/ + public static PutRequest of(String key, InputStream stream) + { + return (new PutRequest().withKey(key).withStream(stream)); + } + + + + /******************************************************************************* + ** Request object lock retention (offered only where the bucket supports it). + *******************************************************************************/ + public PutRequest withObjectLock(LockMode mode, Instant retainUntil) + { + this.lockMode = mode; + this.lockRetainUntil = retainUntil; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/PutResult.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/PutResult.java new file mode 100644 index 0000000..bfcc48b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/PutResult.java @@ -0,0 +1,263 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +/******************************************************************************** + ** The result of a put. + *******************************************************************************/ +public class PutResult +{ + private String key; + private String etag; + private String versionId; + private Long bytes; + private Boolean multipart = false; + private String requestId; + private ObjectHeaders appliedHeaders; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public PutResult() + { + } + + + + /******************************************************************************** + ** Getter for key. + *******************************************************************************/ + public String getKey() + { + return (this.key); + } + + + + /******************************************************************************** + ** Setter for key. + *******************************************************************************/ + public void setKey(String key) + { + this.key = key; + } + + + + /******************************************************************************** + ** Fluent setter for key. + *******************************************************************************/ + public PutResult withKey(String key) + { + this.key = key; + return (this); + } + + + + /******************************************************************************** + ** Getter for etag. + *******************************************************************************/ + public String getEtag() + { + return (this.etag); + } + + + + /******************************************************************************** + ** Setter for etag. + *******************************************************************************/ + public void setEtag(String etag) + { + this.etag = etag; + } + + + + /******************************************************************************** + ** Fluent setter for etag. + *******************************************************************************/ + public PutResult withEtag(String etag) + { + this.etag = etag; + return (this); + } + + + + /******************************************************************************** + ** Getter for versionId. + *******************************************************************************/ + public String getVersionId() + { + return (this.versionId); + } + + + + /******************************************************************************** + ** Setter for versionId. + *******************************************************************************/ + public void setVersionId(String versionId) + { + this.versionId = versionId; + } + + + + /******************************************************************************** + ** Fluent setter for versionId. + *******************************************************************************/ + public PutResult withVersionId(String versionId) + { + this.versionId = versionId; + return (this); + } + + + + /******************************************************************************** + ** Getter for bytes. + *******************************************************************************/ + public Long getBytes() + { + return (this.bytes); + } + + + + /******************************************************************************** + ** Setter for bytes. + *******************************************************************************/ + public void setBytes(Long bytes) + { + this.bytes = bytes; + } + + + + /******************************************************************************** + ** Fluent setter for bytes. + *******************************************************************************/ + public PutResult withBytes(Long bytes) + { + this.bytes = bytes; + return (this); + } + + + + /******************************************************************************** + ** Getter for multipart. + *******************************************************************************/ + public Boolean getMultipart() + { + return (this.multipart); + } + + + + /******************************************************************************** + ** Setter for multipart. + *******************************************************************************/ + public void setMultipart(Boolean multipart) + { + this.multipart = multipart; + } + + + + /******************************************************************************** + ** Fluent setter for multipart. + *******************************************************************************/ + public PutResult withMultipart(Boolean multipart) + { + this.multipart = multipart; + return (this); + } + + + + /******************************************************************************** + ** Getter for requestId. + *******************************************************************************/ + public String getRequestId() + { + return (this.requestId); + } + + + + /******************************************************************************** + ** Setter for requestId. + *******************************************************************************/ + public void setRequestId(String requestId) + { + this.requestId = requestId; + } + + + + /******************************************************************************** + ** Fluent setter for requestId. + *******************************************************************************/ + public PutResult withRequestId(String requestId) + { + this.requestId = requestId; + return (this); + } + + + + /******************************************************************************** + ** Getter for appliedHeaders. + *******************************************************************************/ + public ObjectHeaders getAppliedHeaders() + { + return (this.appliedHeaders); + } + + + + /******************************************************************************** + ** Setter for appliedHeaders. + *******************************************************************************/ + public void setAppliedHeaders(ObjectHeaders appliedHeaders) + { + this.appliedHeaders = appliedHeaders; + } + + + + /******************************************************************************** + ** Fluent setter for appliedHeaders. + *******************************************************************************/ + public PutResult withAppliedHeaders(ObjectHeaders appliedHeaders) + { + this.appliedHeaders = appliedHeaders; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/Range.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/Range.java new file mode 100644 index 0000000..7d2dc8a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/Range.java @@ -0,0 +1,123 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +/******************************************************************************** + ** A byte range for a get. + *******************************************************************************/ +public class Range +{ + private Long from; + private Long to; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public Range() + { + } + + + + /******************************************************************************** + ** Getter for from. + *******************************************************************************/ + public Long getFrom() + { + return (this.from); + } + + + + /******************************************************************************** + ** Setter for from. + *******************************************************************************/ + public void setFrom(Long from) + { + this.from = from; + } + + + + /******************************************************************************** + ** Fluent setter for from. + *******************************************************************************/ + public Range withFrom(Long from) + { + this.from = from; + return (this); + } + + + + /******************************************************************************** + ** Getter for to. + *******************************************************************************/ + public Long getTo() + { + return (this.to); + } + + + + /******************************************************************************** + ** Setter for to. + *******************************************************************************/ + public void setTo(Long to) + { + this.to = to; + } + + + + /******************************************************************************** + ** Fluent setter for to. + *******************************************************************************/ + public Range withTo(Long to) + { + this.to = to; + return (this); + } + + + + /******************************************************************************* + ** The whole object. + *******************************************************************************/ + public static Range all() + { + return (new Range()); + } + + + + /******************************************************************************* + ** Bytes from..to inclusive. + *******************************************************************************/ + public static Range bytes(long from, long to) + { + return (new Range().withFrom(from).withTo(to)); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageAlertSink.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageAlertSink.java new file mode 100644 index 0000000..1d4dc7c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageAlertSink.java @@ -0,0 +1,74 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import java.util.Map; + + +/******************************************************************************* + ** Where storage conditions are raised. The default is a no-op; when the Alerts + ** qbit is installed an adapter forwards to its producer SPI. Alerts carry a + ** stable dedupe key and counts, never secrets or presigned URLs. + *******************************************************************************/ +public interface StorageAlertSink +{ + /******************************************************************************* + ** Raise (or refresh) an alert. + *******************************************************************************/ + void raise(String alertType, String dedupeKey, String severity, String message, Map details); + + + + /******************************************************************************* + ** Resolve an alert. + *******************************************************************************/ + void resolve(String alertType, String dedupeKey); + + + + /******************************************************************************* + ** A sink that does nothing. + *******************************************************************************/ + static StorageAlertSink noop() + { + return (new StorageAlertSink() + { + /******************************************************************************* + ** Does nothing. + *******************************************************************************/ + @Override + public void raise(String alertType, String dedupeKey, String severity, String message, Map details) + { + } + + + + /******************************************************************************* + ** Does nothing. + *******************************************************************************/ + @Override + public void resolve(String alertType, String dedupeKey) + { + } + }); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageBucket.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageBucket.java new file mode 100644 index 0000000..544f1e2 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageBucket.java @@ -0,0 +1,1429 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import com.kingsrook.qbits.objectstorage.api.StorageException.Reason; +import com.kingsrook.qbits.objectstorage.config.BucketRole; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.internal.CapabilityStore; +import com.kingsrook.qbits.objectstorage.internal.ConnectionRuntime; +import com.kingsrook.qbits.objectstorage.internal.ErrorMapper; +import com.kingsrook.qbits.objectstorage.internal.IndexHook; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.internal.OperationLog; +import com.kingsrook.qbits.objectstorage.internal.PostPolicySigner; +import com.kingsrook.qbits.objectstorage.internal.ProtectionRules; +import com.kingsrook.qbits.objectstorage.internal.RateLimiter; +import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.AbortMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.BucketVersioningStatus; +import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.CompletedMultipartUpload; +import software.amazon.awssdk.services.s3.model.CompletedPart; +import software.amazon.awssdk.services.s3.model.CopyObjectRequest; +import software.amazon.awssdk.services.s3.model.CreateMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.CreateMultipartUploadResponse; +import software.amazon.awssdk.services.s3.model.DeleteMarkerEntry; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectResponse; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.ListObjectVersionsRequest; +import software.amazon.awssdk.services.s3.model.ListObjectVersionsResponse; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; +import software.amazon.awssdk.services.s3.model.ObjectVersion; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.model.UploadPartCopyRequest; +import software.amazon.awssdk.services.s3.model.UploadPartRequest; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** One bucket alias, as a typed storage API: put, get, head, copy, delete, + ** restore, list, header and metadata changes, presigned URLs and public URLs. + ** + ** All calls are synchronous, use the connection's cached client and rate + ** limiter, and throw StorageException with a stable reason (never a raw SDK + ** exception). Every write is logged to object_storage_operation, applies the + ** header policy unless headers are given, updates the index (write-through) and + ** then runs the change listeners, which never fail the write. + *******************************************************************************/ +public final class StorageBucket +{ + private static final QLogger LOG = QLogger.getLogger(StorageBucket.class); + + private static final Map PRESIGN_COUNTS = new ConcurrentHashMap<>(); + + private final StorageBucketConfig bucket; + private final ConnectionRuntime connection; + private final StorageCaller caller; + + private volatile long versioningCheckedAt = 0; + private volatile boolean versioningEnabled = false; + + + + /******************************************************************************* + ** Constructor (use ObjectStorage.bucket(alias)). + *******************************************************************************/ + StorageBucket(String alias, StorageCaller caller) + { + this.bucket = ObjectStorageRuntime.bucketConfig(alias); + this.connection = ObjectStorageRuntime.connection(bucket.getConnection()); + this.caller = caller; + } + + + + /******************************************************************************* + ** The alias. + *******************************************************************************/ + public String alias() + { + return (bucket.getAlias()); + } + + + + /******************************************************************************* + ** The bucket configuration. + *******************************************************************************/ + public StorageBucketConfig config() + { + return (bucket); + } + + + + /******************************************************************************* + ** The status of a capability for this bucket. + *******************************************************************************/ + public CapabilityStatus capability(Capability capability) + { + return (CapabilityStore.status(bucket.getAlias(), capability)); + } + + + + /******************************************************************************* + ** The real bucket name on the provider. + *******************************************************************************/ + private String name() + { + return (com.kingsrook.qbits.objectstorage.internal.SecretRefs.resolveSetting(bucket.getBucketName())); + } + + + + // ----------------------------------------------------------------- put + + /******************************************************************************* + ** Put an object. Headers come from the header policy unless the request gives + ** them. A stream is buffered one part at a time and sent as multipart above + ** the part size (default 16 MB). + *******************************************************************************/ + public PutResult put(PutRequest request) + { + checkPermission("object_storage.write"); + String key = StorageKeys.normalize(request.getKey()); + guardWrite(key, ProtectionRules.Op.WRITE, "put"); + if(request.getLockMode() != null && CapabilityStore.ruledOut(capability(Capability.OBJECT_LOCK))) + { + throw (new StorageException(Reason.UNSUPPORTED, "Object lock is not supported for bucket " + alias() + " (" + capability(Capability.OBJECT_LOCK) + ")")); + } + + ObjectHeaders headers = headersForPut(request, key); + OperationLog.Entry log = new OperationLog.Entry(caller, "PUT", alias()).key(key); + try + { + PutResult result = doPut(request, key, headers); + log.bytes(result.getBytes()).requestId(result.getRequestId()); + StoredObject stored = new StoredObject().withBucketAlias(alias()).withKey(key).withSize(result.getBytes()).withEtag(result.getEtag()) + .withLastModified(ObjectStorageRuntime.clock().instant()).withHeaders(headers).withVersionId(result.getVersionId()); + log.detail(afterWrite(ChangeOperation.PUT, key, null, stored)); + log.write(); + return (result); + } + catch(RuntimeException e) + { + StorageException se = ErrorMapper.map(e, "put " + key); + log.failed(se.getMessage()).requestId(se.getRequestId()).write(); + throw (se); + } + } + + + + /******************************************************************************* + ** The headers to store: the request's own, else the policy's (BACKUP buckets + ** never get policy headers). + *******************************************************************************/ + private ObjectHeaders headersForPut(PutRequest request, String key) + { + String contentType = StringUtils.hasContent(request.getContentType()) ? request.getContentType() : guessContentType(key); + ObjectHeaders headers = new ObjectHeaders().withContentType(contentType).withCacheControl(request.getCacheControl()) + .withContentDisposition(request.getContentDisposition()).withUserMetadata(request.getMetadata()); + if(bucket.getRole() != BucketRole.BACKUP && headers.getCacheControl() == null) + { + ObjectHeaders fromPolicy = ObjectStorageRuntime.headerPolicy().headersFor(bucket, key, contentType); + if(fromPolicy != null) + { + headers.setCacheControl(fromPolicy.getCacheControl()); + if(headers.getContentDisposition() == null) + { + headers.setContentDisposition(fromPolicy.getContentDisposition()); + } + } + } + return (headers); + } + + + + /******************************************************************************* + ** The put itself (single request or multipart). + *******************************************************************************/ + private PutResult doPut(PutRequest request, String key, ObjectHeaders headers) + { + S3Client client = connection.client(); + long partSize = Math.max(5, connection.getConfig().getMultipartPartSizeMb()) * 1024L * 1024L; + + byte[] first; + InputStream rest = null; + if(request.getBytes() != null) + { + first = request.getBytes(); + } + else + { + try + { + InputStream in = request.getStream(); + first = in.readNBytes((int) partSize + 1); + if(first.length > partSize) + { + rest = in; + } + } + catch(IOException e) + { + throw (new StorageException(Reason.PROVIDER_ERROR, "Could not read the upload stream: " + e.getMessage(), null, null, e)); + } + } + + if(rest == null) + { + PutObjectRequest.Builder b = PutObjectRequest.builder().bucket(name()).key(key); + applyPutHeaders(b, headers, request); + if(Boolean.TRUE.equals(request.getIfNoneMatch())) + { + b.ifNoneMatch("*"); + } + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + PutObjectResponse response = client.putObject(b.build(), RequestBody.fromBytes(first)); + return (new PutResult().withKey(key).withEtag(response.eTag()).withVersionId(response.versionId()).withBytes((long) first.length) + .withRequestId(response.responseMetadata().requestId()).withAppliedHeaders(headers)); + } + } + return (multipartPut(client, key, headers, request, first, rest, partSize)); + } + + + + /******************************************************************************* + ** Apply headers, metadata, lock and storage class to a put builder. + *******************************************************************************/ + private static void applyPutHeaders(PutObjectRequest.Builder b, ObjectHeaders headers, PutRequest request) + { + if(headers.getContentType() != null) + { + b.contentType(headers.getContentType()); + } + if(headers.getCacheControl() != null) + { + b.cacheControl(headers.getCacheControl()); + } + if(headers.getContentDisposition() != null) + { + b.contentDisposition(headers.getContentDisposition()); + } + if(headers.getUserMetadata() != null && !headers.getUserMetadata().isEmpty()) + { + b.metadata(headers.getUserMetadata()); + } + if(request.getStorageClass() != null) + { + b.storageClass(request.getStorageClass()); + } + if(request.getLockMode() != null) + { + b.objectLockMode(request.getLockMode().name()); + b.objectLockRetainUntilDate(request.getLockRetainUntil()); + } + } + + + + /******************************************************************************* + ** Multipart upload, one part buffered at a time; aborted on any failure. + *******************************************************************************/ + private PutResult multipartPut(S3Client client, String key, ObjectHeaders headers, PutRequest request, byte[] first, InputStream rest, long partSize) + { + CreateMultipartUploadRequest.Builder cb = CreateMultipartUploadRequest.builder().bucket(name()).key(key); + if(headers.getContentType() != null) + { + cb.contentType(headers.getContentType()); + } + if(headers.getCacheControl() != null) + { + cb.cacheControl(headers.getCacheControl()); + } + if(headers.getContentDisposition() != null) + { + cb.contentDisposition(headers.getContentDisposition()); + } + if(headers.getUserMetadata() != null && !headers.getUserMetadata().isEmpty()) + { + cb.metadata(headers.getUserMetadata()); + } + if(request.getStorageClass() != null) + { + cb.storageClass(request.getStorageClass()); + } + if(request.getLockMode() != null) + { + cb.objectLockMode(request.getLockMode().name()); + cb.objectLockRetainUntilDate(request.getLockRetainUntil()); + } + + String uploadId = null; + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + CreateMultipartUploadResponse created = client.createMultipartUpload(cb.build()); + uploadId = created.uploadId(); + List parts = new ArrayList<>(); + long total = 0; + int partNumber = 1; + byte[] chunk = first; + while(chunk.length > 0) + { + var response = client.uploadPart(UploadPartRequest.builder().bucket(name()).key(key).uploadId(uploadId).partNumber(partNumber).build(), RequestBody.fromBytes(chunk)); + parts.add(CompletedPart.builder().partNumber(partNumber).eTag(response.eTag()).build()); + total += chunk.length; + partNumber++; + chunk = rest == null ? new byte[0] : rest.readNBytes((int) partSize); + } + var done = client.completeMultipartUpload(CompleteMultipartUploadRequest.builder().bucket(name()).key(key).uploadId(uploadId) + .multipartUpload(CompletedMultipartUpload.builder().parts(parts).build()).build()); + return (new PutResult().withKey(key).withEtag(done.eTag()).withVersionId(done.versionId()).withBytes(total).withMultipart(true) + .withRequestId(done.responseMetadata().requestId()).withAppliedHeaders(headers)); + } + catch(IOException e) + { + abort(client, key, uploadId); + throw (new StorageException(Reason.PROVIDER_ERROR, "Could not read the upload stream: " + e.getMessage(), null, null, e)); + } + catch(RuntimeException e) + { + abort(client, key, uploadId); + throw (ErrorMapper.map(e, "multipart put " + key)); + } + } + + + + /******************************************************************************* + ** Abort a multipart upload, quietly. + *******************************************************************************/ + private void abort(S3Client client, String key, String uploadId) + { + if(uploadId != null) + { + try + { + client.abortMultipartUpload(AbortMultipartUploadRequest.builder().bucket(name()).key(key).uploadId(uploadId).build()); + } + catch(RuntimeException e) + { + LOG.warn("Could not abort a multipart upload", e, logPair("key", key)); + } + } + } + + + + // ------------------------------------------------------------ head/get + + /******************************************************************************* + ** HEAD an object: its size, ETag, headers and metadata, or empty when absent. + *******************************************************************************/ + public Optional head(String rawKey) + { + checkPermission("object_storage.view"); + String key = StorageKeys.normalize(rawKey); + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + HeadObjectResponse h = connection.client().headObject(HeadObjectRequest.builder().bucket(name()).key(key).build()); + return (Optional.of(new StoredObject().withBucketAlias(alias()).withKey(key).withSize(h.contentLength()).withEtag(h.eTag()) + .withLastModified(h.lastModified()).withVersionId(h.versionId()).withStorageClass(h.storageClassAsString()) + .withLockMode(h.objectLockModeAsString()).withLockRetainUntil(h.objectLockRetainUntilDate()) + .withHeaders(new ObjectHeaders().withCacheControl(h.cacheControl()).withContentType(h.contentType()) + .withContentDisposition(h.contentDisposition()).withContentEncoding(h.contentEncoding()).withUserMetadata(h.metadata())))); + } + catch(S3Exception e) + { + if(e.statusCode() == 404) + { + return (Optional.empty()); + } + throw (ErrorMapper.map(e, "head " + key)); + } + catch(RuntimeException e) + { + throw (ErrorMapper.map(e, "head " + key)); + } + } + + + + /******************************************************************************* + ** Get an object's content (all of it, or a byte range). Close the result. + *******************************************************************************/ + public ObjectStream get(String rawKey, Range range) + { + checkPermission("object_storage.view"); + String key = StorageKeys.normalize(rawKey); + GetObjectRequest.Builder b = GetObjectRequest.builder().bucket(name()).key(key); + if(range != null && range.getFrom() != null) + { + b.range("bytes=" + range.getFrom() + "-" + (range.getTo() == null ? "" : range.getTo())); + } + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + ResponseInputStream in = connection.client().getObject(b.build()); + GetObjectResponse r = in.response(); + return (new ObjectStream(in, r.contentLength(), r.contentType(), r.eTag(), r.lastModified(), new ObjectHeaders().withCacheControl(r.cacheControl()) + .withContentType(r.contentType()).withContentDisposition(r.contentDisposition()).withUserMetadata(r.metadata()))); + } + catch(RuntimeException e) + { + throw (ErrorMapper.map(e, "get " + key)); + } + } + + + + // ---------------------------------------------------------------- copy + + /******************************************************************************* + ** Copy an object (within the bucket, or to another bucket alias). With + ** MetadataDirective.REPLACE the given headers replace the source's; a + ** copy-in-place with REPLACE changes Last-Modified and can change the ETag. + ** Objects over the connection's multipart-copy threshold are copied in parts. + *******************************************************************************/ + public void copy(CopyRequest request) + { + checkPermission("object_storage.write"); + String src = StorageKeys.normalize(request.getSourceKey()); + String dest = StorageKeys.normalize(request.getDestinationKey()); + String destAlias = StringUtils.hasContent(request.getDestinationBucket()) ? request.getDestinationBucket() : alias(); + StorageBucketConfig destConfig = ObjectStorageRuntime.bucketConfig(destAlias); + guardWrite(dest, destAlias, ProtectionRules.Op.WRITE, "copy to"); + OperationLog.Entry log = new OperationLog.Entry(caller, "COPY", alias()).key(src).destination(dest); + try + { + if(!destConfig.getConnection().equals(bucket.getConnection())) + { + copyAcrossConnections(src, dest, destAlias, request); + } + else + { + doCopy(src, dest, destConfig, request); + } + StorageBucket destBucket = destAlias.equals(alias()) ? this : new StorageBucket(destAlias, caller); + StoredObject stored = destBucket.headQuietly(dest); + log.bytes(stored == null ? null : stored.getSize()); + log.detail(afterWrite(ChangeOperation.COPY, destAlias, src, dest, stored)); + log.write(); + } + catch(RuntimeException e) + { + StorageException se = ErrorMapper.map(e, "copy " + src); + log.failed(se.getMessage()).requestId(se.getRequestId()).write(); + throw (se); + } + } + + + + /******************************************************************************* + ** The server-side copy (single request, or parts for very large objects). + *******************************************************************************/ + private void doCopy(String src, String dest, StorageBucketConfig destConfig, CopyRequest request) + { + S3Client client = connection.client(); + String destBucketName = com.kingsrook.qbits.objectstorage.internal.SecretRefs.resolveSetting(destConfig.getBucketName()); + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + HeadObjectResponse head = client.headObject(HeadObjectRequest.builder().bucket(name()).key(src).build()); + long threshold = connection.getConfig().getMultipartCopyThresholdBytes(); + if(head.contentLength() != null && head.contentLength() > threshold) + { + multipartCopy(client, src, dest, destBucketName, head, request); + return; + } + CopyObjectRequest.Builder b = CopyObjectRequest.builder().sourceBucket(name()).sourceKey(src).destinationBucket(destBucketName).destinationKey(dest); + if(request.getDirective() == MetadataDirective.REPLACE) + { + b.metadataDirective(software.amazon.awssdk.services.s3.model.MetadataDirective.REPLACE); + ObjectHeaders h = request.getHeaders() == null ? new ObjectHeaders() : request.getHeaders(); + b.contentType(h.getContentType() != null ? h.getContentType() : head.contentType()); + b.cacheControl(h.getCacheControl()); + b.contentDisposition(h.getContentDisposition()); + b.contentEncoding(h.getContentEncoding() != null ? h.getContentEncoding() : head.contentEncoding()); + b.metadata(h.getUserMetadata() != null ? h.getUserMetadata() : head.metadata()); + } + client.copyObject(b.build()); + } + } + + + + /******************************************************************************* + ** A multipart (UploadPartCopy) copy for objects over the single-copy limit. + *******************************************************************************/ + private void multipartCopy(S3Client client, String src, String dest, String destBucketName, HeadObjectResponse head, CopyRequest request) + { + ObjectHeaders h = request.getDirective() == MetadataDirective.REPLACE && request.getHeaders() != null ? request.getHeaders() : new ObjectHeaders(); + CreateMultipartUploadRequest.Builder cb = CreateMultipartUploadRequest.builder().bucket(destBucketName).key(dest) + .contentType(h.getContentType() != null ? h.getContentType() : head.contentType()) + .cacheControl(request.getDirective() == MetadataDirective.REPLACE ? h.getCacheControl() : head.cacheControl()) + .contentDisposition(request.getDirective() == MetadataDirective.REPLACE ? h.getContentDisposition() : head.contentDisposition()) + .metadata(h.getUserMetadata() != null ? h.getUserMetadata() : head.metadata()); + String uploadId = client.createMultipartUpload(cb.build()).uploadId(); + try + { + long partSize = Math.max(5, connection.getConfig().getMultipartPartSizeMb()) * 1024L * 1024L; + long size = head.contentLength(); + List parts = new ArrayList<>(); + int n = 1; + for(long start = 0; start < size; start += partSize) + { + long end = Math.min(size - 1, start + partSize - 1); + var r = client.uploadPartCopy(UploadPartCopyRequest.builder().sourceBucket(name()).sourceKey(src).destinationBucket(destBucketName).destinationKey(dest) + .uploadId(uploadId).partNumber(n).copySourceRange("bytes=" + start + "-" + end).build()); + parts.add(CompletedPart.builder().partNumber(n).eTag(r.copyPartResult().eTag()).build()); + n++; + } + client.completeMultipartUpload(CompleteMultipartUploadRequest.builder().bucket(destBucketName).key(dest).uploadId(uploadId) + .multipartUpload(CompletedMultipartUpload.builder().parts(parts).build()).build()); + } + catch(RuntimeException e) + { + client.abortMultipartUpload(AbortMultipartUploadRequest.builder().bucket(destBucketName).key(dest).uploadId(uploadId).build()); + throw (e); + } + } + + + + /******************************************************************************* + ** A copy between two connections: stream it through the app server. + *******************************************************************************/ + private void copyAcrossConnections(String src, String dest, String destAlias, CopyRequest request) + { + try(ObjectStream in = get(src, Range.all())) + { + ObjectHeaders h = request.getDirective() == MetadataDirective.REPLACE && request.getHeaders() != null ? request.getHeaders() : in.getHeaders(); + new StorageBucket(destAlias, caller).put(PutRequest.of(dest, in.getInputStream()).withContentType(h.getContentType()).withCacheControl(h.getCacheControl()) + .withContentDisposition(h.getContentDisposition()).withMetadata(h.getUserMetadata())); + } + catch(IOException e) + { + throw (new StorageException(Reason.PROVIDER_ERROR, "Could not copy across connections: " + e.getMessage(), null, null, e)); + } + } + + + + /******************************************************************************* + ** Change an object's headers in place (copy onto itself with REPLACE). Fields + ** not given keep their current values. + *******************************************************************************/ + public void updateHeaders(String rawKey, ObjectHeaders changes) + { + checkPermission("object_storage.write"); + String key = StorageKeys.normalize(rawKey); + guardWrite(key, ProtectionRules.Op.WRITE, "change headers of"); + OperationLog.Entry log = new OperationLog.Entry(caller, "HEADERS", alias()).key(key); + try + { + StoredObject current = head(key).orElseThrow(() -> new StorageException(Reason.NOT_FOUND, "No such object: " + key)); + ObjectHeaders merged = new ObjectHeaders() + .withCacheControl(changes.getCacheControl() != null ? changes.getCacheControl() : current.getHeaders().getCacheControl()) + .withContentType(changes.getContentType() != null ? changes.getContentType() : current.getHeaders().getContentType()) + .withContentDisposition(changes.getContentDisposition() != null ? changes.getContentDisposition() : current.getHeaders().getContentDisposition()) + .withContentEncoding(changes.getContentEncoding() != null ? changes.getContentEncoding() : current.getHeaders().getContentEncoding()) + .withUserMetadata(changes.getUserMetadata() != null ? changes.getUserMetadata() : current.getHeaders().getUserMetadata()); + doCopy(key, key, bucket, CopyRequest.of(key, key).withMetadata(MetadataDirective.REPLACE).withHeaders(merged)); + StoredObject stored = headQuietly(key); + log.bytes(stored == null ? null : stored.getSize()); + log.detail(afterWrite(ChangeOperation.HEADERS, key, null, stored)); + log.write(); + } + catch(RuntimeException e) + { + StorageException se = ErrorMapper.map(e, "change headers of " + key); + log.failed(se.getMessage()).requestId(se.getRequestId()).write(); + throw (se); + } + } + + + + // -------------------------------------------------------------- delete + + /******************************************************************************* + ** Delete an object. Versioned bucket: creates a delete marker. Otherwise, with + ** trash configured: moves it to the private trash location. Otherwise it is + ** permanent and needs object_storage.admin (or a system caller). With + ** permanent(): removes every version and delete marker (system callers only; + ** objects under object-lock retention are skipped and the result says so). + *******************************************************************************/ + public DeleteResult delete(DeleteRequest request) + { + String key = StorageKeys.normalize(request.getKey()); + boolean permanent = Boolean.TRUE.equals(request.getPermanent()); + if(permanent && !caller.isSystem()) + { + throw (new StorageException(Reason.ACCESS_DENIED, "A permanent delete of every version is available to system callers only")); + } + if(!permanent) + { + checkPermission("object_storage.delete"); + } + guardWrite(key, ProtectionRules.Op.DELETE, "delete"); + OperationLog.Entry log = new OperationLog.Entry(caller, "DELETE", alias()).key(key); + try + { + DeleteResult result = permanent ? deleteEverything(key) : softDelete(key, request); + log.detail(result.getMode() + (result.getDetail() == null ? "" : ": " + result.getDetail())); + if(result.getMode() == DeleteMode.SKIPPED_OBJECT_LOCK) + { + log.skipped("object lock retention"); + } + else if(result.getMode() != DeleteMode.NOT_FOUND) + { + String listenerNote = afterDelete(key, result.getMode() == DeleteMode.SOFT_DELETE_MARKER || result.getMode() == DeleteMode.SOFT_TRASH); + if(listenerNote != null) + { + log.detail(result.getMode() + "; " + listenerNote); + } + } + log.write(); + return (result); + } + catch(RuntimeException e) + { + StorageException se = ErrorMapper.map(e, "delete " + key); + log.failed(se.getMessage()).requestId(se.getRequestId()).write(); + throw (se); + } + } + + + + /******************************************************************************* + ** Soft delete by bucket state; permanent only when allowed. + *******************************************************************************/ + private DeleteResult softDelete(String key, DeleteRequest request) + { + S3Client client = connection.client(); + if(isVersioned()) + { + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + DeleteObjectResponse r = client.deleteObject(DeleteObjectRequest.builder().bucket(name()).key(key).build()); + return (new DeleteResult().withKey(key).withMode(DeleteMode.SOFT_DELETE_MARKER).withDetail("delete marker " + r.versionId())); + } + } + if(Boolean.TRUE.equals(bucket.getTrashEnabled())) + { + String trashAlias = StringUtils.hasContent(bucket.getTrashBucket()) ? bucket.getTrashBucket() : alias(); + String trashKey = bucket.getTrashPrefix() + key; + StorageBucketConfig trashConfig = ObjectStorageRuntime.bucketConfig(trashAlias); + if(!trashConfig.getConnection().equals(bucket.getConnection())) + { + throw (new StorageException(Reason.UNSUPPORTED, "The trash bucket must be on the same connection")); + } + if(headQuietly(key) == null) + { + return (new DeleteResult().withKey(key).withMode(DeleteMode.NOT_FOUND)); + } + doCopy(key, trashKey, trashConfig, CopyRequest.of(key, trashKey)); + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + client.deleteObject(DeleteObjectRequest.builder().bucket(name()).key(key).build()); + } + return (new DeleteResult().withKey(key).withMode(DeleteMode.SOFT_TRASH).withDetail("moved to " + trashAlias + "/" + trashKey)); + } + checkPermission("object_storage.admin"); + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + client.deleteObject(DeleteObjectRequest.builder().bucket(name()).key(key).build()); + } + return (new DeleteResult().withKey(key).withMode(DeleteMode.PERMANENT).withDetail("permanent")); + } + + + + /******************************************************************************* + ** Remove every version and delete marker of a key. + *******************************************************************************/ + private DeleteResult deleteEverything(String key) + { + S3Client client = connection.client(); + int deleted = 0; + int locked = 0; + List versions = new ArrayList<>(); + boolean listed = false; + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + ListObjectVersionsResponse r = client.listObjectVersions(ListObjectVersionsRequest.builder().bucket(name()).prefix(key).build()); + for(ObjectVersion v : r.versions()) + { + if(v.key().equals(key)) + { + versions.add(new String[] { v.versionId() }); + } + } + for(DeleteMarkerEntry m : r.deleteMarkers()) + { + if(m.key().equals(key)) + { + versions.add(new String[] { m.versionId() }); + } + } + listed = true; + } + catch(S3Exception e) + { + if(e.statusCode() != 501 && e.statusCode() != 405 && e.statusCode() != 400) + { + throw (e); + } + } + if(!listed || versions.isEmpty()) + { + boolean existed = headQuietly(key) != null; + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + client.deleteObject(DeleteObjectRequest.builder().bucket(name()).key(key).build()); + } + return (new DeleteResult().withKey(key).withMode(existed ? DeleteMode.PERMANENT : DeleteMode.NOT_FOUND).withVersionsDeleted(existed ? 1 : 0)); + } + for(String[] v : versions) + { + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + client.deleteObject(DeleteObjectRequest.builder().bucket(name()).key(key).versionId(v[0]).build()); + deleted++; + } + catch(S3Exception e) + { + String text = e.getMessage() == null ? "" : e.getMessage().toLowerCase(); + if(e.statusCode() == 403 || "AccessDenied".equals(ErrorMapper.errorCode(e)) || text.contains("worm") || text.contains("object lock") || text.contains("retention")) + { + locked++; + } + else + { + throw (e); + } + } + } + if(locked > 0 && deleted == 0) + { + return (new DeleteResult().withKey(key).withMode(DeleteMode.SKIPPED_OBJECT_LOCK).withDetail(locked + " version(s) under object-lock retention")); + } + return (new DeleteResult().withKey(key).withMode(DeleteMode.PERMANENT).withVersionsDeleted(deleted) + .withDetail(locked > 0 ? locked + " version(s) skipped: object-lock retention" : null)); + } + + + + /******************************************************************************* + ** Permanently delete many keys in batches (DeleteObjects, up to 1,000 per + ** call), falling back to one call per key where the provider has no batch + ** delete. System callers only; protected keys are skipped. Returns how many + ** keys were deleted. + *******************************************************************************/ + public int purgeKeys(List rawKeys) + { + if(!caller.isSystem()) + { + throw (new StorageException(Reason.ACCESS_DENIED, "A bulk permanent delete is available to system callers only")); + } + List keys = new ArrayList<>(); + for(String raw : rawKeys) + { + String key = StorageKeys.normalize(raw); + if(!ProtectionRules.check(alias(), key, ProtectionRules.Op.DELETE, caller).blocked()) + { + keys.add(key); + } + } + int deleted = 0; + boolean batch = !CapabilityStore.ruledOut(capability(Capability.BATCH_DELETE)); + OperationLog.Entry log = new OperationLog.Entry(caller, "DELETE", alias()).detail("batch purge of " + keys.size() + " key(s)"); + try + { + for(int i = 0; i < keys.size(); i += 1000) + { + List slice = keys.subList(i, Math.min(keys.size(), i + 1000)); + boolean done = false; + if(batch) + { + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + var r = connection.client().deleteObjects(software.amazon.awssdk.services.s3.model.DeleteObjectsRequest.builder().bucket(name()) + .delete(software.amazon.awssdk.services.s3.model.Delete.builder().objects(slice.stream() + .map(k -> software.amazon.awssdk.services.s3.model.ObjectIdentifier.builder().key(k).build()).toList()).build()).build()); + if(r.errors().isEmpty()) + { + deleted += slice.size(); + done = true; + } + } + catch(S3Exception e) + { + if(e.statusCode() != 501 && e.statusCode() != 405 && e.statusCode() != 400) + { + throw (e); + } + } + } + if(!done) + { + for(String key : slice) + { + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + connection.client().deleteObject(DeleteObjectRequest.builder().bucket(name()).key(key).build()); + deleted++; + } + } + } + } + log.bytes((long) deleted).write(); + for(String key : keys) + { + afterDelete(key, false); + } + return (deleted); + } + catch(RuntimeException e) + { + StorageException se = ErrorMapper.map(e, "purge"); + log.failed(se.getMessage()).requestId(se.getRequestId()).write(); + throw (se); + } + } + + + + /******************************************************************************* + ** Undo a soft delete: remove the delete marker, or copy back from the trash. + ** Returns whether anything was restored. + *******************************************************************************/ + public boolean restore(String rawKey) + { + checkPermission("object_storage.write"); + String key = StorageKeys.normalize(rawKey); + guardWrite(key, ProtectionRules.Op.WRITE, "restore"); + OperationLog.Entry log = new OperationLog.Entry(caller, "RESTORE", alias()).key(key); + try + { + boolean restored = false; + S3Client client = connection.client(); + if(isVersioned()) + { + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + ListObjectVersionsResponse r = client.listObjectVersions(ListObjectVersionsRequest.builder().bucket(name()).prefix(key).build()); + for(DeleteMarkerEntry m : r.deleteMarkers()) + { + if(m.key().equals(key) && Boolean.TRUE.equals(m.isLatest())) + { + client.deleteObject(DeleteObjectRequest.builder().bucket(name()).key(key).versionId(m.versionId()).build()); + restored = true; + } + } + } + } + else if(Boolean.TRUE.equals(bucket.getTrashEnabled())) + { + String trashAlias = StringUtils.hasContent(bucket.getTrashBucket()) ? bucket.getTrashBucket() : alias(); + String trashKey = bucket.getTrashPrefix() + key; + StorageBucket trash = new StorageBucket(trashAlias, StorageCaller.system("object-storage", null)); + if(trash.headQuietly(trashKey) != null) + { + trash.doCopy(trashKey, key, bucket, CopyRequest.of(trashKey, key)); + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + trash.connection.client().deleteObject(DeleteObjectRequest.builder().bucket(trash.name()).key(trashKey).build()); + } + restored = true; + } + } + if(restored) + { + log.detail(afterWrite(ChangeOperation.RESTORE, key, null, headQuietly(key))); + } + else + { + log.skipped("nothing to restore"); + } + log.write(); + return (restored); + } + catch(RuntimeException e) + { + StorageException se = ErrorMapper.map(e, "restore " + key); + log.failed(se.getMessage()).requestId(se.getRequestId()).write(); + throw (se); + } + } + + + + /******************************************************************************* + ** Whether the bucket has versioning enabled (checked live, cached a minute). + *******************************************************************************/ + public boolean isVersioned() + { + if(CapabilityStore.ruledOut(capability(Capability.VERSIONING))) + { + return (false); + } + if(System.currentTimeMillis() - versioningCheckedAt > 60_000) + { + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + versioningEnabled = connection.client().getBucketVersioning(b -> b.bucket(name())).status() == BucketVersioningStatus.ENABLED; + } + catch(RuntimeException e) + { + versioningEnabled = false; + } + versioningCheckedAt = System.currentTimeMillis(); + } + return (versioningEnabled); + } + + + + // ---------------------------------------------------------------- list + + /******************************************************************************* + ** One page of a key listing (up to 1,000 keys per page). + *******************************************************************************/ + public Page list(ListRequest request) + { + checkPermission("object_storage.view"); + ListObjectsV2Request.Builder b = ListObjectsV2Request.builder().bucket(name()).maxKeys(Math.min(1000, request.getPageSize())) + .prefix(StorageKeys.normalizePrefix(request.getPrefix())); + if(StringUtils.hasContent(request.getDelimiter())) + { + b.delimiter(request.getDelimiter()); + } + if(StringUtils.hasContent(request.getAfter())) + { + b.continuationToken(request.getAfter()); + } + if(StringUtils.hasContent(request.getStartAfter())) + { + b.startAfter(request.getStartAfter()); + } + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + ListObjectsV2Response r = connection.client().listObjectsV2(b.build()); + Page page = new Page<>(); + r.contents().forEach(o -> page.getItems().add(new ObjectSummary().withKey(o.key()).withSize(o.size()).withEtag(o.eTag()) + .withLastModified(o.lastModified()).withStorageClass(o.storageClassAsString()))); + r.commonPrefixes().forEach(p -> page.getPrefixes().add(p.prefix())); + page.setNextToken(Boolean.TRUE.equals(r.isTruncated()) ? r.nextContinuationToken() : null); + return (page); + } + catch(RuntimeException e) + { + throw (ErrorMapper.map(e, "list " + request.getPrefix())); + } + } + + + + // ------------------------------------------------------------- presign + + /******************************************************************************* + ** A presigned download URL. The TTL is capped by the bucket's maximum. + *******************************************************************************/ + public URL presignGet(PresignGet request) + { + checkPermission("object_storage.view"); + String key = StorageKeys.normalize(request.getKey()); + Duration ttl = cap(request.getTtl(), Duration.ofMinutes(5), bucket.getMaxPresignGetSeconds()); + countPresign(); + GetObjectRequest.Builder b = GetObjectRequest.builder().bucket(name()).key(key); + if(StringUtils.hasContent(request.getDownloadAs())) + { + b.responseContentDisposition("attachment; filename=\"" + request.getDownloadAs().replace("\"", "") + "\""); + } + try + { + URL url = connection.presigner().presignGetObject(GetObjectPresignRequest.builder().signatureDuration(ttl).getObjectRequest(b.build()).build()).url(); + logPresign("PRESIGN_GET", key, ttl); + return (url); + } + catch(RuntimeException e) + { + throw (ErrorMapper.map(e, "presign get " + key)); + } + } + + + + /******************************************************************************* + ** A short-lived (5 minute) presigned GET used only to render a preview or + ** thumbnail in the admin app for a record the user is already allowed to see. + ** It is not counted against the per-user presign cap and not logged per row + ** (a list of thumbnails would flood the audit log); links a user asks for are + ** created with presignGet and logged. + *******************************************************************************/ + public URL previewUrl(String rawKey) + { + String key = StorageKeys.normalize(rawKey); + try + { + return (connection.presigner().presignGetObject(GetObjectPresignRequest.builder().signatureDuration(Duration.ofMinutes(5)) + .getObjectRequest(GetObjectRequest.builder().bucket(name()).key(key).build()).build()).url()); + } + catch(RuntimeException e) + { + throw (ErrorMapper.map(e, "preview " + key)); + } + } + + + + /******************************************************************************* + ** What a delete does in this bucket: a delete marker (versioned), a move to the + ** trash, or a permanent delete. + *******************************************************************************/ + public DeleteMode plannedDeleteMode() + { + if(isVersioned()) + { + return (DeleteMode.SOFT_DELETE_MARKER); + } + return (Boolean.TRUE.equals(bucket.getTrashEnabled()) ? DeleteMode.SOFT_TRASH : DeleteMode.PERMANENT); + } + + + + /******************************************************************************* + ** The object's tags (empty where tagging is not supported or there are none). + *******************************************************************************/ + public Map getTags(String rawKey) + { + checkPermission("object_storage.view"); + String key = StorageKeys.normalize(rawKey); + if(CapabilityStore.ruledOut(capability(Capability.OBJECT_TAGGING))) + { + return (new java.util.LinkedHashMap<>()); + } + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + Map tags = new java.util.LinkedHashMap<>(); + connection.client().getObjectTagging(b -> b.bucket(name()).key(key)).tagSet().forEach(t -> tags.put(t.key(), t.value())); + return (tags); + } + catch(RuntimeException e) + { + throw (ErrorMapper.map(e, "get tags of " + key)); + } + } + + + + /******************************************************************************* + ** Replace the object's tags. UNSUPPORTED where the provider has no tagging. + *******************************************************************************/ + public void setTags(String rawKey, Map tags) + { + checkPermission("object_storage.write"); + String key = StorageKeys.normalize(rawKey); + if(CapabilityStore.ruledOut(capability(Capability.OBJECT_TAGGING))) + { + throw (new StorageException(Reason.UNSUPPORTED, "Object tagging is not supported on this provider")); + } + guardWrite(key, ProtectionRules.Op.WRITE, "tag"); + OperationLog.Entry log = new OperationLog.Entry(caller, "HEADERS", alias()).key(key).detail("tags: " + tags.keySet()); + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + connection.client().putObjectTagging(b -> b.bucket(name()).key(key).tagging(t -> t.tagSet(tags.entrySet().stream() + .map(e -> software.amazon.awssdk.services.s3.model.Tag.builder().key(e.getKey()).value(e.getValue()).build()).toList()))); + log.write(); + } + catch(RuntimeException e) + { + StorageException se = ErrorMapper.map(e, "set tags of " + key); + log.failed(se.getMessage()).requestId(se.getRequestId()).write(); + throw (se); + } + } + + + + /******************************************************************************* + ** A presigned upload URL. The content type and length are signed when given, + ** which pins the upload to that exact type and size. (A presigned PUT cannot + ** express a maximum size; use presignPost where supported.) + *******************************************************************************/ + public URL presignPut(PresignPut request) + { + checkPermission("object_storage.write"); + String key = StorageKeys.normalize(request.getKey()); + Duration ttl = cap(request.getTtl(), Duration.ofMinutes(15), bucket.getMaxPresignPutSeconds()); + countPresign(); + PutObjectRequest.Builder b = PutObjectRequest.builder().bucket(name()).key(key); + if(StringUtils.hasContent(request.getContentType())) + { + b.contentType(request.getContentType()); + } + if(request.getContentLength() != null) + { + b.contentLength(request.getContentLength()); + } + if(StringUtils.hasContent(request.getCacheControl())) + { + b.cacheControl(request.getCacheControl()); + } + try + { + URL url = connection.presigner().presignPutObject(PutObjectPresignRequest.builder().signatureDuration(ttl).putObjectRequest(b.build()).build()).url(); + logPresign("PRESIGN_PUT", key, ttl); + return (url); + } + catch(RuntimeException e) + { + throw (ErrorMapper.map(e, "presign put " + key)); + } + } + + + + /******************************************************************************* + ** A presigned POST (browser form upload) with a content-length-range policy, + ** which can express a maximum size. UNSUPPORTED where the provider does not do + ** POST uploads ("verify size after upload" is the honest fallback). + *******************************************************************************/ + public PresignedPost presignPost(PresignPost request) + { + checkPermission("object_storage.write"); + if(CapabilityStore.ruledOut(capability(Capability.PRESIGNED_POST))) + { + throw (new StorageException(Reason.UNSUPPORTED, "Presigned POST is not supported on this provider; verify the size after upload")); + } + String key = StorageKeys.normalize(request.getKey()); + Duration ttl = cap(request.getTtl(), Duration.ofMinutes(15), bucket.getMaxPresignPutSeconds()); + countPresign(); + String[] creds = connection.presignCredentials(); + PresignedPost post = PostPolicySigner.sign(creds[0], creds[1], creds[2], creds[3], connection.getConfig().effectiveAddressingStyle(), name(), key, + request.getContentType(), request.getMinBytes(), request.getMaxBytes(), ObjectStorageRuntime.clock().instant(), ttl); + logPresign("PRESIGN_POST", key, ttl); + return (post); + } + + + + /******************************************************************************* + ** The public URL of a key (via the PublicUrlResolver), or null. + *******************************************************************************/ + public String publicUrl(String rawKey) + { + return (ObjectStorageRuntime.publicUrlResolver().resolve(bucket, StorageKeys.normalize(rawKey))); + } + + + + /******************************************************************************* + ** A requested TTL (or the default), capped by the maximum. + *******************************************************************************/ + private static Duration cap(Duration requested, Duration defaultTtl, Integer maxSeconds) + { + Duration ttl = requested == null ? defaultTtl : requested; + Duration max = Duration.ofSeconds(maxSeconds == null ? 3600 : maxSeconds); + return (ttl.compareTo(max) > 0 ? max : ttl); + } + + + + /******************************************************************************* + ** Per-user cap on presigns per hour. + *******************************************************************************/ + private void countPresign() + { + int limit = ObjectStorageRuntime.getConfig().getMaxPresignsPerUserPerHour(); + if(caller.isSystem()) + { + return; + } + String who = caller.describe(); + long[] state = PRESIGN_COUNTS.computeIfAbsent(who, w -> new long[] { System.currentTimeMillis(), 0 }); + synchronized(state) + { + if(System.currentTimeMillis() - state[0] > 3_600_000L) + { + state[0] = System.currentTimeMillis(); + state[1] = 0; + } + if(++state[1] > limit) + { + throw (new StorageException(Reason.THROTTLED, "More than " + limit + " presigned links were requested in an hour")); + } + } + } + + + + /******************************************************************************* + ** Log a presign without the signature (key, method, expiry and issuer only). + *******************************************************************************/ + private void logPresign(String action, String key, Duration ttl) + { + new OperationLog.Entry(caller, action, alias()).key(key).detail("expires " + ObjectStorageRuntime.clock().instant().plus(ttl)).write(); + } + + + + // ------------------------------------------------------------ helpers + + /******************************************************************************* + ** HEAD without throwing (null when absent or unreadable). + *******************************************************************************/ + private StoredObject headQuietly(String key) + { + try + { + return (head(key).orElse(null)); + } + catch(RuntimeException e) + { + return (null); + } + } + + + + /******************************************************************************* + ** Throw when a protection rule (or the protected-bucket flag) blocks a write. + *******************************************************************************/ + private void guardWrite(String key, ProtectionRules.Op op, String what) + { + guardWrite(key, alias(), op, what); + } + + + + /******************************************************************************* + ** Throw when a protection rule blocks a write to a bucket alias. + *******************************************************************************/ + private void guardWrite(String key, String targetAlias, ProtectionRules.Op op, String what) + { + ProtectionRules.Result result = ProtectionRules.check(targetAlias, key, op, caller); + if(result.blocked()) + { + throw (new StorageException(Reason.ACCESS_DENIED, "Cannot " + what + " " + key + ": " + result.reason())); + } + if(result.warned()) + { + LOG.info("Protection rule warning", logPair("key", key), logPair("note", result.reason())); + } + } + + + + /******************************************************************************* + ** Whether the current user holds a permission. + *******************************************************************************/ + private boolean hasPermission(String permission) + { + return (QContext.getQSession() != null && QContext.getQSession().hasPermission(permission)); + } + + + + /******************************************************************************* + ** When core permission enforcement is on, apply the same object_storage.* + ** checks to the session's user. System callers are not subject to them. + *******************************************************************************/ + private void checkPermission(String permission) + { + if(caller.isSystem() || QContext.getQSession() == null || !CorePermissionEnforcer.isInstanceEnforcementEnabled()) + { + return; + } + if(!hasPermission(permission) && !hasPermission("object_storage.admin")) + { + throw (new StorageException(Reason.ACCESS_DENIED, "This needs the " + permission + " permission")); + } + } + + + + /******************************************************************************* + ** After a write: update the index and run the listeners. Returns a note with + ** any listener errors (recorded on the operation row), or null. + *******************************************************************************/ + private String afterWrite(ChangeOperation op, String key, String dest, StoredObject stored) + { + return (afterWrite(op, alias(), key, dest, stored)); + } + + + + /******************************************************************************* + ** After a write to a bucket alias (the destination of a copy). + *******************************************************************************/ + private String afterWrite(ChangeOperation op, String targetAlias, String key, String dest, StoredObject stored) + { + String effectiveKey = op == ChangeOperation.COPY ? dest : key; + IndexHook hook = ObjectStorageRuntime.indexHook(); + if(hook != null && stored != null) + { + try + { + hook.objectWritten(ObjectStorageRuntime.bucketConfig(targetAlias), stored); + } + catch(RuntimeException e) + { + LOG.warn("Index write-through failed; the next scan repairs it", e, logPair("key", effectiveKey)); + } + } + StorageChange change = new StorageChange().withBucketAlias(targetAlias).withKey(op == ChangeOperation.COPY ? key : effectiveKey) + .withDestinationKey(op == ChangeOperation.COPY ? dest : null).withOperation(op).withActor(caller.describe()); + return (notifyListeners(change)); + } + + + + /******************************************************************************* + ** After a delete: update the index and run the listeners. + *******************************************************************************/ + private String afterDelete(String key, boolean soft) + { + IndexHook hook = ObjectStorageRuntime.indexHook(); + if(hook != null) + { + try + { + hook.objectRemoved(bucket, key, soft); + } + catch(RuntimeException e) + { + LOG.warn("Index write-through failed; the next scan repairs it", e, logPair("key", key)); + } + } + return (notifyListeners(new StorageChange().withBucketAlias(alias()).withKey(key).withOperation(ChangeOperation.DELETE).withActor(caller.describe()))); + } + + + + /******************************************************************************* + ** Run every listener; their errors are collected, never thrown. + *******************************************************************************/ + private String notifyListeners(StorageChange change) + { + List errors = new ArrayList<>(); + for(StorageChangeListener listener : ObjectStorageRuntime.listeners()) + { + try + { + listener.onChange(change); + } + catch(Exception e) + { + errors.add(listener.getClass().getSimpleName() + ": " + e.getMessage()); + LOG.warn("A storage change listener failed", e, logPair("listener", listener.getClass().getName())); + } + } + return (errors.isEmpty() ? null : "listener errors: " + String.join("; ", errors)); + } + + + + /******************************************************************************* + ** A content type from the key's extension. + *******************************************************************************/ + static String guessContentType(String key) + { + String guessed = URLConnection.guessContentTypeFromName(key); + if(guessed != null) + { + return (guessed); + } + String lower = key.toLowerCase(); + Map known = new HashMap<>(Map.of("webp", "image/webp", "svg", "image/svg+xml", "json", "application/json", "css", "text/css", + "js", "text/javascript", "mp4", "video/mp4", "webm", "video/webm", "txt", "text/plain", "avif", "image/avif")); + int dot = lower.lastIndexOf('.'); + return (dot >= 0 && known.containsKey(lower.substring(dot + 1)) ? known.get(lower.substring(dot + 1)) : "application/octet-stream"); + } + + + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageCaller.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageCaller.java new file mode 100644 index 0000000..0635635 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageCaller.java @@ -0,0 +1,134 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** Who is making a storage call: the current session's user, or a system caller + ** (a calling qbit and its run, for example "backups" run 1234). System callers + ** are not subject to the user's object_storage.* permissions; the calling qbit + ** must have checked its own permission first. Every write is logged with this. + *******************************************************************************/ +public final class StorageCaller +{ + private final boolean system; + private final String callerName; + private final String runId; + private final String startedBy; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + private StorageCaller(boolean system, String callerName, String runId, String startedBy) + { + this.system = system; + this.callerName = callerName; + this.runId = runId; + this.startedBy = startedBy; + } + + + + /******************************************************************************* + ** The current session's user as the caller. + *******************************************************************************/ + public static StorageCaller user() + { + return (new StorageCaller(false, null, null, currentUserName())); + } + + + + /******************************************************************************* + ** A system caller: the calling qbit's name and its run id. + *******************************************************************************/ + public static StorageCaller system(String callerName, String runId) + { + return (new StorageCaller(true, callerName, runId, currentUserName())); + } + + + + /******************************************************************************* + ** The name of the current user, or null. + *******************************************************************************/ + private static String currentUserName() + { + QSession session = QContext.getQSession(); + if(session != null && session.getUser() != null) + { + String id = session.getUser().getIdReference(); + return (StringUtils.hasContent(id) ? id : session.getUser().getFullName()); + } + return (null); + } + + + + /******************************************************************************* + ** Whether this is a system caller. + *******************************************************************************/ + public boolean isSystem() + { + return (system); + } + + + + /******************************************************************************* + ** Getter for callerName. + *******************************************************************************/ + public String getCallerName() + { + return (callerName); + } + + + + /******************************************************************************* + ** Getter for runId. + *******************************************************************************/ + public String getRunId() + { + return (runId); + } + + + + /******************************************************************************* + ** The text recorded in the operation log. + *******************************************************************************/ + public String describe() + { + if(system) + { + return ("system: " + callerName + (runId == null ? "" : " run " + runId) + (startedBy == null ? "" : " (started by user " + startedBy + ")")); + } + return (startedBy == null ? "system (no user)" : startedBy); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageChange.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageChange.java new file mode 100644 index 0000000..420be4d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageChange.java @@ -0,0 +1,199 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +/******************************************************************************** + ** What a change listener is told after a successful write. + *******************************************************************************/ +public class StorageChange +{ + private String bucketAlias; + private String key; + private String destinationKey; + private ChangeOperation operation; + private String actor; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public StorageChange() + { + } + + + + /******************************************************************************** + ** Getter for bucketAlias. + *******************************************************************************/ + public String getBucketAlias() + { + return (this.bucketAlias); + } + + + + /******************************************************************************** + ** Setter for bucketAlias. + *******************************************************************************/ + public void setBucketAlias(String bucketAlias) + { + this.bucketAlias = bucketAlias; + } + + + + /******************************************************************************** + ** Fluent setter for bucketAlias. + *******************************************************************************/ + public StorageChange withBucketAlias(String bucketAlias) + { + this.bucketAlias = bucketAlias; + return (this); + } + + + + /******************************************************************************** + ** Getter for key. + *******************************************************************************/ + public String getKey() + { + return (this.key); + } + + + + /******************************************************************************** + ** Setter for key. + *******************************************************************************/ + public void setKey(String key) + { + this.key = key; + } + + + + /******************************************************************************** + ** Fluent setter for key. + *******************************************************************************/ + public StorageChange withKey(String key) + { + this.key = key; + return (this); + } + + + + /******************************************************************************** + ** Getter for destinationKey. + *******************************************************************************/ + public String getDestinationKey() + { + return (this.destinationKey); + } + + + + /******************************************************************************** + ** Setter for destinationKey. + *******************************************************************************/ + public void setDestinationKey(String destinationKey) + { + this.destinationKey = destinationKey; + } + + + + /******************************************************************************** + ** Fluent setter for destinationKey. + *******************************************************************************/ + public StorageChange withDestinationKey(String destinationKey) + { + this.destinationKey = destinationKey; + return (this); + } + + + + /******************************************************************************** + ** Getter for operation. + *******************************************************************************/ + public ChangeOperation getOperation() + { + return (this.operation); + } + + + + /******************************************************************************** + ** Setter for operation. + *******************************************************************************/ + public void setOperation(ChangeOperation operation) + { + this.operation = operation; + } + + + + /******************************************************************************** + ** Fluent setter for operation. + *******************************************************************************/ + public StorageChange withOperation(ChangeOperation operation) + { + this.operation = operation; + return (this); + } + + + + /******************************************************************************** + ** Getter for actor. + *******************************************************************************/ + public String getActor() + { + return (this.actor); + } + + + + /******************************************************************************** + ** Setter for actor. + *******************************************************************************/ + public void setActor(String actor) + { + this.actor = actor; + } + + + + /******************************************************************************** + ** Fluent setter for actor. + *******************************************************************************/ + public StorageChange withActor(String actor) + { + this.actor = actor; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageChangeListener.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageChangeListener.java new file mode 100644 index 0000000..dc51195 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageChangeListener.java @@ -0,0 +1,35 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +/******************************************************************************* + ** Called after every successful write (put, copy, header change, delete, + ** restore). The CDN qbit registers one to plan purges. Listeners run after the + ** write and never fail it; their errors are logged on the operation row. + *******************************************************************************/ +public interface StorageChangeListener +{ + /******************************************************************************* + ** Called after a write. + *******************************************************************************/ + void onChange(StorageChange change) throws Exception; +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageException.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageException.java new file mode 100644 index 0000000..a0c3a4f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageException.java @@ -0,0 +1,99 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +/******************************************************************************* + ** The only exception the storage API throws. No raw SDK exception leaks out. + ** Carries a stable reason and the provider's request ID when there is one. + *******************************************************************************/ +public class StorageException extends RuntimeException +{ + /******************************************************************************* + ** Stable failure reasons. + *******************************************************************************/ + public enum Reason + { + NOT_FOUND, + ACCESS_DENIED, + UNSUPPORTED, + PRECONDITION_FAILED, + THROTTLED, + PROVIDER_ERROR + } + + private final Reason reason; + private final String requestId; + private final Integer httpStatus; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + public StorageException(Reason reason, String message, String requestId, Integer httpStatus, Throwable cause) + { + super(message, cause); + this.reason = reason; + this.requestId = requestId; + this.httpStatus = httpStatus; + } + + + + /******************************************************************************* + ** Constructor without a provider response. + *******************************************************************************/ + public StorageException(Reason reason, String message) + { + this(reason, message, null, null, null); + } + + + + /******************************************************************************* + ** Getter for reason. + *******************************************************************************/ + public Reason getReason() + { + return (reason); + } + + + + /******************************************************************************* + ** Getter for requestId. + *******************************************************************************/ + public String getRequestId() + { + return (requestId); + } + + + + /******************************************************************************* + ** Getter for httpStatus. + *******************************************************************************/ + public Integer getHttpStatus() + { + return (httpStatus); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageKeys.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageKeys.java new file mode 100644 index 0000000..e9eecae --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageKeys.java @@ -0,0 +1,195 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.regex.Pattern; + + +/******************************************************************************* + ** Builds and validates object keys, and percent-encodes them for URLs. + ** Apps keep their own key layouts; this only makes them consistent and safe. + *******************************************************************************/ +public final class StorageKeys +{ + public static final int MAX_KEY_BYTES = 1024; + + private static final Pattern CONTROL_CHARS = Pattern.compile("[\\p{Cntrl}]"); + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private StorageKeys() + { + } + + + + /******************************************************************************* + ** Normalize and validate: collapse //, strip a leading /, reject .. segments, + ** control characters and keys over 1,024 bytes. Returns the normalized key. + *******************************************************************************/ + public static String normalize(String key) + { + if(key == null || key.isEmpty()) + { + throw (new IllegalArgumentException("An object key is required")); + } + if(CONTROL_CHARS.matcher(key).find()) + { + throw (new IllegalArgumentException("An object key may not contain control characters")); + } + String normalized = key.replaceAll("/{2,}", "/"); + while(normalized.startsWith("/")) + { + normalized = normalized.substring(1); + } + if(normalized.isEmpty()) + { + throw (new IllegalArgumentException("An object key is required")); + } + for(String segment : normalized.split("/", -1)) + { + if("..".equals(segment)) + { + throw (new IllegalArgumentException("An object key may not contain .. segments")); + } + } + if(normalized.getBytes(StandardCharsets.UTF_8).length > MAX_KEY_BYTES) + { + throw (new IllegalArgumentException("An object key may not be longer than " + MAX_KEY_BYTES + " bytes")); + } + return (normalized); + } + + + + /******************************************************************************* + ** Normalize a prefix (a key that may be empty or end with a slash). + *******************************************************************************/ + public static String normalizePrefix(String prefix) + { + if(prefix == null || prefix.isEmpty() || "/".equals(prefix)) + { + return (""); + } + boolean trailing = prefix.endsWith("/"); + String n = normalize(prefix); + return (trailing && !n.endsWith("/") ? n + "/" : n); + } + + + + /******************************************************************************* + ** A safe file name: no path separators or control characters, no leading dots. + *******************************************************************************/ + public static String safeFilename(String name) + { + String n = name == null ? "" : name.replace('\\', '/'); + n = n.substring(n.lastIndexOf('/') + 1); + n = CONTROL_CHARS.matcher(n).replaceAll(""); + n = n.replaceAll("^\\.+", "").trim(); + return (n.isEmpty() ? "file" : n); + } + + + + /******************************************************************************* + ** The content-addressed layout {scope}/{slug}/{sha256}/{name}. + *******************************************************************************/ + public static String contentAddressed(String scope, String slug, byte[] content, String name) + { + return (normalize(scope + "/" + slug + "/" + sha256Hex(content) + "/" + safeFilename(name))); + } + + + + /******************************************************************************* + ** Lowercase hex SHA-256. + *******************************************************************************/ + public static String sha256Hex(byte[] content) + { + try + { + return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content))); + } + catch(NoSuchAlgorithmException e) + { + throw (new IllegalStateException(e)); + } + } + + + + /******************************************************************************* + ** Percent-encode a key for use in a URL path: slashes stay, and '+', space and + ** non-ASCII are encoded (the usual source of "works on MinIO, 403 on AWS"). + *******************************************************************************/ + public static String encodeForUrl(String key) + { + StringBuilder out = new StringBuilder(); + for(byte b : key.getBytes(StandardCharsets.UTF_8)) + { + char c = (char) (b & 0xFF); + if((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~' || c == '/') + { + out.append(c); + } + else + { + out.append('%').append(String.format("%02X", b & 0xFF)); + } + } + return (out.toString()); + } + + + + /******************************************************************************* + ** The reverse of {@link #encodeForUrl(String)}. + *******************************************************************************/ + public static String decodeFromUrl(String encoded) + { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for(int i = 0; i < encoded.length(); i++) + { + char c = encoded.charAt(i); + if(c == '%' && i + 2 < encoded.length()) + { + out.write(Integer.parseInt(encoded.substring(i + 1, i + 3), 16)); + i += 2; + } + else + { + byte[] bytes = String.valueOf(c).getBytes(StandardCharsets.UTF_8); + out.write(bytes, 0, bytes.length); + } + } + return (out.toString(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageReference.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageReference.java new file mode 100644 index 0000000..23e7484 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageReference.java @@ -0,0 +1,263 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +/******************************************************************************** + ** A record that points at a key or prefix in a bucket. + *******************************************************************************/ +public class StorageReference +{ + private String bucketAlias; + private String keyOrPrefix; + private MatchMode matchMode = MatchMode.KEY; + private String tableName; + private String recordId; + private String fieldName; + private String label; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public StorageReference() + { + } + + + + /******************************************************************************** + ** Getter for bucketAlias. + *******************************************************************************/ + public String getBucketAlias() + { + return (this.bucketAlias); + } + + + + /******************************************************************************** + ** Setter for bucketAlias. + *******************************************************************************/ + public void setBucketAlias(String bucketAlias) + { + this.bucketAlias = bucketAlias; + } + + + + /******************************************************************************** + ** Fluent setter for bucketAlias. + *******************************************************************************/ + public StorageReference withBucketAlias(String bucketAlias) + { + this.bucketAlias = bucketAlias; + return (this); + } + + + + /******************************************************************************** + ** Getter for keyOrPrefix. + *******************************************************************************/ + public String getKeyOrPrefix() + { + return (this.keyOrPrefix); + } + + + + /******************************************************************************** + ** Setter for keyOrPrefix. + *******************************************************************************/ + public void setKeyOrPrefix(String keyOrPrefix) + { + this.keyOrPrefix = keyOrPrefix; + } + + + + /******************************************************************************** + ** Fluent setter for keyOrPrefix. + *******************************************************************************/ + public StorageReference withKeyOrPrefix(String keyOrPrefix) + { + this.keyOrPrefix = keyOrPrefix; + return (this); + } + + + + /******************************************************************************** + ** Getter for matchMode. + *******************************************************************************/ + public MatchMode getMatchMode() + { + return (this.matchMode); + } + + + + /******************************************************************************** + ** Setter for matchMode. + *******************************************************************************/ + public void setMatchMode(MatchMode matchMode) + { + this.matchMode = matchMode; + } + + + + /******************************************************************************** + ** Fluent setter for matchMode. + *******************************************************************************/ + public StorageReference withMatchMode(MatchMode matchMode) + { + this.matchMode = matchMode; + return (this); + } + + + + /******************************************************************************** + ** Getter for tableName. + *******************************************************************************/ + public String getTableName() + { + return (this.tableName); + } + + + + /******************************************************************************** + ** Setter for tableName. + *******************************************************************************/ + public void setTableName(String tableName) + { + this.tableName = tableName; + } + + + + /******************************************************************************** + ** Fluent setter for tableName. + *******************************************************************************/ + public StorageReference withTableName(String tableName) + { + this.tableName = tableName; + return (this); + } + + + + /******************************************************************************** + ** Getter for recordId. + *******************************************************************************/ + public String getRecordId() + { + return (this.recordId); + } + + + + /******************************************************************************** + ** Setter for recordId. + *******************************************************************************/ + public void setRecordId(String recordId) + { + this.recordId = recordId; + } + + + + /******************************************************************************** + ** Fluent setter for recordId. + *******************************************************************************/ + public StorageReference withRecordId(String recordId) + { + this.recordId = recordId; + return (this); + } + + + + /******************************************************************************** + ** Getter for fieldName. + *******************************************************************************/ + public String getFieldName() + { + return (this.fieldName); + } + + + + /******************************************************************************** + ** Setter for fieldName. + *******************************************************************************/ + public void setFieldName(String fieldName) + { + this.fieldName = fieldName; + } + + + + /******************************************************************************** + ** Fluent setter for fieldName. + *******************************************************************************/ + public StorageReference withFieldName(String fieldName) + { + this.fieldName = fieldName; + return (this); + } + + + + /******************************************************************************** + ** Getter for label. + *******************************************************************************/ + public String getLabel() + { + return (this.label); + } + + + + /******************************************************************************** + ** Setter for label. + *******************************************************************************/ + public void setLabel(String label) + { + this.label = label; + } + + + + /******************************************************************************** + ** Fluent setter for label. + *******************************************************************************/ + public StorageReference withLabel(String label) + { + this.label = label; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageReferenceProvider.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageReferenceProvider.java new file mode 100644 index 0000000..9a37c66 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/StorageReferenceProvider.java @@ -0,0 +1,38 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import java.util.stream.Stream; +import com.kingsrook.qqq.backend.core.exceptions.QException; + + +/******************************************************************************* + ** Lets an app declare which records point at storage. Most apps need no code: + ** see FieldReferenceProvider. + *******************************************************************************/ +public interface StorageReferenceProvider +{ + /******************************************************************************* + ** Every reference this provider knows of in a bucket. + *******************************************************************************/ + Stream references(String bucketAlias) throws QException; +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/StoredObject.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/StoredObject.java new file mode 100644 index 0000000..92745cb --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/StoredObject.java @@ -0,0 +1,362 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import java.time.Instant; + + +/******************************************************************************** + ** One object as reported by HEAD. + *******************************************************************************/ +public class StoredObject +{ + private String bucketAlias; + private String key; + private Long size; + private String etag; + private Instant lastModified; + private ObjectHeaders headers = new ObjectHeaders(); + private String versionId; + private String storageClass; + private String lockMode; + private Instant lockRetainUntil; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public StoredObject() + { + } + + + + /******************************************************************************** + ** Getter for bucketAlias. + *******************************************************************************/ + public String getBucketAlias() + { + return (this.bucketAlias); + } + + + + /******************************************************************************** + ** Setter for bucketAlias. + *******************************************************************************/ + public void setBucketAlias(String bucketAlias) + { + this.bucketAlias = bucketAlias; + } + + + + /******************************************************************************** + ** Fluent setter for bucketAlias. + *******************************************************************************/ + public StoredObject withBucketAlias(String bucketAlias) + { + this.bucketAlias = bucketAlias; + return (this); + } + + + + /******************************************************************************** + ** Getter for key. + *******************************************************************************/ + public String getKey() + { + return (this.key); + } + + + + /******************************************************************************** + ** Setter for key. + *******************************************************************************/ + public void setKey(String key) + { + this.key = key; + } + + + + /******************************************************************************** + ** Fluent setter for key. + *******************************************************************************/ + public StoredObject withKey(String key) + { + this.key = key; + return (this); + } + + + + /******************************************************************************** + ** Getter for size. + *******************************************************************************/ + public Long getSize() + { + return (this.size); + } + + + + /******************************************************************************** + ** Setter for size. + *******************************************************************************/ + public void setSize(Long size) + { + this.size = size; + } + + + + /******************************************************************************** + ** Fluent setter for size. + *******************************************************************************/ + public StoredObject withSize(Long size) + { + this.size = size; + return (this); + } + + + + /******************************************************************************** + ** Getter for etag. + *******************************************************************************/ + public String getEtag() + { + return (this.etag); + } + + + + /******************************************************************************** + ** Setter for etag. + *******************************************************************************/ + public void setEtag(String etag) + { + this.etag = etag; + } + + + + /******************************************************************************** + ** Fluent setter for etag. + *******************************************************************************/ + public StoredObject withEtag(String etag) + { + this.etag = etag; + return (this); + } + + + + /******************************************************************************** + ** Getter for lastModified. + *******************************************************************************/ + public Instant getLastModified() + { + return (this.lastModified); + } + + + + /******************************************************************************** + ** Setter for lastModified. + *******************************************************************************/ + public void setLastModified(Instant lastModified) + { + this.lastModified = lastModified; + } + + + + /******************************************************************************** + ** Fluent setter for lastModified. + *******************************************************************************/ + public StoredObject withLastModified(Instant lastModified) + { + this.lastModified = lastModified; + return (this); + } + + + + /******************************************************************************** + ** Getter for headers. + *******************************************************************************/ + public ObjectHeaders getHeaders() + { + return (this.headers); + } + + + + /******************************************************************************** + ** Setter for headers. + *******************************************************************************/ + public void setHeaders(ObjectHeaders headers) + { + this.headers = headers; + } + + + + /******************************************************************************** + ** Fluent setter for headers. + *******************************************************************************/ + public StoredObject withHeaders(ObjectHeaders headers) + { + this.headers = headers; + return (this); + } + + + + /******************************************************************************** + ** Getter for versionId. + *******************************************************************************/ + public String getVersionId() + { + return (this.versionId); + } + + + + /******************************************************************************** + ** Setter for versionId. + *******************************************************************************/ + public void setVersionId(String versionId) + { + this.versionId = versionId; + } + + + + /******************************************************************************** + ** Fluent setter for versionId. + *******************************************************************************/ + public StoredObject withVersionId(String versionId) + { + this.versionId = versionId; + return (this); + } + + + + /******************************************************************************** + ** Getter for storageClass. + *******************************************************************************/ + public String getStorageClass() + { + return (this.storageClass); + } + + + + /******************************************************************************** + ** Setter for storageClass. + *******************************************************************************/ + public void setStorageClass(String storageClass) + { + this.storageClass = storageClass; + } + + + + /******************************************************************************** + ** Fluent setter for storageClass. + *******************************************************************************/ + public StoredObject withStorageClass(String storageClass) + { + this.storageClass = storageClass; + return (this); + } + + + + /******************************************************************************** + ** Getter for lockMode. + *******************************************************************************/ + public String getLockMode() + { + return (this.lockMode); + } + + + + /******************************************************************************** + ** Setter for lockMode. + *******************************************************************************/ + public void setLockMode(String lockMode) + { + this.lockMode = lockMode; + } + + + + /******************************************************************************** + ** Fluent setter for lockMode. + *******************************************************************************/ + public StoredObject withLockMode(String lockMode) + { + this.lockMode = lockMode; + return (this); + } + + + + /******************************************************************************** + ** Getter for lockRetainUntil. + *******************************************************************************/ + public Instant getLockRetainUntil() + { + return (this.lockRetainUntil); + } + + + + /******************************************************************************** + ** Setter for lockRetainUntil. + *******************************************************************************/ + public void setLockRetainUntil(Instant lockRetainUntil) + { + this.lockRetainUntil = lockRetainUntil; + } + + + + /******************************************************************************** + ** Fluent setter for lockRetainUntil. + *******************************************************************************/ + public StoredObject withLockRetainUntil(Instant lockRetainUntil) + { + this.lockRetainUntil = lockRetainUntil; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/api/SystemStorage.java b/src/main/java/com/kingsrook/qbits/objectstorage/api/SystemStorage.java new file mode 100644 index 0000000..6c9e70e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/api/SystemStorage.java @@ -0,0 +1,56 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import com.kingsrook.qbits.objectstorage.internal.ConfigSync; + + +/******************************************************************************* + ** The storage API for a system caller (see ObjectStorage.asSystem): calls are + ** tagged with the calling qbit and run, skip the user's object_storage.* + ** permissions, and may use the system-only permanent delete. + *******************************************************************************/ +public final class SystemStorage +{ + private final StorageCaller caller; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + SystemStorage(StorageCaller caller) + { + this.caller = caller; + } + + + + /******************************************************************************* + ** The bucket behind an alias, called as this system caller. + *******************************************************************************/ + public StorageBucket bucket(String alias) + { + ConfigSync.ensureSynced(); + return (new StorageBucket(alias, caller)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/app/StorageHome.java b/src/main/java/com/kingsrook/qbits/objectstorage/app/StorageHome.java new file mode 100644 index 0000000..b86254e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/app/StorageHome.java @@ -0,0 +1,471 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.app; + + +import java.math.BigDecimal; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; + + +/******************************************************************************* + ** The Storage home: full-width sections, stacked, readable at every width with + ** no inner horizontal scroll. It is the single place for storage data: every + ** number comes from the bucket rows, the object index or the operation log, + ** and every empty state says what is missing and why. Other apps link here; + ** nothing is duplicated. + *******************************************************************************/ +public final class StorageHome +{ + private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'").withZone(ZoneOffset.UTC); + + private static final String WRAP = "display:flex;flex-direction:column;gap:1.25rem;width:100%;"; + private static final String CARD = "border:1px solid rgba(127,127,127,.3);border-radius:8px;padding:1rem;min-width:0;overflow-wrap:anywhere;"; + private static final String GRID = "display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,19rem),1fr));gap:.75rem;"; + private static final String ITEM = "border:1px solid rgba(127,127,127,.25);border-radius:6px;padding:.7rem .85rem;min-width:0;overflow-wrap:anywhere;"; + private static final String PAIRS = "display:grid;grid-template-columns:minmax(6.5rem,auto) minmax(0,1fr);gap:.2rem .75rem;margin:.5rem 0 0;font-size:.88rem;"; + private static final String CHIP = "display:inline-block;border:1px solid rgba(127,127,127,.45);border-radius:999px;padding:0 .5rem;margin:0 .25rem .25rem 0;font-size:.78rem;"; + private static final String MUTED = "opacity:.75;"; + private static final String ROW = "padding:.5rem 0;border-bottom:1px solid rgba(127,127,127,.2);min-width:0;overflow-wrap:anywhere;"; + private static final String HEAD = "display:flex;justify-content:space-between;gap:.5rem;align-items:baseline;"; + + /** Important capabilities whose absence or partial support is worth a chip. */ + private static final Map CHIP_NAMES = Map.of("VERSIONING", "versioning", "LIFECYCLE", "lifecycle", "OBJECT_LOCK", "object lock", + "OBJECT_TAGGING", "tagging", "PRESIGNED_POST", "presigned POST", "CONDITIONAL_WRITE", "conditional writes"); + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private StorageHome() + { + } + + + + /******************************************************************************* + ** Render the whole home, reading the current user's view of the tables (so + ** per-bucket scoping applies). tablePathPrefix is the prefix of table and + ** record links. + *******************************************************************************/ + public static String render(String tablePathPrefix, Instant now) throws QException + { + List connections = query(ObjectStorageTables.CONNECTION, new QQueryFilter().withOrderBy(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("name"))); + List buckets = query(ObjectStorageTables.BUCKET, new QQueryFilter().withOrderBy(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("alias"))); + List caps = query(ObjectStorageTables.BUCKET_CAPABILITY, new QQueryFilter()); + List operations = query(ObjectStorageTables.OPERATION, new QQueryFilter().withOrderBy(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("occurredAt", false)).withLimit(20)); + List usage = query(ObjectStorageTables.USAGE_DAILY, new QQueryFilter().withCriteria(new QFilterCriteria("snapshotDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, + LocalDate.ofInstant(now, ZoneOffset.UTC).minusDays(30))).withOrderBy(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("snapshotDate"))); + + StringBuilder html = new StringBuilder("
"); + bucketsSection(html, buckets, connections, caps, tablePathPrefix); + healthSection(html, buckets, tablePathPrefix); + recentSection(html, operations, tablePathPrefix); + headersSection(html, buckets, tablePathPrefix); + usageSection(html, buckets, usage); + connectionsSection(html, connections, tablePathPrefix, now); + return (html.append("
").toString()); + } + + + + /******************************************************************************* + ** Query a table (empty list when the user cannot see it). + *******************************************************************************/ + private static List query(String table, QQueryFilter filter) throws QException + { + return (new QueryAction().execute(new QueryInput(table).withFilter(filter)).getRecords()); + } + + + + /******************************************************************************* + ** 1. Buckets: one card per bucket, linking to its record. Cards flow into as + ** many columns as fit, so the section reads at any width without scrolling. + *******************************************************************************/ + private static void bucketsSection(StringBuilder html, List buckets, List connections, List caps, String prefix) + { + open(html, "Buckets"); + if(buckets.isEmpty()) + { + empty(html, "No buckets are configured. Declare them in the qbit config (withBucket) and restart."); + close(html); + return; + } + Map connectionNames = new LinkedHashMap<>(); + connections.forEach(c -> connectionNames.put(c.getValue("id"), c.getValueString("name"))); + html.append("
"); + for(QRecord bucket : buckets) + { + boolean indexed = Boolean.TRUE.equals(bucket.getValueBoolean("indexed")); + html.append("
") + .append(link(prefix, ObjectStorageTables.BUCKET, bucket.getValue("id"), bucket.getValueString("alias"))).append("") + .append(esc(bucket.getValueString("role"))).append("
"); + pair(html, "Connection", esc(connectionNames.getOrDefault(bucket.getValue("connectionId"), ""))); + pair(html, "Objects", indexed ? number(bucket.getValueLong("objectCount")) : "not indexed"); + pair(html, "Size", indexed ? bytes(bucket.getValueLong("totalBytes")) : "not indexed"); + pair(html, "Last scan", bucket.getValueInstant("lastScanDate") == null ? "never" : WHEN.format(bucket.getValueInstant("lastScanDate"))); + pair(html, "Versioning", esc(bucket.getValueString("versioningState") == null ? "not checked yet" : bucket.getValueString("versioningState"))); + html.append("
"); + StringBuilder chips = new StringBuilder(); + for(QRecord cap : caps) + { + String capability = cap.getValueString("capability"); + boolean relevant = CHIP_NAMES.containsKey(capability) && (!"OBJECT_LOCK".equals(capability) || "BACKUP".equals(bucket.getValueString("role"))); + if(relevant && cap.getValueInteger("bucketId") != null && cap.getValueInteger("bucketId").equals(bucket.getValueInteger("id"))) + { + String status = cap.getValueString("status"); + String name = CHIP_NAMES.get(capability); + if("UNSUPPORTED".equals(status)) + { + chips.append("no ").append(name).append(""); + } + else if("PARTIAL".equals(status)) + { + chips.append("").append(name).append(" partial"); + } + else if("NOT_PERMITTED".equals(status)) + { + chips.append("").append(name).append(": key not permitted"); + } + } + } + if(bucket.getValueString("configDriftNote") != null) + { + chips.append("config drift"); + } + if(chips.length() > 0) + { + html.append("
").append(chips).append("
"); + } + html.append("
"); + } + html.append("
"); + close(html); + } + + + + /******************************************************************************* + ** 2. Data health: orphaned and missing objects per bucket. + *******************************************************************************/ + private static void healthSection(StringBuilder html, List buckets, String prefix) + { + open(html, "Data health"); + List tracked = buckets.stream().filter(b -> Boolean.TRUE.equals(b.getValueBoolean("referenceTracked"))).toList(); + if(tracked.isEmpty()) + { + empty(html, "No bucket tracks references yet, so orphaned and missing objects cannot be counted. Register a reference provider (for example FieldReferenceProvider) and mark the bucket referenceTracked."); + close(html); + return; + } + html.append("
"); + for(QRecord bucket : tracked) + { + boolean scanned = bucket.getValueInstant("lastReferenceScanDate") != null; + String orphanFilter = filterLink(prefix, ObjectStorageTables.OBJECT, "[{\"fieldName\":\"bucketId\",\"operator\":\"EQUALS\",\"values\":[" + bucket.getValueInteger("id") + "]}," + + "{\"fieldName\":\"referenceCount\",\"operator\":\"EQUALS\",\"values\":[0]}]"); + String missingFilter = filterLink(prefix, ObjectStorageTables.OBJECT_REFERENCE, "[{\"fieldName\":\"bucketId\",\"operator\":\"EQUALS\",\"values\":[" + bucket.getValueInteger("id") + "]}," + + "{\"fieldName\":\"missing\",\"operator\":\"EQUALS\",\"values\":[true]}]"); + html.append("
").append(link(prefix, ObjectStorageTables.BUCKET, bucket.getValue("id"), bucket.getValueString("alias"))).append("
"); + pair(html, "Orphaned", scanned ? "" + number(bucket.getValueLong("orphanedCount")) + " objects (" + bytes(bucket.getValueLong("orphanedBytes")) + ")" + : "not scanned yet"); + pair(html, "Missing", scanned ? "" + number(bucket.getValueLong("missingCount")) + " objects" : ""); + pair(html, "Last scan", scanned ? WHEN.format(bucket.getValueInstant("lastReferenceScanDate")) : "never"); + html.append("
"); + } + html.append("
"); + close(html); + } + + + + /******************************************************************************* + ** 3. Recent changes: the latest 20 operations, linking to the full table. + *******************************************************************************/ + private static void recentSection(StringBuilder html, List operations, String prefix) + { + open(html, "Recent changes"); + if(operations.isEmpty()) + { + empty(html, "No storage writes have been recorded yet. Every write from the app, an action or a scheduled job lands here."); + close(html); + return; + } + for(QRecord op : operations) + { + html.append("
").append(link(prefix, ObjectStorageTables.OPERATION, op.getValue("id"), String.valueOf(op.getValueString("action")))) + .append(" ").append(esc(op.getValueString("bucketAlias") + "/" + (op.getValueString("objectKey") == null ? "" : op.getValueString("objectKey")))).append("
").append(op.getValueInstant("occurredAt") == null ? "" : WHEN.format(op.getValueInstant("occurredAt"))).append(" · ") + .append(esc(op.getValueString("actor"))).append(" · ").append(esc(op.getValueString("result"))) + .append(op.getValueString("errorMessage") == null ? "" : ": " + esc(op.getValueString("errorMessage"))).append("
"); + } + html.append("

See every operation

"); + close(html); + } + + + + /******************************************************************************* + ** 4. Cache headers: how many indexed objects match the header policy. + *******************************************************************************/ + private static void headersSection(StringBuilder html, List buckets, String prefix) + { + open(html, "Cache headers"); + List indexed = buckets.stream().filter(b -> Boolean.TRUE.equals(b.getValueBoolean("indexed")) && !"BACKUP".equals(b.getValueString("role"))).toList(); + if(indexed.isEmpty()) + { + empty(html, "No indexed bucket, so header coverage cannot be measured."); + close(html); + return; + } + for(QRecord bucket : indexed) + { + long total = bucket.getValueLong("objectCount") == null ? 0 : bucket.getValueLong("objectCount"); + long known = bucket.getValueLong("headersKnownCount") == null ? 0 : bucket.getValueLong("headersKnownCount"); + long match = bucket.getValueLong("headersMatchingPolicyCount") == null ? 0 : bucket.getValueLong("headersMatchingPolicyCount"); + html.append("
").append(link(prefix, ObjectStorageTables.BUCKET, bucket.getValue("id"), bucket.getValueString("alias"))).append(": "); + if(bucket.getValueInstant("lastScanDate") == null) + { + html.append("not scanned yet"); + } + else + { + html.append(number(match)).append(" of ").append(number(total)).append(" objects match the header policy; headers known for ") + .append(total == 0 ? 100 : Math.round(known * 100.0 / total)).append("%"); + } + html.append("
"); + } + html.append("

To fix mismatches, run Apply header policy on the objects or a bucket. Edge caching lives in the CDN app.

"); + close(html); + } + + + + /******************************************************************************* + ** 5. Usage trend: bytes and object count per bucket over 30 days (snapshots). + *******************************************************************************/ + private static void usageSection(StringBuilder html, List buckets, List usage) + { + open(html, "Usage trend"); + Map> byBucket = new LinkedHashMap<>(); + for(QRecord row : usage) + { + byBucket.computeIfAbsent(row.getValue("bucketId"), k -> new ArrayList<>()).add(row); + } + StringBuilder trends = new StringBuilder(); + StringBuilder collecting = new StringBuilder(); + for(QRecord bucket : buckets) + { + List rows = byBucket.getOrDefault(bucket.getValue("id"), List.of()); + if(rows.size() < 2) + { + if(!rows.isEmpty()) + { + collecting.append("
").append(esc(bucket.getValueString("alias"))).append(": collecting since ") + .append(rows.get(0).getValueString("snapshotDate")).append("
"); + } + continue; + } + QRecord first = rows.get(0); + QRecord last = rows.get(rows.size() - 1); + long delta = (last.getValueLong("totalBytes") == null ? 0 : last.getValueLong("totalBytes")) - (first.getValueLong("totalBytes") == null ? 0 : first.getValueLong("totalBytes")); + long objs = (last.getValueLong("objectCount") == null ? 0 : last.getValueLong("objectCount")) - (first.getValueLong("objectCount") == null ? 0 : first.getValueLong("objectCount")); + trends.append("
").append(esc(bucket.getValueString("alias"))).append("
"); + pair(trends, "Now", number(last.getValueLong("objectCount")) + " objects, " + bytes(last.getValueLong("totalBytes"))); + pair(trends, "Since " + first.getValueString("snapshotDate"), number(first.getValueLong("objectCount")) + " objects, " + bytes(first.getValueLong("totalBytes"))); + pair(trends, "Change", (objs >= 0 ? "+" : "") + objs + " objects, " + (delta >= 0 ? "+" : "-") + bytes(Math.abs(delta))); + trends.append("
"); + } + if(trends.length() > 0) + { + html.append("
").append(trends).append("
"); + } + html.append(collecting); + if(trends.length() == 0 && collecting.length() == 0) + { + empty(html, "No usage snapshots yet. The nightly snapshot records bucket size and object count from the index; a trend appears after two days."); + } + close(html); + } + + + + /******************************************************************************* + ** 6. Connections: last test and result, with a nudge when never tested or old. + *******************************************************************************/ + private static void connectionsSection(StringBuilder html, List connections, String prefix, Instant now) + { + open(html, "Connections"); + if(connections.isEmpty()) + { + empty(html, "No connections are configured."); + close(html); + return; + } + html.append("
"); + for(QRecord c : connections) + { + Instant tested = c.getValueInstant("lastTestedAt"); + String nudge = tested == null ? "Never tested: run Test connection on the record." + : ChronoUnit.DAYS.between(tested, now) > 30 ? "Last tested more than 30 days ago." : ""; + if(c.getValueString("presignWarning") != null) + { + nudge = (nudge.isEmpty() ? "" : nudge + " ") + c.getValueString("presignWarning"); + } + html.append("
") + .append(link(prefix, ObjectStorageTables.CONNECTION, c.getValue("id"), c.getValueString("name"))).append("") + .append(esc(c.getValueString("providerType"))).append("
"); + pair(html, "Last test", tested == null ? "never" : WHEN.format(tested)); + pair(html, "Result", esc(c.getValueString("lastTestStatus") == null ? "" : c.getValueString("lastTestStatus"))); + if(!nudge.isEmpty()) + { + pair(html, "Note", esc(nudge)); + } + html.append("
"); + } + html.append("
"); + close(html); + } + + + + /******************************************************************************* + ** One label/value pair in a card. + *******************************************************************************/ + private static void pair(StringBuilder html, String label, String valueHtml) + { + html.append("
").append(esc(label)).append("
").append(valueHtml).append("
"); + } + + + + /******************************************************************************* + ** Open a card. + *******************************************************************************/ + private static void open(StringBuilder html, String title) + { + html.append("

").append(esc(title)).append("

"); + } + + + + /******************************************************************************* + ** Close a card. + *******************************************************************************/ + private static void close(StringBuilder html) + { + html.append("
"); + } + + + + /******************************************************************************* + ** An honest empty state. + *******************************************************************************/ + private static void empty(StringBuilder html, String text) + { + html.append("

").append(esc(text)).append("

"); + } + + + + /******************************************************************************* + ** A link to a record. + *******************************************************************************/ + private static String link(String prefix, String table, Object id, String text) + { + return ("" + esc(text) + ""); + } + + + + /******************************************************************************* + ** A link to a table filtered by criteria JSON. + *******************************************************************************/ + private static String filterLink(String prefix, String table, String criteriaJson) + { + String filter = "{\"criteria\":" + criteriaJson + ",\"booleanOperator\":\"AND\"}"; + return (prefix + "/" + table + "?filter=" + URLEncoder.encode(filter, StandardCharsets.UTF_8)); + } + + + + /******************************************************************************* + ** A count with thousands separators. + *******************************************************************************/ + private static String number(Long value) + { + return (value == null ? "0" : String.format("%,d", value)); + } + + + + /******************************************************************************* + ** Bytes as KB/MB/GB. + *******************************************************************************/ + static String bytes(Long value) + { + if(value == null) + { + return ("0 B"); + } + String[] units = { "B", "KB", "MB", "GB", "TB" }; + BigDecimal v = new BigDecimal(value); + int unit = 0; + while(v.compareTo(BigDecimal.valueOf(1024)) >= 0 && unit < units.length - 1) + { + v = v.divide(BigDecimal.valueOf(1024), 4, java.math.RoundingMode.HALF_UP); + unit++; + } + return (unit == 0 ? value + " B" : v.setScale(1, java.math.RoundingMode.HALF_UP) + " " + units[unit]); + } + + + + /******************************************************************************* + ** HTML-escape. + *******************************************************************************/ + private static String esc(String s) + { + return (s == null ? "" : ValueUtils.getValueAsString(s).replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/app/StorageWidgets.java b/src/main/java/com/kingsrook/qbits/objectstorage/app/StorageWidgets.java new file mode 100644 index 0000000..1c89659 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/app/StorageWidgets.java @@ -0,0 +1,138 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.app; + + +import java.time.Instant; +import java.util.List; +import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig; +import com.kingsrook.qbits.objectstorage.internal.ConfigSync; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** The Admin -> Storage app: a home widget (the single owner of storage data) + ** and the nav of the main tables. Sub-tables (capabilities, folders, versions, + ** references, usage) appear as relations and child lists on their parents, not + ** in the nav. Processes are never added to the nav. + *******************************************************************************/ +public final class StorageWidgets +{ + public static final String HOME_WIDGET = "objectStorageHome"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private StorageWidgets() + { + } + + + + /******************************************************************************* + ** The widgets. + *******************************************************************************/ + public static List widgets() + { + return (List.of(new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Overview").withType(WidgetType.HTML.getType()) + .withCodeReference(new QCodeReference(HomeRenderer.class)).withGridColumns(12).withIsCard(false).withPermissionRules(ObjectStorageTables.permissionRules()))); + } + + + + /******************************************************************************* + ** The app. Nav entries are the home and the main tables. + *******************************************************************************/ + public static QAppMetaData app(ObjectStorageQBitConfig config, List tables) + { + QAppMetaData app = new QAppMetaData() + .withName(config.getAppName()) + .withLabel(config.getAppLabel()) + .withIcon(new QIcon("inventory_2")) + .withPermissionRules(ObjectStorageTables.permissionRules()) + .withWidgets(List.of(HOME_WIDGET)) + .withSection(new QAppSection().withName("storage").withLabel("Storage").withIcon(new QIcon("inventory_2")) + .withTables(List.of(ObjectStorageTables.BUCKET, ObjectStorageTables.OBJECT, ObjectStorageTables.FOLDER, ObjectStorageTables.CONNECTION))) + .withSection(new QAppSection().withName("activity").withLabel("Activity").withIcon(new QIcon("history")) + .withTables(List.of(ObjectStorageTables.OPERATION))) + .withSection(new QAppSection().withName("rules").withLabel("Rules").withIcon(new QIcon("rule")) + .withTables(List.of(ObjectStorageTables.HEADER_RULE, ObjectStorageTables.PROTECTION_RULE))); + for(String navTable : ObjectStorageTables.NAV_TABLES) + { + for(QTableMetaData table : tables) + { + if(table.getName().equals(navTable)) + { + app.withChild(table); + } + } + } + return (app); + } + + + + /******************************************************************************* + ** The prefix of table and record links. + *******************************************************************************/ + public static String tablePathPrefix() + { + if(!ObjectStorageRuntime.isInstalled()) + { + return ("/objectStorageAdmin"); + } + ObjectStorageQBitConfig config = ObjectStorageRuntime.getConfig(); + return (config.getParentAppName() == null ? "/" + config.getAppName() : "/" + config.getParentAppName() + "/" + config.getAppName()); + } + + + + /******************************************************************************* + ** Storage home renderer. + *******************************************************************************/ + public static class HomeRenderer extends AbstractWidgetRenderer + { + /******************************************************************************* + ** Render. + *******************************************************************************/ + @Override + public RenderWidgetOutput render(RenderWidgetInput input) throws QException + { + ConfigSync.ensureSynced(); + return (new RenderWidgetOutput(new RawHTML(null, StorageHome.render(tablePathPrefix(), Instant.now())))); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/config/AddressingStyle.java b/src/main/java/com/kingsrook/qbits/objectstorage/config/AddressingStyle.java new file mode 100644 index 0000000..c135c51 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/config/AddressingStyle.java @@ -0,0 +1,31 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.config; + + +/******************************************************************************** + ** How bucket names are put in request URLs. + *******************************************************************************/ +public enum AddressingStyle +{ + PATH, + VIRTUAL_HOST +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/config/AdminApiType.java b/src/main/java/com/kingsrook/qbits/objectstorage/config/AdminApiType.java new file mode 100644 index 0000000..755b8b5 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/config/AdminApiType.java @@ -0,0 +1,32 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.config; + + +/******************************************************************************** + ** Non-S3 admin APIs for usage and quotas (off by default). + *******************************************************************************/ +public enum AdminApiType +{ + NONE, + MINIO_ADMIN, + GARAGE_ADMIN +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/config/BucketRole.java b/src/main/java/com/kingsrook/qbits/objectstorage/config/BucketRole.java new file mode 100644 index 0000000..9f33ab4 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/config/BucketRole.java @@ -0,0 +1,33 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.config; + + +/******************************************************************************** + ** What a bucket is for. BACKUP is always private and is excluded from header policy and orphan scans. + *******************************************************************************/ +public enum BucketRole +{ + PUBLIC, + PRIVATE, + INCOMING, + BACKUP +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/config/ChecksumMode.java b/src/main/java/com/kingsrook/qbits/objectstorage/config/ChecksumMode.java new file mode 100644 index 0000000..6726301 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/config/ChecksumMode.java @@ -0,0 +1,31 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.config; + + +/******************************************************************************** + ** Maps to the SDK v2 request checksum calculation and response checksum validation. + *******************************************************************************/ +public enum ChecksumMode +{ + WHEN_REQUIRED, + WHEN_SUPPORTED +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/config/CredentialsMode.java b/src/main/java/com/kingsrook/qbits/objectstorage/config/CredentialsMode.java new file mode 100644 index 0000000..03ac1cf --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/config/CredentialsMode.java @@ -0,0 +1,32 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.config; + + +/******************************************************************************** + ** Where credentials come from. + *******************************************************************************/ +public enum CredentialsMode +{ + STATIC_REFS, + DEFAULT_CHAIN, + WEB_IDENTITY +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/config/ManagedBy.java b/src/main/java/com/kingsrook/qbits/objectstorage/config/ManagedBy.java new file mode 100644 index 0000000..c307359 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/config/ManagedBy.java @@ -0,0 +1,31 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.config; + + +/******************************************************************************** + ** Who owns a bucket's configuration: infrastructure-as-code (read-only here) or this UI. + *******************************************************************************/ +public enum ManagedBy +{ + IAC, + UI +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/config/PayloadSigning.java b/src/main/java/com/kingsrook/qbits/objectstorage/config/PayloadSigning.java new file mode 100644 index 0000000..f62c3f0 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/config/PayloadSigning.java @@ -0,0 +1,31 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.config; + + +/******************************************************************************** + ** Whether request payloads are hashed and signed. + *******************************************************************************/ +public enum PayloadSigning +{ + SIGNED, + UNSIGNED_OVER_TLS +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/config/ProviderType.java b/src/main/java/com/kingsrook/qbits/objectstorage/config/ProviderType.java new file mode 100644 index 0000000..057a051 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/config/ProviderType.java @@ -0,0 +1,38 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.config; + + +/******************************************************************************** + ** S3-compatible provider kinds. Seeds default capabilities and settings; the probe overrides them. + *******************************************************************************/ +public enum ProviderType +{ + AWS_S3, + MINIO, + CLOUDFLARE_R2, + BACKBLAZE_B2, + WASABI, + DO_SPACES, + CEPH_RGW, + GARAGE, + OTHER +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/config/StorageBucketConfig.java b/src/main/java/com/kingsrook/qbits/objectstorage/config/StorageBucketConfig.java new file mode 100644 index 0000000..54271cc --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/config/StorageBucketConfig.java @@ -0,0 +1,721 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.config; + + +/******************************************************************************** + ** Configuration of one bucket alias: where it lives and what it is for. + *******************************************************************************/ +public class StorageBucketConfig +{ + private String alias; + private String connection; + private String bucketName; + private BucketRole role = BucketRole.PRIVATE; + private String publicBaseUrl; + private Boolean indexed = true; + private ManagedBy managedBy = ManagedBy.IAC; + private Boolean trashEnabled = false; + private String trashBucket; + private String trashPrefix = ".trash/"; + private Integer trashRetentionDays = 30; + private Integer maxPresignGetSeconds = 3600; + private Integer maxPresignPutSeconds = 900; + private Boolean referenceTracked = false; + private java.util.List exemptPrefixes = new java.util.ArrayList<>(); + private Long maxUploadBytes = 104_857_600L; + private Boolean protectedBucket = false; + private Boolean expectVersioning; + private Boolean expectLifecycle; + private Boolean expectCors; + private Boolean expectPolicy; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public StorageBucketConfig() + { + } + + + + /******************************************************************************** + ** Getter for alias. + *******************************************************************************/ + public String getAlias() + { + return (this.alias); + } + + + + /******************************************************************************** + ** Setter for alias. + *******************************************************************************/ + public void setAlias(String alias) + { + this.alias = alias; + } + + + + /******************************************************************************** + ** Fluent setter for alias. + *******************************************************************************/ + public StorageBucketConfig withAlias(String alias) + { + this.alias = alias; + return (this); + } + + + + /******************************************************************************** + ** Getter for connection. + *******************************************************************************/ + public String getConnection() + { + return (this.connection); + } + + + + /******************************************************************************** + ** Setter for connection. + *******************************************************************************/ + public void setConnection(String connection) + { + this.connection = connection; + } + + + + /******************************************************************************** + ** Fluent setter for connection. + *******************************************************************************/ + public StorageBucketConfig withConnection(String connection) + { + this.connection = connection; + return (this); + } + + + + /******************************************************************************** + ** Getter for bucketName. + *******************************************************************************/ + public String getBucketName() + { + return (this.bucketName); + } + + + + /******************************************************************************** + ** Setter for bucketName. + *******************************************************************************/ + public void setBucketName(String bucketName) + { + this.bucketName = bucketName; + } + + + + /******************************************************************************** + ** Fluent setter for bucketName. + *******************************************************************************/ + public StorageBucketConfig withBucketName(String bucketName) + { + this.bucketName = bucketName; + return (this); + } + + + + /******************************************************************************** + ** Getter for role. + *******************************************************************************/ + public BucketRole getRole() + { + return (this.role); + } + + + + /******************************************************************************** + ** Setter for role. + *******************************************************************************/ + public void setRole(BucketRole role) + { + this.role = role; + } + + + + /******************************************************************************** + ** Fluent setter for role. + *******************************************************************************/ + public StorageBucketConfig withRole(BucketRole role) + { + this.role = role; + return (this); + } + + + + /******************************************************************************** + ** Getter for publicBaseUrl. + *******************************************************************************/ + public String getPublicBaseUrl() + { + return (this.publicBaseUrl); + } + + + + /******************************************************************************** + ** Setter for publicBaseUrl. + *******************************************************************************/ + public void setPublicBaseUrl(String publicBaseUrl) + { + this.publicBaseUrl = publicBaseUrl; + } + + + + /******************************************************************************** + ** Fluent setter for publicBaseUrl. + *******************************************************************************/ + public StorageBucketConfig withPublicBaseUrl(String publicBaseUrl) + { + this.publicBaseUrl = publicBaseUrl; + return (this); + } + + + + /******************************************************************************** + ** Getter for indexed. + *******************************************************************************/ + public Boolean getIndexed() + { + return (this.indexed); + } + + + + /******************************************************************************** + ** Setter for indexed. + *******************************************************************************/ + public void setIndexed(Boolean indexed) + { + this.indexed = indexed; + } + + + + /******************************************************************************** + ** Fluent setter for indexed. + *******************************************************************************/ + public StorageBucketConfig withIndexed(Boolean indexed) + { + this.indexed = indexed; + return (this); + } + + + + /******************************************************************************** + ** Getter for managedBy. + *******************************************************************************/ + public ManagedBy getManagedBy() + { + return (this.managedBy); + } + + + + /******************************************************************************** + ** Setter for managedBy. + *******************************************************************************/ + public void setManagedBy(ManagedBy managedBy) + { + this.managedBy = managedBy; + } + + + + /******************************************************************************** + ** Fluent setter for managedBy. + *******************************************************************************/ + public StorageBucketConfig withManagedBy(ManagedBy managedBy) + { + this.managedBy = managedBy; + return (this); + } + + + + /******************************************************************************** + ** Getter for trashEnabled. + *******************************************************************************/ + public Boolean getTrashEnabled() + { + return (this.trashEnabled); + } + + + + /******************************************************************************** + ** Setter for trashEnabled. + *******************************************************************************/ + public void setTrashEnabled(Boolean trashEnabled) + { + this.trashEnabled = trashEnabled; + } + + + + /******************************************************************************** + ** Fluent setter for trashEnabled. + *******************************************************************************/ + public StorageBucketConfig withTrashEnabled(Boolean trashEnabled) + { + this.trashEnabled = trashEnabled; + return (this); + } + + + + /******************************************************************************** + ** Getter for trashBucket. + *******************************************************************************/ + public String getTrashBucket() + { + return (this.trashBucket); + } + + + + /******************************************************************************** + ** Setter for trashBucket. + *******************************************************************************/ + public void setTrashBucket(String trashBucket) + { + this.trashBucket = trashBucket; + } + + + + /******************************************************************************** + ** Fluent setter for trashBucket. + *******************************************************************************/ + public StorageBucketConfig withTrashBucket(String trashBucket) + { + this.trashBucket = trashBucket; + return (this); + } + + + + /******************************************************************************** + ** Getter for trashPrefix. + *******************************************************************************/ + public String getTrashPrefix() + { + return (this.trashPrefix); + } + + + + /******************************************************************************** + ** Setter for trashPrefix. + *******************************************************************************/ + public void setTrashPrefix(String trashPrefix) + { + this.trashPrefix = trashPrefix; + } + + + + /******************************************************************************** + ** Fluent setter for trashPrefix. + *******************************************************************************/ + public StorageBucketConfig withTrashPrefix(String trashPrefix) + { + this.trashPrefix = trashPrefix; + return (this); + } + + + + /******************************************************************************** + ** Getter for trashRetentionDays. + *******************************************************************************/ + public Integer getTrashRetentionDays() + { + return (this.trashRetentionDays); + } + + + + /******************************************************************************** + ** Setter for trashRetentionDays. + *******************************************************************************/ + public void setTrashRetentionDays(Integer trashRetentionDays) + { + this.trashRetentionDays = trashRetentionDays; + } + + + + /******************************************************************************** + ** Fluent setter for trashRetentionDays. + *******************************************************************************/ + public StorageBucketConfig withTrashRetentionDays(Integer trashRetentionDays) + { + this.trashRetentionDays = trashRetentionDays; + return (this); + } + + + + /******************************************************************************** + ** Getter for maxPresignGetSeconds. + *******************************************************************************/ + public Integer getMaxPresignGetSeconds() + { + return (this.maxPresignGetSeconds); + } + + + + /******************************************************************************** + ** Setter for maxPresignGetSeconds. + *******************************************************************************/ + public void setMaxPresignGetSeconds(Integer maxPresignGetSeconds) + { + this.maxPresignGetSeconds = maxPresignGetSeconds; + } + + + + /******************************************************************************** + ** Fluent setter for maxPresignGetSeconds. + *******************************************************************************/ + public StorageBucketConfig withMaxPresignGetSeconds(Integer maxPresignGetSeconds) + { + this.maxPresignGetSeconds = maxPresignGetSeconds; + return (this); + } + + + + /******************************************************************************** + ** Getter for maxPresignPutSeconds. + *******************************************************************************/ + public Integer getMaxPresignPutSeconds() + { + return (this.maxPresignPutSeconds); + } + + + + /******************************************************************************** + ** Setter for maxPresignPutSeconds. + *******************************************************************************/ + public void setMaxPresignPutSeconds(Integer maxPresignPutSeconds) + { + this.maxPresignPutSeconds = maxPresignPutSeconds; + } + + + + /******************************************************************************** + ** Fluent setter for maxPresignPutSeconds. + *******************************************************************************/ + public StorageBucketConfig withMaxPresignPutSeconds(Integer maxPresignPutSeconds) + { + this.maxPresignPutSeconds = maxPresignPutSeconds; + return (this); + } + + + + /******************************************************************************** + ** Getter for referenceTracked. + *******************************************************************************/ + public Boolean getReferenceTracked() + { + return (this.referenceTracked); + } + + + + /******************************************************************************** + ** Setter for referenceTracked. + *******************************************************************************/ + public void setReferenceTracked(Boolean referenceTracked) + { + this.referenceTracked = referenceTracked; + } + + + + /******************************************************************************** + ** Fluent setter for referenceTracked. + *******************************************************************************/ + public StorageBucketConfig withReferenceTracked(Boolean referenceTracked) + { + this.referenceTracked = referenceTracked; + return (this); + } + + + + /******************************************************************************** + ** Getter for exemptPrefixes. + *******************************************************************************/ + public java.util.List getExemptPrefixes() + { + return (this.exemptPrefixes); + } + + + + /******************************************************************************** + ** Setter for exemptPrefixes. + *******************************************************************************/ + public void setExemptPrefixes(java.util.List exemptPrefixes) + { + this.exemptPrefixes = exemptPrefixes; + } + + + + /******************************************************************************** + ** Fluent setter for exemptPrefixes. + *******************************************************************************/ + public StorageBucketConfig withExemptPrefixes(java.util.List exemptPrefixes) + { + this.exemptPrefixes = exemptPrefixes; + return (this); + } + + + + /******************************************************************************** + ** Getter for maxUploadBytes. + *******************************************************************************/ + public Long getMaxUploadBytes() + { + return (this.maxUploadBytes); + } + + + + /******************************************************************************** + ** Setter for maxUploadBytes. + *******************************************************************************/ + public void setMaxUploadBytes(Long maxUploadBytes) + { + this.maxUploadBytes = maxUploadBytes; + } + + + + /******************************************************************************** + ** Fluent setter for maxUploadBytes. + *******************************************************************************/ + public StorageBucketConfig withMaxUploadBytes(Long maxUploadBytes) + { + this.maxUploadBytes = maxUploadBytes; + return (this); + } + + + + /******************************************************************************** + ** Getter for protectedBucket. + *******************************************************************************/ + public Boolean getProtectedBucket() + { + return (this.protectedBucket); + } + + + + /******************************************************************************** + ** Setter for protectedBucket. + *******************************************************************************/ + public void setProtectedBucket(Boolean protectedBucket) + { + this.protectedBucket = protectedBucket; + } + + + + /******************************************************************************** + ** Fluent setter for protectedBucket. + *******************************************************************************/ + public StorageBucketConfig withProtectedBucket(Boolean protectedBucket) + { + this.protectedBucket = protectedBucket; + return (this); + } + + + + /******************************************************************************** + ** Getter for expectVersioning. + *******************************************************************************/ + public Boolean getExpectVersioning() + { + return (this.expectVersioning); + } + + + + /******************************************************************************** + ** Setter for expectVersioning. + *******************************************************************************/ + public void setExpectVersioning(Boolean expectVersioning) + { + this.expectVersioning = expectVersioning; + } + + + + /******************************************************************************** + ** Fluent setter for expectVersioning. + *******************************************************************************/ + public StorageBucketConfig withExpectVersioning(Boolean expectVersioning) + { + this.expectVersioning = expectVersioning; + return (this); + } + + + + /******************************************************************************** + ** Getter for expectLifecycle. + *******************************************************************************/ + public Boolean getExpectLifecycle() + { + return (this.expectLifecycle); + } + + + + /******************************************************************************** + ** Setter for expectLifecycle. + *******************************************************************************/ + public void setExpectLifecycle(Boolean expectLifecycle) + { + this.expectLifecycle = expectLifecycle; + } + + + + /******************************************************************************** + ** Fluent setter for expectLifecycle. + *******************************************************************************/ + public StorageBucketConfig withExpectLifecycle(Boolean expectLifecycle) + { + this.expectLifecycle = expectLifecycle; + return (this); + } + + + + /******************************************************************************** + ** Getter for expectCors. + *******************************************************************************/ + public Boolean getExpectCors() + { + return (this.expectCors); + } + + + + /******************************************************************************** + ** Setter for expectCors. + *******************************************************************************/ + public void setExpectCors(Boolean expectCors) + { + this.expectCors = expectCors; + } + + + + /******************************************************************************** + ** Fluent setter for expectCors. + *******************************************************************************/ + public StorageBucketConfig withExpectCors(Boolean expectCors) + { + this.expectCors = expectCors; + return (this); + } + + + + /******************************************************************************** + ** Getter for expectPolicy. + *******************************************************************************/ + public Boolean getExpectPolicy() + { + return (this.expectPolicy); + } + + + + /******************************************************************************** + ** Setter for expectPolicy. + *******************************************************************************/ + public void setExpectPolicy(Boolean expectPolicy) + { + this.expectPolicy = expectPolicy; + } + + + + /******************************************************************************** + ** Fluent setter for expectPolicy. + *******************************************************************************/ + public StorageBucketConfig withExpectPolicy(Boolean expectPolicy) + { + this.expectPolicy = expectPolicy; + return (this); + } + + + + /******************************************************************************* + ** Constructor with the alias. + *******************************************************************************/ + public StorageBucketConfig(String alias) + { + this.alias = alias; + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/config/StorageConnectionConfig.java b/src/main/java/com/kingsrook/qbits/objectstorage/config/StorageConnectionConfig.java new file mode 100644 index 0000000..5a2d485 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/config/StorageConnectionConfig.java @@ -0,0 +1,871 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.config; + + +/******************************************************************************** + ** Configuration of one S3-compatible connection. Secrets are references + ** ** (for example ${env.S3_SECRET_KEY}), never values. + *******************************************************************************/ +public class StorageConnectionConfig +{ + private String name; + private ProviderType providerType = ProviderType.OTHER; + private String endpoint; + private String presignEndpoint; + private String region; + private AddressingStyle addressingStyle; + private String signatureVersion = "V4"; + private ChecksumMode checksumMode; + private PayloadSigning payloadSigning = PayloadSigning.SIGNED; + private Boolean tlsVerify = true; + private String caBundleRef; + private CredentialsMode credentialsMode = CredentialsMode.STATIC_REFS; + private String accessKeyRef; + private String secretKeyRef; + private String sessionTokenRef; + private String presignAccessKeyRef; + private String presignSecretKeyRef; + private AdminApiType adminApiType = AdminApiType.NONE; + private Integer maxOpsPerSecond = 20; + private Integer maxConcurrency = 4; + private Integer connectTimeoutMs = 5000; + private Integer readTimeoutMs = 60000; + private Integer multipartPartSizeMb = 16; + private Long multipartCopyThresholdBytes = 5_368_709_120L; + + + + /******************************************************************************** + ** Default constructor. + *******************************************************************************/ + public StorageConnectionConfig() + { + } + + + + /******************************************************************************** + ** Getter for name. + *******************************************************************************/ + public String getName() + { + return (this.name); + } + + + + /******************************************************************************** + ** Setter for name. + *******************************************************************************/ + public void setName(String name) + { + this.name = name; + } + + + + /******************************************************************************** + ** Fluent setter for name. + *******************************************************************************/ + public StorageConnectionConfig withName(String name) + { + this.name = name; + return (this); + } + + + + /******************************************************************************** + ** Getter for providerType. + *******************************************************************************/ + public ProviderType getProviderType() + { + return (this.providerType); + } + + + + /******************************************************************************** + ** Setter for providerType. + *******************************************************************************/ + public void setProviderType(ProviderType providerType) + { + this.providerType = providerType; + } + + + + /******************************************************************************** + ** Fluent setter for providerType. + *******************************************************************************/ + public StorageConnectionConfig withProviderType(ProviderType providerType) + { + this.providerType = providerType; + return (this); + } + + + + /******************************************************************************** + ** Getter for endpoint. + *******************************************************************************/ + public String getEndpoint() + { + return (this.endpoint); + } + + + + /******************************************************************************** + ** Setter for endpoint. + *******************************************************************************/ + public void setEndpoint(String endpoint) + { + this.endpoint = endpoint; + } + + + + /******************************************************************************** + ** Fluent setter for endpoint. + *******************************************************************************/ + public StorageConnectionConfig withEndpoint(String endpoint) + { + this.endpoint = endpoint; + return (this); + } + + + + /******************************************************************************** + ** Getter for presignEndpoint. + *******************************************************************************/ + public String getPresignEndpoint() + { + return (this.presignEndpoint); + } + + + + /******************************************************************************** + ** Setter for presignEndpoint. + *******************************************************************************/ + public void setPresignEndpoint(String presignEndpoint) + { + this.presignEndpoint = presignEndpoint; + } + + + + /******************************************************************************** + ** Fluent setter for presignEndpoint. + *******************************************************************************/ + public StorageConnectionConfig withPresignEndpoint(String presignEndpoint) + { + this.presignEndpoint = presignEndpoint; + return (this); + } + + + + /******************************************************************************** + ** Getter for region. + *******************************************************************************/ + public String getRegion() + { + return (this.region); + } + + + + /******************************************************************************** + ** Setter for region. + *******************************************************************************/ + public void setRegion(String region) + { + this.region = region; + } + + + + /******************************************************************************** + ** Fluent setter for region. + *******************************************************************************/ + public StorageConnectionConfig withRegion(String region) + { + this.region = region; + return (this); + } + + + + /******************************************************************************** + ** Getter for addressingStyle. + *******************************************************************************/ + public AddressingStyle getAddressingStyle() + { + return (this.addressingStyle); + } + + + + /******************************************************************************** + ** Setter for addressingStyle. + *******************************************************************************/ + public void setAddressingStyle(AddressingStyle addressingStyle) + { + this.addressingStyle = addressingStyle; + } + + + + /******************************************************************************** + ** Fluent setter for addressingStyle. + *******************************************************************************/ + public StorageConnectionConfig withAddressingStyle(AddressingStyle addressingStyle) + { + this.addressingStyle = addressingStyle; + return (this); + } + + + + /******************************************************************************** + ** Getter for signatureVersion. + *******************************************************************************/ + public String getSignatureVersion() + { + return (this.signatureVersion); + } + + + + /******************************************************************************** + ** Setter for signatureVersion. + *******************************************************************************/ + public void setSignatureVersion(String signatureVersion) + { + this.signatureVersion = signatureVersion; + } + + + + /******************************************************************************** + ** Fluent setter for signatureVersion. + *******************************************************************************/ + public StorageConnectionConfig withSignatureVersion(String signatureVersion) + { + this.signatureVersion = signatureVersion; + return (this); + } + + + + /******************************************************************************** + ** Getter for checksumMode. + *******************************************************************************/ + public ChecksumMode getChecksumMode() + { + return (this.checksumMode); + } + + + + /******************************************************************************** + ** Setter for checksumMode. + *******************************************************************************/ + public void setChecksumMode(ChecksumMode checksumMode) + { + this.checksumMode = checksumMode; + } + + + + /******************************************************************************** + ** Fluent setter for checksumMode. + *******************************************************************************/ + public StorageConnectionConfig withChecksumMode(ChecksumMode checksumMode) + { + this.checksumMode = checksumMode; + return (this); + } + + + + /******************************************************************************** + ** Getter for payloadSigning. + *******************************************************************************/ + public PayloadSigning getPayloadSigning() + { + return (this.payloadSigning); + } + + + + /******************************************************************************** + ** Setter for payloadSigning. + *******************************************************************************/ + public void setPayloadSigning(PayloadSigning payloadSigning) + { + this.payloadSigning = payloadSigning; + } + + + + /******************************************************************************** + ** Fluent setter for payloadSigning. + *******************************************************************************/ + public StorageConnectionConfig withPayloadSigning(PayloadSigning payloadSigning) + { + this.payloadSigning = payloadSigning; + return (this); + } + + + + /******************************************************************************** + ** Getter for tlsVerify. + *******************************************************************************/ + public Boolean getTlsVerify() + { + return (this.tlsVerify); + } + + + + /******************************************************************************** + ** Setter for tlsVerify. + *******************************************************************************/ + public void setTlsVerify(Boolean tlsVerify) + { + this.tlsVerify = tlsVerify; + } + + + + /******************************************************************************** + ** Fluent setter for tlsVerify. + *******************************************************************************/ + public StorageConnectionConfig withTlsVerify(Boolean tlsVerify) + { + this.tlsVerify = tlsVerify; + return (this); + } + + + + /******************************************************************************** + ** Getter for caBundleRef. + *******************************************************************************/ + public String getCaBundleRef() + { + return (this.caBundleRef); + } + + + + /******************************************************************************** + ** Setter for caBundleRef. + *******************************************************************************/ + public void setCaBundleRef(String caBundleRef) + { + this.caBundleRef = caBundleRef; + } + + + + /******************************************************************************** + ** Fluent setter for caBundleRef. + *******************************************************************************/ + public StorageConnectionConfig withCaBundleRef(String caBundleRef) + { + this.caBundleRef = caBundleRef; + return (this); + } + + + + /******************************************************************************** + ** Getter for credentialsMode. + *******************************************************************************/ + public CredentialsMode getCredentialsMode() + { + return (this.credentialsMode); + } + + + + /******************************************************************************** + ** Setter for credentialsMode. + *******************************************************************************/ + public void setCredentialsMode(CredentialsMode credentialsMode) + { + this.credentialsMode = credentialsMode; + } + + + + /******************************************************************************** + ** Fluent setter for credentialsMode. + *******************************************************************************/ + public StorageConnectionConfig withCredentialsMode(CredentialsMode credentialsMode) + { + this.credentialsMode = credentialsMode; + return (this); + } + + + + /******************************************************************************** + ** Getter for accessKeyRef. + *******************************************************************************/ + public String getAccessKeyRef() + { + return (this.accessKeyRef); + } + + + + /******************************************************************************** + ** Setter for accessKeyRef. + *******************************************************************************/ + public void setAccessKeyRef(String accessKeyRef) + { + this.accessKeyRef = accessKeyRef; + } + + + + /******************************************************************************** + ** Fluent setter for accessKeyRef. + *******************************************************************************/ + public StorageConnectionConfig withAccessKeyRef(String accessKeyRef) + { + this.accessKeyRef = accessKeyRef; + return (this); + } + + + + /******************************************************************************** + ** Getter for secretKeyRef. + *******************************************************************************/ + public String getSecretKeyRef() + { + return (this.secretKeyRef); + } + + + + /******************************************************************************** + ** Setter for secretKeyRef. + *******************************************************************************/ + public void setSecretKeyRef(String secretKeyRef) + { + this.secretKeyRef = secretKeyRef; + } + + + + /******************************************************************************** + ** Fluent setter for secretKeyRef. + *******************************************************************************/ + public StorageConnectionConfig withSecretKeyRef(String secretKeyRef) + { + this.secretKeyRef = secretKeyRef; + return (this); + } + + + + /******************************************************************************** + ** Getter for sessionTokenRef. + *******************************************************************************/ + public String getSessionTokenRef() + { + return (this.sessionTokenRef); + } + + + + /******************************************************************************** + ** Setter for sessionTokenRef. + *******************************************************************************/ + public void setSessionTokenRef(String sessionTokenRef) + { + this.sessionTokenRef = sessionTokenRef; + } + + + + /******************************************************************************** + ** Fluent setter for sessionTokenRef. + *******************************************************************************/ + public StorageConnectionConfig withSessionTokenRef(String sessionTokenRef) + { + this.sessionTokenRef = sessionTokenRef; + return (this); + } + + + + /******************************************************************************** + ** Getter for presignAccessKeyRef. + *******************************************************************************/ + public String getPresignAccessKeyRef() + { + return (this.presignAccessKeyRef); + } + + + + /******************************************************************************** + ** Setter for presignAccessKeyRef. + *******************************************************************************/ + public void setPresignAccessKeyRef(String presignAccessKeyRef) + { + this.presignAccessKeyRef = presignAccessKeyRef; + } + + + + /******************************************************************************** + ** Fluent setter for presignAccessKeyRef. + *******************************************************************************/ + public StorageConnectionConfig withPresignAccessKeyRef(String presignAccessKeyRef) + { + this.presignAccessKeyRef = presignAccessKeyRef; + return (this); + } + + + + /******************************************************************************** + ** Getter for presignSecretKeyRef. + *******************************************************************************/ + public String getPresignSecretKeyRef() + { + return (this.presignSecretKeyRef); + } + + + + /******************************************************************************** + ** Setter for presignSecretKeyRef. + *******************************************************************************/ + public void setPresignSecretKeyRef(String presignSecretKeyRef) + { + this.presignSecretKeyRef = presignSecretKeyRef; + } + + + + /******************************************************************************** + ** Fluent setter for presignSecretKeyRef. + *******************************************************************************/ + public StorageConnectionConfig withPresignSecretKeyRef(String presignSecretKeyRef) + { + this.presignSecretKeyRef = presignSecretKeyRef; + return (this); + } + + + + /******************************************************************************** + ** Getter for adminApiType. + *******************************************************************************/ + public AdminApiType getAdminApiType() + { + return (this.adminApiType); + } + + + + /******************************************************************************** + ** Setter for adminApiType. + *******************************************************************************/ + public void setAdminApiType(AdminApiType adminApiType) + { + this.adminApiType = adminApiType; + } + + + + /******************************************************************************** + ** Fluent setter for adminApiType. + *******************************************************************************/ + public StorageConnectionConfig withAdminApiType(AdminApiType adminApiType) + { + this.adminApiType = adminApiType; + return (this); + } + + + + /******************************************************************************** + ** Getter for maxOpsPerSecond. + *******************************************************************************/ + public Integer getMaxOpsPerSecond() + { + return (this.maxOpsPerSecond); + } + + + + /******************************************************************************** + ** Setter for maxOpsPerSecond. + *******************************************************************************/ + public void setMaxOpsPerSecond(Integer maxOpsPerSecond) + { + this.maxOpsPerSecond = maxOpsPerSecond; + } + + + + /******************************************************************************** + ** Fluent setter for maxOpsPerSecond. + *******************************************************************************/ + public StorageConnectionConfig withMaxOpsPerSecond(Integer maxOpsPerSecond) + { + this.maxOpsPerSecond = maxOpsPerSecond; + return (this); + } + + + + /******************************************************************************** + ** Getter for maxConcurrency. + *******************************************************************************/ + public Integer getMaxConcurrency() + { + return (this.maxConcurrency); + } + + + + /******************************************************************************** + ** Setter for maxConcurrency. + *******************************************************************************/ + public void setMaxConcurrency(Integer maxConcurrency) + { + this.maxConcurrency = maxConcurrency; + } + + + + /******************************************************************************** + ** Fluent setter for maxConcurrency. + *******************************************************************************/ + public StorageConnectionConfig withMaxConcurrency(Integer maxConcurrency) + { + this.maxConcurrency = maxConcurrency; + return (this); + } + + + + /******************************************************************************** + ** Getter for connectTimeoutMs. + *******************************************************************************/ + public Integer getConnectTimeoutMs() + { + return (this.connectTimeoutMs); + } + + + + /******************************************************************************** + ** Setter for connectTimeoutMs. + *******************************************************************************/ + public void setConnectTimeoutMs(Integer connectTimeoutMs) + { + this.connectTimeoutMs = connectTimeoutMs; + } + + + + /******************************************************************************** + ** Fluent setter for connectTimeoutMs. + *******************************************************************************/ + public StorageConnectionConfig withConnectTimeoutMs(Integer connectTimeoutMs) + { + this.connectTimeoutMs = connectTimeoutMs; + return (this); + } + + + + /******************************************************************************** + ** Getter for readTimeoutMs. + *******************************************************************************/ + public Integer getReadTimeoutMs() + { + return (this.readTimeoutMs); + } + + + + /******************************************************************************** + ** Setter for readTimeoutMs. + *******************************************************************************/ + public void setReadTimeoutMs(Integer readTimeoutMs) + { + this.readTimeoutMs = readTimeoutMs; + } + + + + /******************************************************************************** + ** Fluent setter for readTimeoutMs. + *******************************************************************************/ + public StorageConnectionConfig withReadTimeoutMs(Integer readTimeoutMs) + { + this.readTimeoutMs = readTimeoutMs; + return (this); + } + + + + /******************************************************************************** + ** Getter for multipartPartSizeMb. + *******************************************************************************/ + public Integer getMultipartPartSizeMb() + { + return (this.multipartPartSizeMb); + } + + + + /******************************************************************************** + ** Setter for multipartPartSizeMb. + *******************************************************************************/ + public void setMultipartPartSizeMb(Integer multipartPartSizeMb) + { + this.multipartPartSizeMb = multipartPartSizeMb; + } + + + + /******************************************************************************** + ** Fluent setter for multipartPartSizeMb. + *******************************************************************************/ + public StorageConnectionConfig withMultipartPartSizeMb(Integer multipartPartSizeMb) + { + this.multipartPartSizeMb = multipartPartSizeMb; + return (this); + } + + + + /******************************************************************************** + ** Getter for multipartCopyThresholdBytes. + *******************************************************************************/ + public Long getMultipartCopyThresholdBytes() + { + return (this.multipartCopyThresholdBytes); + } + + + + /******************************************************************************** + ** Setter for multipartCopyThresholdBytes. + *******************************************************************************/ + public void setMultipartCopyThresholdBytes(Long multipartCopyThresholdBytes) + { + this.multipartCopyThresholdBytes = multipartCopyThresholdBytes; + } + + + + /******************************************************************************** + ** Fluent setter for multipartCopyThresholdBytes. + *******************************************************************************/ + public StorageConnectionConfig withMultipartCopyThresholdBytes(Long multipartCopyThresholdBytes) + { + this.multipartCopyThresholdBytes = multipartCopyThresholdBytes; + return (this); + } + + + + /******************************************************************************* + ** Constructor with the connection name. + *******************************************************************************/ + public StorageConnectionConfig(String name) + { + this.name = name; + } + + + + /******************************************************************************* + ** The addressing style, defaulting by provider (path for self-hosted stores). + *******************************************************************************/ + public AddressingStyle effectiveAddressingStyle() + { + if(addressingStyle != null) + { + return (addressingStyle); + } + return (switch(providerType) + { + case AWS_S3, CLOUDFLARE_R2, BACKBLAZE_B2, WASABI, DO_SPACES -> AddressingStyle.VIRTUAL_HOST; + default -> AddressingStyle.PATH; + }); + } + + + + /******************************************************************************* + ** The checksum mode, defaulting to WHEN_REQUIRED for every provider except AWS. + *******************************************************************************/ + public ChecksumMode effectiveChecksumMode() + { + if(checksumMode != null) + { + return (checksumMode); + } + return (providerType == ProviderType.AWS_S3 ? ChecksumMode.WHEN_SUPPORTED : ChecksumMode.WHEN_REQUIRED); + } + + + + /******************************************************************************* + ** Whether a separate presign key is configured. + *******************************************************************************/ + public boolean hasPresignKey() + { + return (presignAccessKeyRef != null && presignSecretKeyRef != null); + } + + + + /******************************************************************************* + ** Redacted: only the name and non-secret settings. + *******************************************************************************/ + @Override + public String toString() + { + return ("StorageConnectionConfig{name=" + name + ", providerType=" + providerType + ", endpoint=" + endpoint + ", credentials=[redacted]}"); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/index/FolderIndex.java b/src/main/java/com/kingsrook/qbits/objectstorage/index/FolderIndex.java new file mode 100644 index 0000000..dc6a21a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/index/FolderIndex.java @@ -0,0 +1,242 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.index; + + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.objectstorage.internal.Db; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** The folder tree of an indexed bucket: one object_storage_folder row per + ** prefix, with parentFolderId giving the tree and object count, bytes and + ** newest change rolled up (a folder counts everything beneath it). + *******************************************************************************/ +public final class FolderIndex +{ + private final Integer bucketId; + private final Map idByPath = new HashMap<>(); + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + private FolderIndex(Integer bucketId) + { + this.bucketId = bucketId; + } + + + + /******************************************************************************* + ** An index with nothing cached: each folder is looked up on demand (for + ** one-off write-through, where loading the whole tree would be wasteful). + *******************************************************************************/ + public static FolderIndex empty(Integer bucketId) + { + return (new FolderIndex(bucketId)); + } + + + + /******************************************************************************* + ** Load every existing folder of the bucket (cheap next to the object rows). + *******************************************************************************/ + public static FolderIndex load(Integer bucketId) throws QException + { + FolderIndex index = new FolderIndex(bucketId); + Db.forEachPage(ObjectStorageTables.FOLDER, new QQueryFilter(Db.eq("bucketId", bucketId)), 5000, rows -> + { + for(QRecord row : rows) + { + index.idByPath.put(row.getValueString("path"), row.getValueInteger("id")); + } + }); + return (index); + } + + + + /******************************************************************************* + ** The folder id of a key's parent prefix, creating the chain of folders as + ** needed. Null for a key at the root. + *******************************************************************************/ + public Integer ensureFor(String key) throws QException + { + Integer parentId = null; + int slash = key.indexOf('/'); + while(slash >= 0) + { + String path = key.substring(0, slash + 1); + Integer id = idByPath.get(path); + if(id == null) + { + QRecord existing = Db.first(ObjectStorageTables.FOLDER, "bucketId", bucketId, "path", path); + if(existing != null) + { + id = existing.getValueInteger("id"); + } + else + { + String trimmed = path.substring(0, path.length() - 1); + id = Db.insert(ObjectStorageTables.FOLDER, new QRecord().withValue("bucketId", bucketId).withValue("parentFolderId", parentId).withValue("path", path) + .withValue("name", trimmed.substring(trimmed.lastIndexOf('/') + 1)).withValue("objectCount", 0L).withValue("totalBytes", 0L)).getValueInteger("id"); + } + idByPath.put(path, id); + } + parentId = id; + slash = key.indexOf('/', slash + 1); + } + return (parentId); + } + + + + /******************************************************************************* + ** The id of a folder path, or null. + *******************************************************************************/ + public Integer idOf(String path) + { + return (idByPath.get(path)); + } + + + + /******************************************************************************* + ** Folder counts and sizes, direct (per folder) and the grand total. + *******************************************************************************/ + public static class Rollup + { + final Map direct = new HashMap<>(); + final Map newest = new HashMap<>(); + long totalCount = 0; + long totalBytes = 0; + + + + /******************************************************************************* + ** Add one live object. + *******************************************************************************/ + public void add(Integer folderId, Long size, Instant lastModified) + { + totalCount++; + totalBytes += size == null ? 0 : size; + if(folderId != null) + { + long[] c = direct.computeIfAbsent(folderId, k -> new long[2]); + c[0]++; + c[1] += size == null ? 0 : size; + if(lastModified != null && (newest.get(folderId) == null || lastModified.isAfter(newest.get(folderId)))) + { + newest.put(folderId, lastModified); + } + } + } + } + + + + /******************************************************************************* + ** Write every folder's rolled-up totals: a folder counts everything beneath it. + *******************************************************************************/ + public static void writeRollups(Integer bucketId, Rollup rollup) throws QException + { + Map parentOf = new HashMap<>(); + List ids = new ArrayList<>(); + Db.forEachPage(ObjectStorageTables.FOLDER, new QQueryFilter(Db.eq("bucketId", bucketId)), 5000, rows -> + { + for(QRecord row : rows) + { + parentOf.put(row.getValueInteger("id"), row.getValueInteger("parentFolderId")); + ids.add(row.getValueInteger("id")); + } + }); + Map total = new HashMap<>(); + Map newest = new HashMap<>(); + for(Map.Entry entry : rollup.direct.entrySet()) + { + Instant when = rollup.newest.get(entry.getKey()); + for(Integer id = entry.getKey(); id != null; id = parentOf.get(id)) + { + long[] t = total.computeIfAbsent(id, k -> new long[2]); + t[0] += entry.getValue()[0]; + t[1] += entry.getValue()[1]; + if(when != null && (newest.get(id) == null || when.isAfter(newest.get(id)))) + { + newest.put(id, when); + } + } + } + List updates = new ArrayList<>(); + for(Integer id : ids) + { + long[] t = total.getOrDefault(id, new long[2]); + updates.add(new QRecord().withValue("id", id).withValue("objectCount", t[0]).withValue("totalBytes", t[1]).withValue("newestChange", newest.get(id))); + } + for(int i = 0; i < updates.size(); i += 1000) + { + Db.update(ObjectStorageTables.FOLDER, updates.subList(i, Math.min(updates.size(), i + 1000))); + } + } + + + + /******************************************************************************* + ** Apply a delta to every ancestor folder of a key (write-through), and to the + ** bucket totals. + *******************************************************************************/ + public static void applyDelta(Integer bucketId, String key, long countDelta, long bytesDelta, Instant newest) throws QException + { + int slash = key.indexOf('/'); + while(slash >= 0) + { + QRecord folder = Db.first(ObjectStorageTables.FOLDER, "bucketId", bucketId, "path", key.substring(0, slash + 1)); + if(folder != null) + { + QRecord update = new QRecord().withValue("id", folder.getValue("id")) + .withValue("objectCount", Math.max(0, (folder.getValueLong("objectCount") == null ? 0 : folder.getValueLong("objectCount")) + countDelta)) + .withValue("totalBytes", Math.max(0, (folder.getValueLong("totalBytes") == null ? 0 : folder.getValueLong("totalBytes")) + bytesDelta)); + if(newest != null && (folder.getValueInstant("newestChange") == null || newest.isAfter(folder.getValueInstant("newestChange")))) + { + update.withValue("newestChange", newest); + } + Db.update(ObjectStorageTables.FOLDER, List.of(update)); + } + slash = key.indexOf('/', slash + 1); + } + QRecord bucket = Db.first(ObjectStorageTables.BUCKET, "id", bucketId); + if(bucket != null) + { + Db.update(ObjectStorageTables.BUCKET, List.of(new QRecord().withValue("id", bucketId) + .withValue("objectCount", Math.max(0, (bucket.getValueLong("objectCount") == null ? 0 : bucket.getValueLong("objectCount")) + countDelta)) + .withValue("totalBytes", Math.max(0, (bucket.getValueLong("totalBytes") == null ? 0 : bucket.getValueLong("totalBytes")) + bytesDelta)))); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/index/ObjectIndex.java b/src/main/java/com/kingsrook/qbits/objectstorage/index/ObjectIndex.java new file mode 100644 index 0000000..1dd9076 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/index/ObjectIndex.java @@ -0,0 +1,636 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.index; + + +import java.io.Serializable; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.objectstorage.api.ListRequest; +import com.kingsrook.qbits.objectstorage.api.ObjectHeaders; +import com.kingsrook.qbits.objectstorage.api.ObjectStorage; +import com.kingsrook.qbits.objectstorage.api.ObjectSummary; +import com.kingsrook.qbits.objectstorage.api.Page; +import com.kingsrook.qbits.objectstorage.api.StorageBucket; +import com.kingsrook.qbits.objectstorage.api.StoredObject; +import com.kingsrook.qbits.objectstorage.config.BucketRole; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.internal.ConfigSync; +import com.kingsrook.qbits.objectstorage.internal.Db; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** The object index: a real QQQ table (object_storage_object) kept in step with + ** a bucket, because S3 listing is a forward-only cursor with no server-side + ** sort, filter, count or page N. + ** + ** - Full scan: streams ListObjectsV2 pages, upserts rows in batches, marks each + ** row with the scan generation, and at the end removes rows not seen, with a + ** safety stop when more than a configurable share would go (usually a wrong + ** prefix or credentials). The continuation token is saved on the bucket row + ** so a large scan resumes after a restart. + ** - HEAD backlog: objects whose headers are unknown get HEADed under the rate + ** limit, oldest first. + ** - Write-through: see ObjectIndexHook. An index failure never fails a write. + ** - Drift: writes made outside QQQ appear at the next scan. + ** + ** Deleted objects of a versioned or trash-enabled bucket are kept as rows with + ** deleted=true (so Restore can find them) until the trash retention passes. + *******************************************************************************/ +public final class ObjectIndex +{ + private static final QLogger LOG = QLogger.getLogger(ObjectIndex.class); + + public static final int PAGE_SIZE = 1000; + + private static final Set RUNNING = java.util.concurrent.ConcurrentHashMap.newKeySet(); + + /** Test seam: simulate a crash after this many pages of a scan (so resume can be tested). */ + static volatile Integer failAfterPages = null; + + + + /******************************************************************************* + ** The outcome of a scan. + *******************************************************************************/ + public record ScanResult(String status, long seen, long added, long updated, long removed, long markedDeleted, int pages, String note) + { + } + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private ObjectIndex() + { + } + + + + /******************************************************************************* + ** Whether a scan of the bucket is running in this app instance. + *******************************************************************************/ + public static boolean isScanning(String alias) + { + return (RUNNING.contains(alias)); + } + + + + /******************************************************************************* + ** Scan a bucket (at most one running scan per bucket). Never throws for a + ** provider error: the result carries the status and the bucket row says so. + *******************************************************************************/ + public static ScanResult scan(String alias) throws QException + { + StorageBucketConfig config = ObjectStorageRuntime.bucketConfig(alias); + if(!Boolean.TRUE.equals(config.getIndexed())) + { + return (new ScanResult("NOT_INDEXED", 0, 0, 0, 0, 0, 0, "Bucket " + alias + " is not indexed")); + } + if(!RUNNING.add(alias)) + { + return (new ScanResult("ALREADY_RUNNING", 0, 0, 0, 0, 0, 0, "A scan of " + alias + " is already running")); + } + try + { + return (Db.asSystem(() -> doScan(config))); + } + catch(QException | RuntimeException e) + { + markBucket(config, "FAILED", null, null); + ObjectStorageRuntime.alertSink().raise("storage.scan.stale", "bucket:" + alias, "warning", "The scan of " + alias + " failed: " + e.getMessage(), Map.of("bucket", alias)); + throw (e); + } + finally + { + RUNNING.remove(alias); + } + } + + + + /******************************************************************************* + ** The scan itself (runs as the system user). + *******************************************************************************/ + private static ScanResult doScan(StorageBucketConfig config) throws QException + { + String alias = config.getAlias(); + Integer bucketId = requireBucketId(alias); + QRecord bucket = Db.first(ObjectStorageTables.BUCKET, "id", bucketId); + boolean resume = ("RUNNING".equals(bucket.getValueString("lastScanStatus")) || "FAILED".equals(bucket.getValueString("lastScanStatus"))) && StringUtils.hasContent(bucket.getValueString("scanCursor")); + long gen = bucket.getValueLong("scanGeneration") == null ? 1 : bucket.getValueLong("scanGeneration") + (resume ? 0 : 1); + String token = resume ? bucket.getValueString("scanCursor") : null; + long existing = Db.count(ObjectStorageTables.OBJECT, new QQueryFilter(Db.eq("bucketId", bucketId), Db.eq("deleted", false))); + + Db.update(ObjectStorageTables.BUCKET, List.of(new QRecord().withValue("id", bucketId).withValue("lastScanStatus", "RUNNING").withValue("scanGeneration", gen) + .withValue("scanCursor", token))); + + StorageBucket storage = ObjectStorage.asSystem("object-storage", "scan-" + alias).bucket(alias); + FolderIndex folders = FolderIndex.load(bucketId); + long seen = 0; + long added = 0; + long updated = 0; + int pages = 0; + boolean capped = false; + String fatal = null; + Instant now = ObjectStorageRuntime.clock().instant(); + + do + { + Page page; + try + { + page = storage.list(ListRequest.prefix("").pageSize(PAGE_SIZE).after(token)); + } + catch(RuntimeException e) + { + fatal = e.getMessage(); + break; + } + pages++; + long[] counts = upsertPage(bucketId, gen, page.getItems(), folders, now); + added += counts[0]; + updated += counts[1]; + seen += page.getItems().size(); + token = page.getNextToken(); + Db.update(ObjectStorageTables.BUCKET, List.of(new QRecord().withValue("id", bucketId).withValue("scanCursor", token))); + if(failAfterPages != null && pages >= failAfterPages && token != null) + { + failAfterPages = null; + throw (new IllegalStateException("Simulated crash during a scan")); + } + if(seen > ObjectStorageRuntime.getConfig().getIndexMaxKeys()) + { + capped = true; + break; + } + } + while(token != null); + + if(fatal != null) + { + markBucket(config, "FAILED", gen, null); + ObjectStorageRuntime.alertSink().raise("storage.scan.stale", "bucket:" + alias, "warning", "The scan of " + alias + " failed: " + fatal, Map.of("bucket", alias)); + return (new ScanResult("FAILED", seen, added, updated, 0, 0, pages, fatal)); + } + if(capped) + { + markBucket(config, "CAP_REACHED", gen, null); + return (new ScanResult("CAP_REACHED", seen, added, updated, 0, 0, pages, "More than " + ObjectStorageRuntime.getConfig().getIndexMaxKeys() + " keys: the index stopped at the cap")); + } + + // the listing completed: handle rows that were not seen + List unseenIds = new ArrayList<>(); + List toMark = new ArrayList<>(); + boolean keepDeleted = isSoftDeleteBucket(config); + QQueryFilter unseenFilter = new QQueryFilter(Db.eq("bucketId", bucketId), Db.eq("deleted", false), new QFilterCriteria("scanGeneration", QCriteriaOperator.LESS_THAN, gen)); + Db.forEachPage(ObjectStorageTables.OBJECT, unseenFilter, 5000, rows -> rows.forEach(row -> unseenIds.add(row.getValueInteger("id")))); + double share = ObjectStorageRuntime.getConfig().getScanRemovalSafetyShare(); + if(existing > 20 && unseenIds.size() > existing * share) + { + String note = "The scan would remove " + unseenIds.size() + " of " + existing + " indexed objects (more than " + Math.round(share * 100) + + "%), which usually means a wrong prefix or credentials. Nothing was removed."; + markBucket(config, "STOPPED_SAFETY", gen, null); + ObjectStorageRuntime.alertSink().raise("storage.scan.stale", "bucket:" + alias, "warning", note, Map.of("bucket", alias)); + recomputeStats(config); + return (new ScanResult("STOPPED_SAFETY", seen, added, updated, 0, 0, pages, note)); + } + long removed = 0; + long marked = 0; + if(keepDeleted) + { + for(Serializable id : unseenIds) + { + toMark.add(new QRecord().withValue("id", id).withValue("deleted", true).withValue("indexedAt", now)); + } + for(int i = 0; i < toMark.size(); i += PAGE_SIZE) + { + Db.update(ObjectStorageTables.OBJECT, toMark.subList(i, Math.min(toMark.size(), i + PAGE_SIZE))); + } + marked = toMark.size(); + } + else + { + for(int i = 0; i < unseenIds.size(); i += PAGE_SIZE) + { + Db.delete(ObjectStorageTables.OBJECT, unseenIds.subList(i, Math.min(unseenIds.size(), i + PAGE_SIZE))); + } + removed = unseenIds.size(); + } + if(keepDeleted) + { + removed += purgeExpiredDeletedRows(config, bucketId, now); + } + + markBucket(config, "OK", gen, now); + recomputeStats(config); + ObjectStorageRuntime.alertSink().resolve("storage.scan.stale", "bucket:" + alias); + return (new ScanResult("OK", seen, added, updated, removed, marked, pages, null)); + } + + + + /******************************************************************************* + ** Upsert one page of listed objects; returns {added, updated}. + *******************************************************************************/ + private static long[] upsertPage(Integer bucketId, long gen, List items, FolderIndex folders, Instant now) throws QException + { + if(items.isEmpty()) + { + return (new long[] { 0, 0 }); + } + List keys = new ArrayList<>(); + items.forEach(i -> keys.add(i.getKey())); + Map existing = new HashMap<>(); + for(QRecord row : Db.query(ObjectStorageTables.OBJECT, new QQueryFilter(Db.eq("bucketId", bucketId), new QFilterCriteria("objectKey", QCriteriaOperator.IN, keys)))) + { + existing.put(row.getValueString("objectKey"), row); + } + List inserts = new ArrayList<>(); + List updates = new ArrayList<>(); + for(ObjectSummary s : items) + { + Integer folderId = folders.ensureFor(s.getKey()); + QRecord row = existing.get(s.getKey()); + String etag = cleanEtag(s.getEtag()); + if(row == null) + { + inserts.add(newRow(bucketId, s.getKey(), folderId, s.getSize(), etag, s.getLastModified(), s.getStorageClass(), gen, now)); + } + else + { + boolean changed = !java.util.Objects.equals(row.getValueLong("size"), s.getSize()) || !java.util.Objects.equals(row.getValueString("etag"), etag) + || !java.util.Objects.equals(seconds(row.getValueInstant("lastModified")), seconds(s.getLastModified())); + QRecord update = new QRecord().withValue("id", row.getValue("id")).withValue("folderId", folderId).withValue("size", s.getSize()).withValue("etag", etag) + .withValue("lastModified", s.getLastModified()).withValue("storageClass", s.getStorageClass()).withValue("scanGeneration", gen).withValue("indexedAt", now) + .withValue("deleted", false); + if(changed) + { + update.withValue("headersKnown", false); + } + updates.add(update); + } + } + if(!inserts.isEmpty()) + { + Db.insert(ObjectStorageTables.OBJECT, inserts); + } + if(!updates.isEmpty()) + { + Db.update(ObjectStorageTables.OBJECT, updates); + } + return (new long[] { inserts.size(), updates.size() }); + } + + + + /******************************************************************************* + ** A new object row. + *******************************************************************************/ + static QRecord newRow(Integer bucketId, String key, Integer folderId, Long size, String etag, Instant lastModified, String storageClass, Long gen, Instant now) + { + String name = key.substring(key.lastIndexOf('/') + 1); + int dot = name.lastIndexOf('.'); + return (new QRecord().withValue("bucketId", bucketId).withValue("folderId", folderId).withValue("objectKey", key).withValue("name", name) + .withValue("extension", dot > 0 ? name.substring(dot + 1).toLowerCase() : null).withValue("size", size).withValue("etag", etag).withValue("lastModified", lastModified) + .withValue("storageClass", storageClass).withValue("referenceCount", 0).withValue("headersKnown", false).withValue("deleted", false) + .withValue("scanGeneration", gen).withValue("indexedAt", now)); + } + + + + /******************************************************************************* + ** An instant to whole seconds (a listing and a HEAD report different precision). + *******************************************************************************/ + static Instant seconds(Instant instant) + { + return (instant == null ? null : instant.truncatedTo(java.time.temporal.ChronoUnit.SECONDS)); + } + + + + /******************************************************************************* + ** The ETag without its quotes. + *******************************************************************************/ + static String cleanEtag(String etag) + { + return (etag == null ? null : etag.replace("\"", "")); + } + + + + /******************************************************************************* + ** Whether deleting from the bucket is reversible (versioning or trash), in + ** which case unseen rows are kept as deleted rows so Restore can find them. + *******************************************************************************/ + static boolean isSoftDeleteBucket(StorageBucketConfig config) + { + if(Boolean.TRUE.equals(config.getTrashEnabled())) + { + return (true); + } + try + { + return (ObjectStorage.asSystem("object-storage", "index").bucket(config.getAlias()).isVersioned()); + } + catch(RuntimeException e) + { + return (false); + } + } + + + + /******************************************************************************* + ** Remove deleted rows older than the trash retention. + *******************************************************************************/ + private static long purgeExpiredDeletedRows(StorageBucketConfig config, Integer bucketId, Instant now) throws QException + { + Instant cutoff = now.minus(java.time.Duration.ofDays(config.getTrashRetentionDays())); + List ids = new ArrayList<>(); + Db.forEachPage(ObjectStorageTables.OBJECT, new QQueryFilter(Db.eq("bucketId", bucketId), Db.eq("deleted", true), new QFilterCriteria("indexedAt", QCriteriaOperator.LESS_THAN, cutoff)), 5000, + rows -> rows.forEach(r -> ids.add(r.getValueInteger("id")))); + for(int i = 0; i < ids.size(); i += PAGE_SIZE) + { + Db.delete(ObjectStorageTables.OBJECT, ids.subList(i, Math.min(ids.size(), i + PAGE_SIZE))); + } + return (ids.size()); + } + + + + /******************************************************************************* + ** Set the scan status on the bucket row. + *******************************************************************************/ + private static void markBucket(StorageBucketConfig config, String status, Long gen, Instant completedAt) + { + try + { + Integer id = ConfigSync.bucketId(config.getAlias()); + if(id != null) + { + QRecord update = new QRecord().withValue("id", id).withValue("lastScanStatus", status); + if(completedAt != null) + { + update.withValue("lastScanDate", completedAt).withValue("scanCursor", null); + } + Db.asSystem(() -> Db.update(ObjectStorageTables.BUCKET, List.of(update))); + } + } + catch(Exception e) + { + LOG.warn("Could not update the scan status", e, logPair("bucket", config.getAlias())); + } + } + + + + /******************************************************************************* + ** Recompute the folder rollups and the bucket totals and header coverage from + ** the object rows. + *******************************************************************************/ + public static void recomputeStats(StorageBucketConfig config) throws QException + { + Integer bucketId = requireBucketId(config.getAlias()); + Db.asSystem(() -> + { + FolderIndex.Rollup rollup = new FolderIndex.Rollup(); + Db.forEachPage(ObjectStorageTables.OBJECT, new QQueryFilter(Db.eq("bucketId", bucketId), Db.eq("deleted", false)), 5000, rows -> + { + for(QRecord row : rows) + { + rollup.add(row.getValueInteger("folderId"), row.getValueLong("size"), row.getValueInstant("lastModified")); + } + }); + FolderIndex.writeRollups(bucketId, rollup); + long known = Db.count(ObjectStorageTables.OBJECT, new QQueryFilter(Db.eq("bucketId", bucketId), Db.eq("deleted", false), Db.eq("headersKnown", true))); + long match = Db.count(ObjectStorageTables.OBJECT, new QQueryFilter(Db.eq("bucketId", bucketId), Db.eq("deleted", false), Db.eq("matchesHeaderPolicy", true))); + Db.update(ObjectStorageTables.BUCKET, List.of(new QRecord().withValue("id", bucketId).withValue("objectCount", rollup.totalCount).withValue("totalBytes", rollup.totalBytes) + .withValue("headersKnownCount", known).withValue("headersMatchingPolicyCount", match))); + return (null); + }); + } + + + + /******************************************************************************* + ** HEAD the objects whose headers are unknown (oldest first), up to max, and + ** record their headers and whether they match the header policy. An object + ** that no longer exists is marked deleted. + *******************************************************************************/ + public static int headBacklog(String alias, int max) throws QException + { + StorageBucketConfig config = ObjectStorageRuntime.bucketConfig(alias); + if(!Boolean.TRUE.equals(config.getIndexed())) + { + return (0); + } + Integer bucketId = requireBucketId(alias); + return (Db.asSystem(() -> + { + List todo = new ArrayList<>(); + QQueryFilter filter = new QQueryFilter(Db.eq("bucketId", bucketId), Db.eq("deleted", false), Db.eq("headersKnown", false)); + filter.setLimit(max); + filter.withOrderBy(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("lastModified")); + todo.addAll(Db.query(ObjectStorageTables.OBJECT, filter)); + StorageBucket storage = ObjectStorage.asSystem("object-storage", "head-" + alias).bucket(alias); + List updates = new ArrayList<>(); + for(QRecord row : todo) + { + QRecord update = refreshRow(config, storage, row); + if(update != null) + { + updates.add(update); + } + } + for(int i = 0; i < updates.size(); i += PAGE_SIZE) + { + Db.update(ObjectStorageTables.OBJECT, updates.subList(i, Math.min(updates.size(), i + PAGE_SIZE))); + } + if(!updates.isEmpty()) + { + recomputeStats(config); + } + return (updates.size()); + })); + } + + + + /******************************************************************************* + ** HEAD the given object rows and refresh them in the index ("Refresh from + ** bucket"). Returns {refreshed, markedDeleted, unreadable}. + *******************************************************************************/ + public static int[] refresh(String alias, List rows) throws QException + { + StorageBucketConfig config = ObjectStorageRuntime.bucketConfig(alias); + StorageBucket storage = ObjectStorage.asSystem("object-storage", "refresh-" + alias).bucket(alias); + return (Db.asSystem(() -> + { + int[] counts = new int[3]; + List updates = new ArrayList<>(); + for(QRecord row : rows) + { + QRecord update = refreshRow(config, storage, row); + if(update == null) + { + counts[2]++; + } + else + { + updates.add(update); + counts[Boolean.TRUE.equals(update.getValueBoolean("deleted")) ? 1 : 0]++; + } + } + for(int i = 0; i < updates.size(); i += PAGE_SIZE) + { + Db.update(ObjectStorageTables.OBJECT, updates.subList(i, Math.min(updates.size(), i + PAGE_SIZE))); + } + return (counts); + })); + } + + + + /******************************************************************************* + ** HEAD one object row and build the update (null when the HEAD failed). + *******************************************************************************/ + static QRecord refreshRow(StorageBucketConfig config, StorageBucket storage, QRecord row) + { + String key = row.getValueString("objectKey"); + try + { + java.util.Optional head = storage.head(key); + if(head.isEmpty()) + { + return (new QRecord().withValue("id", row.getValue("id")).withValue("deleted", true).withValue("indexedAt", ObjectStorageRuntime.clock().instant())); + } + return (headersUpdate(config, row.getValue("id"), head.get())); + } + catch(RuntimeException e) + { + LOG.warn("Could not HEAD an object to refresh the index", e, logPair("key", key)); + return (null); + } + } + + + + /******************************************************************************* + ** The row update for a fully known object (HEAD result or write-through). + *******************************************************************************/ + static QRecord headersUpdate(StorageBucketConfig config, Serializable id, StoredObject o) + { + ObjectHeaders headers = o.getHeaders() == null ? new ObjectHeaders() : o.getHeaders(); + QRecord update = new QRecord().withValue("id", id).withValue("size", o.getSize()).withValue("etag", cleanEtag(o.getEtag())).withValue("lastModified", o.getLastModified()) + .withValue("storageClass", o.getStorageClass()).withValue("contentType", headers.getContentType()).withValue("cacheControl", headers.getCacheControl()) + .withValue("contentDisposition", headers.getContentDisposition()) + .withValue("userMetadata", headers.getUserMetadata() == null || headers.getUserMetadata().isEmpty() ? null : JsonUtils.toJson(new LinkedHashMap<>(headers.getUserMetadata()))) + .withValue("versionId", o.getVersionId()).withValue("headersKnown", true).withValue("deleted", false).withValue("matchesHeaderPolicy", matchesPolicy(config, o.getKey(), headers)) + .withValue("indexedAt", ObjectStorageRuntime.clock().instant()); + return (update); + } + + + + /******************************************************************************* + ** Whether the observed headers equal what the policy says (no opinion counts as + ** a match; BACKUP buckets are outside the policy). + *******************************************************************************/ + public static boolean matchesPolicy(StorageBucketConfig config, String key, ObjectHeaders observed) + { + if(config.getRole() == BucketRole.BACKUP) + { + return (true); + } + ObjectHeaders wanted = ObjectStorageRuntime.headerPolicy().headersFor(config, key, observed.getContentType()); + if(wanted == null || (wanted.getCacheControl() == null && wanted.getContentDisposition() == null)) + { + return (true); + } + return (wanted.getCacheControl() == null || java.util.Objects.equals(wanted.getCacheControl().trim(), observed.getCacheControl() == null ? null : observed.getCacheControl().trim())) + && (wanted.getContentDisposition() == null || java.util.Objects.equals(wanted.getContentDisposition().trim(), observed.getContentDisposition() == null ? null : observed.getContentDisposition().trim())); + } + + + + /******************************************************************************* + ** The bucket's row id, failing clearly when the tables are not in the context. + *******************************************************************************/ + static Integer requireBucketId(String alias) + { + Integer id = ConfigSync.bucketId(alias); + if(id == null) + { + throw (new IllegalStateException("The bucket " + alias + " has no row: the storage tables are not available in this context")); + } + return (id); + } + + + + /******************************************************************************* + ** The distinct aliases of buckets that are indexed. + *******************************************************************************/ + public static List indexedAliases() + { + List aliases = new ArrayList<>(); + for(StorageBucketConfig bucket : ObjectStorageRuntime.getConfig().getBuckets()) + { + if(Boolean.TRUE.equals(bucket.getIndexed())) + { + aliases.add(bucket.getAlias()); + } + } + return (aliases); + } + + + + /******************************************************************************* + ** Keys of a set, for tests. + *******************************************************************************/ + static Set keysOf(List rows) + { + Set keys = new HashSet<>(); + rows.forEach(r -> keys.add(r.getValueString("objectKey"))); + return (keys); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/index/ObjectIndexHook.java b/src/main/java/com/kingsrook/qbits/objectstorage/index/ObjectIndexHook.java new file mode 100644 index 0000000..e140677 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/index/ObjectIndexHook.java @@ -0,0 +1,153 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.index; + + +import java.time.Instant; +import java.util.List; +import com.kingsrook.qbits.objectstorage.api.StoredObject; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.internal.ConfigSync; +import com.kingsrook.qbits.objectstorage.internal.Db; +import com.kingsrook.qbits.objectstorage.internal.IndexHook; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** Write-through: every write made through the service updates the index + ** immediately (the object row and the rolled-up folder and bucket totals). A + ** failure here never fails the write; the next scan repairs the index. + *******************************************************************************/ +public class ObjectIndexHook implements IndexHook +{ + /******************************************************************************* + ** An object was created or changed. + *******************************************************************************/ + @Override + public void objectWritten(StorageBucketConfig bucket, StoredObject object) + { + if(!applies(bucket)) + { + return; + } + try + { + Db.asSystem(() -> + { + Integer bucketId = ObjectIndex.requireBucketId(bucket.getAlias()); + QRecord existing = Db.first(ObjectStorageTables.OBJECT, "bucketId", bucketId, "objectKey", object.getKey()); + Instant now = ObjectStorageRuntime.clock().instant(); + if(existing == null) + { + Integer folderId = FolderIndex.empty(bucketId).ensureFor(object.getKey()); + QRecord row = ObjectIndex.newRow(bucketId, object.getKey(), folderId, object.getSize(), ObjectIndex.cleanEtag(object.getEtag()), object.getLastModified(), object.getStorageClass(), + currentGeneration(bucketId), now); + QRecord inserted = Db.insert(ObjectStorageTables.OBJECT, row); + Db.update(ObjectStorageTables.OBJECT, List.of(ObjectIndex.headersUpdate(bucket, inserted.getValue("id"), object))); + FolderIndex.applyDelta(bucketId, object.getKey(), 1, object.getSize() == null ? 0 : object.getSize(), object.getLastModified()); + } + else + { + boolean wasLive = !Boolean.TRUE.equals(existing.getValueBoolean("deleted")); + long before = wasLive && existing.getValueLong("size") != null ? existing.getValueLong("size") : 0; + Db.update(ObjectStorageTables.OBJECT, List.of(ObjectIndex.headersUpdate(bucket, existing.getValue("id"), object))); + FolderIndex.applyDelta(bucketId, object.getKey(), wasLive ? 0 : 1, (object.getSize() == null ? 0 : object.getSize()) - before, object.getLastModified()); + } + return (null); + }); + } + catch(QException | RuntimeException e) + { + throw (new IllegalStateException("Index write-through failed: " + e.getMessage(), e)); + } + } + + + + /******************************************************************************* + ** An object was removed from the live listing. + *******************************************************************************/ + @Override + public void objectRemoved(StorageBucketConfig bucket, String key, boolean soft) + { + if(!applies(bucket)) + { + return; + } + try + { + Db.asSystem(() -> + { + Integer bucketId = ObjectIndex.requireBucketId(bucket.getAlias()); + QRecord existing = Db.first(ObjectStorageTables.OBJECT, "bucketId", bucketId, "objectKey", key); + if(existing == null) + { + return (null); + } + boolean wasLive = !Boolean.TRUE.equals(existing.getValueBoolean("deleted")); + long size = existing.getValueLong("size") == null ? 0 : existing.getValueLong("size"); + if(soft) + { + Db.update(ObjectStorageTables.OBJECT, List.of(new QRecord().withValue("id", existing.getValue("id")).withValue("deleted", true) + .withValue("indexedAt", ObjectStorageRuntime.clock().instant()))); + } + else + { + Db.delete(ObjectStorageTables.OBJECT, List.of(existing.getValueInteger("id"))); + } + if(wasLive) + { + FolderIndex.applyDelta(bucketId, key, -1, -size, null); + } + return (null); + }); + } + catch(QException | RuntimeException e) + { + throw (new IllegalStateException("Index write-through failed: " + e.getMessage(), e)); + } + } + + + + /******************************************************************************* + ** Whether the index covers this bucket and the tables are in the context. + *******************************************************************************/ + private static boolean applies(StorageBucketConfig bucket) + { + return (Boolean.TRUE.equals(bucket.getIndexed()) && Db.available(ObjectStorageTables.OBJECT) && ConfigSync.bucketId(bucket.getAlias()) != null); + } + + + + /******************************************************************************* + ** The bucket's current scan generation (new rows join it, so the next scan + ** does not treat them as unseen). + *******************************************************************************/ + private static Long currentGeneration(Integer bucketId) throws QException + { + QRecord bucket = Db.first(ObjectStorageTables.BUCKET, "id", bucketId); + return (bucket == null || bucket.getValueLong("scanGeneration") == null ? 0L : bucket.getValueLong("scanGeneration")); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/index/ReferenceScan.java b/src/main/java/com/kingsrook/qbits/objectstorage/index/ReferenceScan.java new file mode 100644 index 0000000..864703f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/index/ReferenceScan.java @@ -0,0 +1,297 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.index; + + +import java.io.Serializable; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.objectstorage.api.MatchMode; +import com.kingsrook.qbits.objectstorage.api.StorageReference; +import com.kingsrook.qbits.objectstorage.api.StorageReferenceProvider; +import com.kingsrook.qbits.objectstorage.config.BucketRole; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.internal.Db; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** The reference scan: runs after a bucket scan, rebuilds + ** object_storage_object_reference from the registered StorageReferenceProviders, + ** and sets object_storage_object.referenceCount. + ** + ** - Orphaned = a live object in a reference-tracked bucket with referenceCount 0, + ** outside the exempt prefixes. + ** - Missing = a reference row with no matching object (no key match, or a prefix + ** with zero objects). + ** - Referenced objects are skipped by Delete and Move. + ** + ** BACKUP buckets are never scanned: backup objects are not app media, so they + ** are never orphaned. + *******************************************************************************/ +public final class ReferenceScan +{ + /******************************************************************************* + ** The outcome of a reference scan. + *******************************************************************************/ + public record Result(long references, long missing, long orphaned, long orphanedBytes) + { + } + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private ReferenceScan() + { + } + + + + /******************************************************************************* + ** Whether the bucket takes part in reference scans. + *******************************************************************************/ + public static boolean applies(StorageBucketConfig bucket) + { + return (Boolean.TRUE.equals(bucket.getReferenceTracked()) && bucket.getRole() != BucketRole.BACKUP && Boolean.TRUE.equals(bucket.getIndexed())); + } + + + + /******************************************************************************* + ** Run the scan for a bucket alias. + *******************************************************************************/ + public static Result run(String alias) throws QException + { + StorageBucketConfig config = ObjectStorageRuntime.bucketConfig(alias); + if(!applies(config)) + { + return (new Result(0, 0, 0, 0)); + } + Integer bucketId = ObjectIndex.requireBucketId(alias); + return (Db.asSystem(() -> doRun(config, bucketId))); + } + + + + /******************************************************************************* + ** The scan itself. + *******************************************************************************/ + private static Result doRun(StorageBucketConfig config, Integer bucketId) throws QException + { + String alias = config.getAlias(); + Instant now = ObjectStorageRuntime.clock().instant(); + + List references = new ArrayList<>(); + for(StorageReferenceProvider provider : ObjectStorageRuntime.getConfig().getReferenceProviders()) + { + provider.references(alias).forEach(references::add); + } + + // resolve every reference against the index + Map idByKey = new HashMap<>(); + List keyRefs = new ArrayList<>(); + for(StorageReference ref : references) + { + if(ref.getMatchMode() == MatchMode.KEY) + { + keyRefs.add(ref.getKeyOrPrefix()); + } + } + for(int i = 0; i < keyRefs.size(); i += 500) + { + List slice = new ArrayList<>(keyRefs.subList(i, Math.min(keyRefs.size(), i + 500))); + for(QRecord row : Db.query(ObjectStorageTables.OBJECT, new QQueryFilter(Db.eq("bucketId", bucketId), Db.eq("deleted", false), new QFilterCriteria("objectKey", QCriteriaOperator.IN, slice)))) + { + idByKey.put(row.getValueString("objectKey"), row.getValueInteger("id")); + } + } + + Map counts = new HashMap<>(); + List rows = new ArrayList<>(); + long missing = 0; + Map> prefixCache = new HashMap<>(); + for(StorageReference ref : references) + { + List matched = new ArrayList<>(); + if(ref.getMatchMode() == MatchMode.KEY) + { + Integer id = idByKey.get(ref.getKeyOrPrefix()); + if(id != null) + { + matched.add(id); + } + } + else + { + matched = prefixCache.computeIfAbsent(ref.getKeyOrPrefix(), p -> idsUnder(bucketId, p)); + } + for(Integer id : matched) + { + counts.merge(id, 1, Integer::sum); + } + boolean isMissing = matched.isEmpty(); + if(isMissing) + { + missing++; + } + rows.add(new QRecord().withValue("bucketId", bucketId).withValue("objectId", matched.isEmpty() ? null : matched.get(0)).withValue("keyOrPrefix", ref.getKeyOrPrefix()) + .withValue("matchMode", ref.getMatchMode().name()).withValue("sourceTable", ref.getTableName()).withValue("sourceRecordId", ref.getRecordId()) + .withValue("sourceField", ref.getFieldName()).withValue("label", ref.getLabel()).withValue("missing", isMissing).withValue("scannedAt", now)); + } + + // rebuild the reference rows + List oldIds = new ArrayList<>(); + Db.forEachPage(ObjectStorageTables.OBJECT_REFERENCE, new QQueryFilter(Db.eq("bucketId", bucketId)), 5000, old -> old.forEach(r -> oldIds.add(r.getValueInteger("id")))); + for(int i = 0; i < oldIds.size(); i += 1000) + { + Db.delete(ObjectStorageTables.OBJECT_REFERENCE, oldIds.subList(i, Math.min(oldIds.size(), i + 1000))); + } + for(int i = 0; i < rows.size(); i += 1000) + { + Db.insert(ObjectStorageTables.OBJECT_REFERENCE, rows.subList(i, Math.min(rows.size(), i + 1000))); + } + + // set referenceCount where it changed + Set seenWithCount = new HashSet<>(); + List updates = new ArrayList<>(); + Db.forEachPage(ObjectStorageTables.OBJECT, new QQueryFilter(Db.eq("bucketId", bucketId), new QFilterCriteria("referenceCount", QCriteriaOperator.GREATER_THAN, 0)), 5000, objs -> + { + for(QRecord obj : objs) + { + Integer id = obj.getValueInteger("id"); + seenWithCount.add(id); + int wanted = counts.getOrDefault(id, 0); + if(obj.getValueInteger("referenceCount") != wanted) + { + updates.add(new QRecord().withValue("id", id).withValue("referenceCount", wanted)); + } + } + }); + for(Map.Entry entry : counts.entrySet()) + { + if(!seenWithCount.contains(entry.getKey())) + { + updates.add(new QRecord().withValue("id", entry.getKey()).withValue("referenceCount", entry.getValue())); + } + } + for(int i = 0; i < updates.size(); i += 1000) + { + Db.update(ObjectStorageTables.OBJECT, updates.subList(i, Math.min(updates.size(), i + 1000))); + } + + // orphans + long[] orphaned = new long[2]; + Db.forEachPage(ObjectStorageTables.OBJECT, new QQueryFilter(Db.eq("bucketId", bucketId), Db.eq("deleted", false), Db.eq("referenceCount", 0)), 5000, objs -> + { + for(QRecord obj : objs) + { + if(!exempt(config, obj.getValueString("objectKey"))) + { + orphaned[0]++; + orphaned[1] += obj.getValueLong("size") == null ? 0 : obj.getValueLong("size"); + } + } + }); + Db.update(ObjectStorageTables.BUCKET, List.of(new QRecord().withValue("id", bucketId).withValue("orphanedCount", orphaned[0]).withValue("orphanedBytes", orphaned[1]) + .withValue("missingCount", missing).withValue("lastReferenceScanDate", now))); + raiseAlerts(config, bucketId, missing, orphaned[0]); + return (new Result(references.size(), missing, orphaned[0], orphaned[1])); + } + + + + /******************************************************************************* + ** The ids of live objects under a prefix. + *******************************************************************************/ + private static List idsUnder(Integer bucketId, String prefix) + { + List ids = new ArrayList<>(); + try + { + Db.forEachPage(ObjectStorageTables.OBJECT, new QQueryFilter(Db.eq("bucketId", bucketId), Db.eq("deleted", false), new QFilterCriteria("objectKey", QCriteriaOperator.STARTS_WITH, prefix)), 5000, + rows -> rows.forEach(r -> ids.add(r.getValueInteger("id")))); + } + catch(QException e) + { + throw (new IllegalStateException(e)); + } + return (ids); + } + + + + /******************************************************************************* + ** Whether a key is under an exempt prefix (never reported as orphaned). + *******************************************************************************/ + static boolean exempt(StorageBucketConfig config, String key) + { + for(String prefix : config.getExemptPrefixes()) + { + if(key != null && key.startsWith(prefix)) + { + return (true); + } + } + return (false); + } + + + + /******************************************************************************* + ** Raise or resolve the missing and orphaned alerts (a no-op without the Alerts + ** qbit). + *******************************************************************************/ + private static void raiseAlerts(StorageBucketConfig config, Integer bucketId, long missing, long orphaned) throws QException + { + String alias = config.getAlias(); + if(missing > 0) + { + ObjectStorageRuntime.alertSink().raise("storage.objects.missing", "bucket:" + alias, "error", missing + " referenced objects are missing from " + alias, Map.of("bucket", alias, "count", missing)); + } + else + { + ObjectStorageRuntime.alertSink().resolve("storage.objects.missing", "bucket:" + alias); + } + QRecord bucket = Db.first(ObjectStorageTables.BUCKET, "id", bucketId); + Integer threshold = bucket == null ? null : bucket.getValueInteger("orphanCountAlertThreshold"); + if(threshold != null && orphaned > threshold) + { + ObjectStorageRuntime.alertSink().raise("storage.objects.orphaned", "bucket:" + alias, "warning", orphaned + " orphaned objects in " + alias, Map.of("bucket", alias, "count", orphaned)); + } + else + { + ObjectStorageRuntime.alertSink().resolve("storage.objects.orphaned", "bucket:" + alias); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/index/StorageJobs.java b/src/main/java/com/kingsrook/qbits/objectstorage/index/StorageJobs.java new file mode 100644 index 0000000..2dcb32b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/index/StorageJobs.java @@ -0,0 +1,220 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.index; + + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.objectstorage.api.ListRequest; +import com.kingsrook.qbits.objectstorage.api.ObjectStorage; +import com.kingsrook.qbits.objectstorage.api.ObjectSummary; +import com.kingsrook.qbits.objectstorage.api.Page; +import com.kingsrook.qbits.objectstorage.api.StorageBucket; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.internal.Db; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** The scheduled work: the nightly scan, HEAD backlog, reference scan, usage + ** snapshot, trash purge, and the freshness and quota alerts. Each step is + ** isolated: one failing bucket never stops the others. + *******************************************************************************/ +public final class StorageJobs +{ + private static final QLogger LOG = QLogger.getLogger(StorageJobs.class); + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private StorageJobs() + { + } + + + + /******************************************************************************* + ** Run everything for every indexed bucket; returns one line per step. + *******************************************************************************/ + public static List nightly() throws QException + { + List log = new ArrayList<>(); + for(String alias : ObjectIndex.indexedAliases()) + { + try + { + ObjectIndex.ScanResult scan = ObjectIndex.scan(alias); + log.add(alias + " scan: " + scan.status() + " (" + scan.seen() + " seen, " + scan.added() + " new, " + scan.removed() + " removed)"); + int headed = ObjectIndex.headBacklog(alias, ObjectStorageRuntime.getConfig().getHeadBacklogPerRun()); + log.add(alias + " HEAD backlog: " + headed + " objects"); + StorageBucketConfig config = ObjectStorageRuntime.bucketConfig(alias); + if(ReferenceScan.applies(config)) + { + ReferenceScan.Result r = ReferenceScan.run(alias); + log.add(alias + " references: " + r.references() + ", missing " + r.missing() + ", orphaned " + r.orphaned()); + } + usageSnapshot(alias); + } + catch(QException | RuntimeException e) + { + LOG.warn("A nightly storage step failed", e, logPair("bucket", alias)); + log.add(alias + " FAILED: " + e.getMessage()); + } + } + for(StorageBucketConfig bucket : ObjectStorageRuntime.getConfig().getBuckets()) + { + if(Boolean.TRUE.equals(bucket.getTrashEnabled())) + { + try + { + log.add(bucket.getAlias() + " trash purge: " + purgeTrash(bucket.getAlias()) + " objects"); + } + catch(RuntimeException e) + { + log.add(bucket.getAlias() + " trash purge FAILED: " + e.getMessage()); + } + } + } + checkFreshness(); + return (log); + } + + + + /******************************************************************************* + ** Record today's usage for a bucket (one row per bucket per day) and raise or + ** resolve the quota alert. + *******************************************************************************/ + public static void usageSnapshot(String alias) throws QException + { + Integer bucketId = ObjectIndex.requireBucketId(alias); + Db.asSystem(() -> + { + QRecord bucket = Db.first(ObjectStorageTables.BUCKET, "id", bucketId); + Map> byPrefix = new LinkedHashMap<>(); + Db.forEachPage(ObjectStorageTables.FOLDER, new QQueryFilter(Db.eq("bucketId", bucketId)), 5000, rows -> + { + for(QRecord folder : rows) + { + if(folder.getValueInteger("parentFolderId") == null) + { + byPrefix.put(folder.getValueString("path"), Map.of("objects", folder.getValueLong("objectCount") == null ? 0L : folder.getValueLong("objectCount"), + "bytes", folder.getValueLong("totalBytes") == null ? 0L : folder.getValueLong("totalBytes"))); + } + } + }); + LocalDate today = LocalDate.ofInstant(ObjectStorageRuntime.clock().instant(), ZoneOffset.UTC); + QRecord values = new QRecord().withValue("bucketId", bucketId).withValue("snapshotDate", today).withValue("objectCount", bucket.getValueLong("objectCount")) + .withValue("totalBytes", bucket.getValueLong("totalBytes")).withValue("byPrefix", JsonUtils.toJson(byPrefix)); + QRecord existing = Db.first(ObjectStorageTables.USAGE_DAILY, "bucketId", bucketId, "snapshotDate", today); + if(existing == null) + { + Db.insert(ObjectStorageTables.USAGE_DAILY, values); + } + else + { + Db.update(ObjectStorageTables.USAGE_DAILY, List.of(values.withValue("id", existing.getValue("id")))); + } + Long quota = bucket.getValueLong("quotaBytes"); + long used = bucket.getValueLong("totalBytes") == null ? 0 : bucket.getValueLong("totalBytes"); + if(quota != null && quota > 0 && used >= quota * 0.8) + { + boolean critical = used >= quota * 0.95; + ObjectStorageRuntime.alertSink().raise("storage.quota.near", "bucket:" + alias, critical ? "error" : "warning", + alias + " uses " + Math.round(used * 100.0 / quota) + "% of its quota", Map.of("bucket", alias, "bytes", used, "quota", quota)); + } + else + { + ObjectStorageRuntime.alertSink().resolve("storage.quota.near", "bucket:" + alias); + } + return (null); + }); + } + + + + /******************************************************************************* + ** Permanently delete trash objects older than the retention (the nightly purge + ** for stores whose lifecycle rules cannot expire the trash). Returns the count. + *******************************************************************************/ + public static int purgeTrash(String alias) + { + StorageBucketConfig config = ObjectStorageRuntime.bucketConfig(alias); + String trashAlias = config.getTrashBucket() == null ? alias : config.getTrashBucket(); + StorageBucket trash = ObjectStorage.asSystem("object-storage", "trash-purge").bucket(trashAlias); + Instant cutoff = ObjectStorageRuntime.clock().instant().minus(Duration.ofDays(config.getTrashRetentionDays())); + List expired = new ArrayList<>(); + String token = null; + do + { + Page page = trash.list(ListRequest.prefix(config.getTrashPrefix()).pageSize(1000).after(token)); + for(ObjectSummary o : page.getItems()) + { + if(o.getLastModified() != null && o.getLastModified().isBefore(cutoff)) + { + expired.add(o.getKey()); + } + } + token = page.getNextToken(); + } + while(token != null); + return (expired.isEmpty() ? 0 : trash.purgeKeys(expired)); + } + + + + /******************************************************************************* + ** Raise storage.scan.stale for indexed buckets not scanned in 48 hours. + *******************************************************************************/ + public static void checkFreshness() throws QException + { + Instant cutoff = ObjectStorageRuntime.clock().instant().minus(Duration.ofHours(48)); + for(String alias : ObjectIndex.indexedAliases()) + { + Integer id = com.kingsrook.qbits.objectstorage.internal.ConfigSync.bucketId(alias); + if(id == null) + { + continue; + } + QRecord bucket = Db.asSystem(() -> Db.first(ObjectStorageTables.BUCKET, "id", id)); + Instant last = bucket == null ? null : bucket.getValueInstant("lastScanDate"); + if(last == null || last.isBefore(cutoff)) + { + ObjectStorageRuntime.alertSink().raise("storage.scan.stale", "bucket:" + alias, "warning", "The index of " + alias + " has not completed a scan in 48 hours", Map.of("bucket", alias)); + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/internal/CapabilityProbe.java b/src/main/java/com/kingsrook/qbits/objectstorage/internal/CapabilityProbe.java new file mode 100644 index 0000000..14f93e0 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/internal/CapabilityProbe.java @@ -0,0 +1,712 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import com.kingsrook.qbits.objectstorage.api.Capability; +import com.kingsrook.qbits.objectstorage.api.CapabilityStatus; +import com.kingsrook.qbits.objectstorage.api.PresignedPost; +import com.kingsrook.qbits.objectstorage.config.AdminApiType; +import com.kingsrook.qbits.objectstorage.config.BucketRole; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest; +import software.amazon.awssdk.services.s3.model.CompletedMultipartUpload; +import software.amazon.awssdk.services.s3.model.CompletedPart; +import software.amazon.awssdk.services.s3.model.CopyObjectRequest; +import software.amazon.awssdk.services.s3.model.Delete; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; +import software.amazon.awssdk.services.s3.model.ObjectIdentifier; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.model.UploadPartRequest; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** The capability probe behind "Test connection". Bucket-setting probes are + ** read-only Get* calls. Write probes (only when asked and the bucket is not + ** protected) touch a single .qqq-probe/ key that is always deleted afterwards. + ** The probe never changes bucket configuration and never throws: every + ** unexpected result is recorded as UNKNOWN with the detail. + *******************************************************************************/ +public final class CapabilityProbe +{ + private static final QLogger LOG = QLogger.getLogger(CapabilityProbe.class); + + public static final String PROBE_PREFIX = ".qqq-probe/"; + + private static final int MIN_PART_BYTES = 5 * 1024 * 1024; + + + + /******************************************************************************* + ** The outcome of one connection test. + *******************************************************************************/ + public static class Report + { + private final Map> byBucket = new java.util.LinkedHashMap<>(); + private final Map> notes = new java.util.LinkedHashMap<>(); + private final List problems = new ArrayList<>(); + private Long clockSkewSeconds; + private Boolean presignHostReachable; + + + + /******************************************************************************* + ** Per bucket alias, the status of each capability probed. + *******************************************************************************/ + public Map> getByBucket() + { + return (byBucket); + } + + + + /******************************************************************************* + ** Per bucket alias, notes (for example "write probes skipped: object lock"). + *******************************************************************************/ + public Map> getNotes() + { + return (notes); + } + + + + /******************************************************************************* + ** Connection-level problems (auth, TLS, DNS, clock). + *******************************************************************************/ + public List getProblems() + { + return (problems); + } + + + + /******************************************************************************* + ** Getter for clockSkewSeconds. + *******************************************************************************/ + public Long getClockSkewSeconds() + { + return (clockSkewSeconds); + } + + + + /******************************************************************************* + ** Getter for presignHostReachable. + *******************************************************************************/ + public Boolean getPresignHostReachable() + { + return (presignHostReachable); + } + + + + /******************************************************************************* + ** One summary line for a bucket: "8 supported, 3 unsupported, 1 not permitted". + *******************************************************************************/ + public String summaryFor(String alias) + { + Map counts = new EnumMap<>(CapabilityStatus.class); + byBucket.getOrDefault(alias, Map.of()).values().forEach(s -> counts.merge(s, 1, Integer::sum)); + List parts = new ArrayList<>(); + for(CapabilityStatus status : CapabilityStatus.values()) + { + if(counts.containsKey(status)) + { + parts.add(counts.get(status) + " " + status.name().toLowerCase().replace('_', ' ')); + } + } + return (parts.isEmpty() ? "nothing probed" : String.join(", ", parts)); + } + } + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private CapabilityProbe() + { + } + + + + /******************************************************************************* + ** Whether a bucket gets write probes: not protected, and not a BACKUP bucket + ** with object lock on (a probe object there could not be deleted: SF4). + *******************************************************************************/ + public static String writeProbeSkipReason(StorageBucketConfig bucket, ConnectionRuntime connection) + { + if(Boolean.TRUE.equals(bucket.getProtectedBucket())) + { + return ("bucket is protected"); + } + if(bucket.getRole() == BucketRole.BACKUP) + { + try + { + connection.client().getObjectLockConfiguration(b -> b.bucket(name(bucket))); + return ("write probes skipped: object lock"); + } + catch(RuntimeException e) + { + return (null); + } + } + return (null); + } + + + + /******************************************************************************* + ** Probe every bucket of a connection and record the results. + *******************************************************************************/ + public static Report probeConnection(String connectionName, boolean includeWrite) + { + details.clear(); + Report report = new Report(); + ConnectionRuntime connection = ObjectStorageRuntime.connection(connectionName); + StorageConnectionConfig config = connection.getConfig(); + List buckets = new ArrayList<>(); + for(StorageBucketConfig bucket : ObjectStorageRuntime.getConfig().getBuckets()) + { + if(connectionName.equals(bucket.getConnection())) + { + buckets.add(bucket); + } + } + + StorageBucketConfig writable = null; + for(StorageBucketConfig bucket : buckets) + { + Map results = new EnumMap<>(Capability.class); + report.byBucket.put(bucket.getAlias(), results); + List notes = new ArrayList<>(); + report.notes.put(bucket.getAlias(), notes); + + readProbes(connection, bucket, results, report); + recordVersioningState(bucket); + + if(includeWrite) + { + String skip = writeProbeSkipReason(bucket, connection); + if(skip != null) + { + notes.add(skip.startsWith("write probes skipped") ? skip : "write probes skipped: " + skip); + } + else + { + writeProbes(connection, bucket, results); + if(writable == null) + { + writable = bucket; + } + } + } + for(Map.Entry e : results.entrySet()) + { + CapabilityStore.record(bucket, e.getKey(), e.getValue(), details.getOrDefault(bucket.getAlias() + ":" + e.getKey(), null)); + } + } + + if(includeWrite && writable != null) + { + Map presign = new EnumMap<>(Capability.class); + presignProbes(connection, writable, presign, report); + report.byBucket.get(writable.getAlias()).putAll(presign); + for(Map.Entry e : presign.entrySet()) + { + CapabilityStore.record(writable, e.getKey(), e.getValue(), details.get(writable.getAlias() + ":" + e.getKey())); + } + } + + for(Capability admin : new Capability[] { Capability.USAGE_ADMIN_API, Capability.QUOTA_ADMIN_API }) + { + CapabilityStatus status = config.getAdminApiType() == AdminApiType.NONE ? CapabilityStatus.UNSUPPORTED : CapabilityStatus.UNKNOWN; + for(StorageBucketConfig bucket : buckets) + { + report.byBucket.get(bucket.getAlias()).put(admin, status); + } + if(!buckets.isEmpty()) + { + CapabilityStore.record(buckets.get(0), admin, status, config.getAdminApiType() == AdminApiType.NONE ? "no admin API configured" : "admin API probing is not part of v1"); + } + } + return (report); + } + + private static final Map details = new java.util.concurrent.ConcurrentHashMap<>(); + + + + /******************************************************************************* + ** Show what the probe saw about versioning on the bucket row (Enabled, + ** Suspended, Off), so the home and the record do not say "unknown". + *******************************************************************************/ + private static void recordVersioningState(StorageBucketConfig bucket) + { + String seen = details.get(bucket.getAlias() + ":" + Capability.VERSIONING); + if(seen == null || !Db.available(ObjectStorageTables.BUCKET)) + { + return; + } + String state = seen.equals("Enabled") || seen.equals("Suspended") ? seen : seen.equals("not enabled") ? "Off" : seen.contains("stub") ? "not supported" : null; + Integer id = ConfigSync.bucketId(bucket.getAlias()); + if(state == null || id == null) + { + return; + } + try + { + Db.asSystem(() -> Db.update(ObjectStorageTables.BUCKET, List.of(new QRecord().withValue("id", id).withValue("versioningState", state)))); + } + catch(Exception e) + { + LOG.debug("Could not record the versioning state", e); + } + } + + + + /******************************************************************************* + ** The read-only probes of one bucket. + *******************************************************************************/ + private static void readProbes(ConnectionRuntime connection, StorageBucketConfig bucket, Map results, Report report) + { + S3Client client = connection.client(); + String name = name(bucket); + String alias = bucket.getAlias(); + String sampleKey = null; + + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + ListObjectsV2Response r = client.listObjectsV2(ListObjectsV2Request.builder().bucket(name).maxKeys(1).build()); + results.put(Capability.LIST_V2, CapabilityStatus.SUPPORTED); + if(!r.contents().isEmpty()) + { + sampleKey = r.contents().get(0).key(); + } + r.sdkHttpResponse().firstMatchingHeader("Date").ifPresent(d -> + { + try + { + long skew = Math.abs(Duration.between(ZonedDateTime.parse(d, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant(), Instant.now()).getSeconds()); + if(report.clockSkewSeconds == null || skew > report.clockSkewSeconds) + { + report.clockSkewSeconds = skew; + } + } + catch(RuntimeException e) + { + LOG.debug("Could not parse the Date header", e); + } + }); + } + catch(RuntimeException e) + { + results.put(Capability.LIST_V2, classify(alias, Capability.LIST_V2, e)); + report.problems.add(alias + ": listing failed (" + shortMessage(e) + ")"); + } + + probe(results, alias, Capability.VERSIONING, () -> + { + String status = client.getBucketVersioning(b -> b.bucket(name)).statusAsString(); + if(status == null && ProviderDefaults.defaultStatus(connection.getConfig().getProviderType(), Capability.VERSIONING) == CapabilityStatus.UNSUPPORTED) + { + details.put(alias + ":" + Capability.VERSIONING, "the provider reports versioning off and documents it as a stub"); + return (CapabilityStatus.UNSUPPORTED); + } + details.put(alias + ":" + Capability.VERSIONING, status == null ? "not enabled" : status); + return (CapabilityStatus.SUPPORTED); + }); + probe(results, alias, Capability.OBJECT_LOCK, () -> + { + try + { + client.getObjectLockConfiguration(b -> b.bucket(name)); + return (CapabilityStatus.SUPPORTED); + } + catch(S3Exception e) + { + if("ObjectLockConfigurationNotFoundError".equals(ErrorMapper.errorCode(e))) + { + details.put(alias + ":" + Capability.OBJECT_LOCK, "object lock is not enabled on this bucket"); + return (CapabilityStatus.UNSUPPORTED); + } + throw (e); + } + }); + probe(results, alias, Capability.LIFECYCLE, () -> notConfiguredOk(connection, Capability.LIFECYCLE, "NoSuchLifecycleConfiguration", () -> client.getBucketLifecycleConfiguration(b -> b.bucket(name)))); + probe(results, alias, Capability.BUCKET_POLICY, () -> notConfiguredOk(connection, Capability.BUCKET_POLICY, "NoSuchBucketPolicy", () -> client.getBucketPolicy(b -> b.bucket(name)))); + probe(results, alias, Capability.CORS, () -> notConfiguredOk(connection, Capability.CORS, "NoSuchCORSConfiguration", () -> client.getBucketCors(b -> b.bucket(name)))); + probe(results, alias, Capability.ACL, () -> notConfiguredOk(connection, Capability.ACL, "NoSuchACL", () -> client.getBucketAcl(b -> b.bucket(name)))); + + String key = sampleKey; + if(key == null) + { + results.put(Capability.OBJECT_TAGGING, CapabilityStatus.UNKNOWN); + details.put(alias + ":" + Capability.OBJECT_TAGGING, "no object in the bucket to test"); + } + else + { + probe(results, alias, Capability.OBJECT_TAGGING, () -> + { + client.getObjectTagging(b -> b.bucket(name).key(key)); + return (CapabilityStatus.SUPPORTED); + }); + } + } + + + + /******************************************************************************* + ** A bucket-setting call where "not configured" is the documented empty answer. + ** PARTIAL when the provider's seed says the support is partial. + *******************************************************************************/ + private static CapabilityStatus notConfiguredOk(ConnectionRuntime connection, Capability capability, String notConfiguredCode, Runnable call) + { + try + { + call.run(); + } + catch(S3Exception e) + { + if(!notConfiguredCode.equals(ErrorMapper.errorCode(e))) + { + throw (e); + } + } + return (ProviderDefaults.defaultStatus(connection.getConfig().getProviderType(), capability) == CapabilityStatus.PARTIAL ? CapabilityStatus.PARTIAL : CapabilityStatus.SUPPORTED); + } + + + + /******************************************************************************* + ** The write probes of one bucket. + *******************************************************************************/ + private static void writeProbes(ConnectionRuntime connection, StorageBucketConfig bucket, Map results) + { + S3Client client = connection.client(); + String name = name(bucket); + String alias = bucket.getAlias(); + String base = PROBE_PREFIX + UUID.randomUUID(); + List cleanup = new ArrayList<>(); + try + { + probe(results, alias, Capability.PUT_GET_DELETE, () -> + { + String key = base + "/pgd"; + cleanup.add(key); + client.putObject(PutObjectRequest.builder().bucket(name).key(key).contentType("text/plain").build(), RequestBody.fromString("probe")); + client.headObject(HeadObjectRequest.builder().bucket(name).key(key).build()); + String body = client.getObjectAsBytes(b -> b.bucket(name).key(key)).asUtf8String(); + client.deleteObject(DeleteObjectRequest.builder().bucket(name).key(key).build()); + return ("probe".equals(body) ? CapabilityStatus.SUPPORTED : CapabilityStatus.UNKNOWN); + }); + probe(results, alias, Capability.COPY_REPLACE_METADATA, () -> + { + String key = base + "/copy"; + cleanup.add(key); + client.putObject(PutObjectRequest.builder().bucket(name).key(key).cacheControl("no-cache").build(), RequestBody.fromString("probe")); + client.copyObject(CopyObjectRequest.builder().sourceBucket(name).sourceKey(key).destinationBucket(name).destinationKey(key) + .metadataDirective(software.amazon.awssdk.services.s3.model.MetadataDirective.REPLACE).cacheControl("max-age=60").contentType("text/plain").build()); + String cc = client.headObject(HeadObjectRequest.builder().bucket(name).key(key).build()).cacheControl(); + return ("max-age=60".equals(cc) ? CapabilityStatus.SUPPORTED : CapabilityStatus.UNSUPPORTED); + }); + probe(results, alias, Capability.BATCH_DELETE, () -> + { + String k1 = base + "/b1"; + String k2 = base + "/b2"; + cleanup.add(k1); + cleanup.add(k2); + client.putObject(PutObjectRequest.builder().bucket(name).key(k1).build(), RequestBody.fromString("1")); + client.putObject(PutObjectRequest.builder().bucket(name).key(k2).build(), RequestBody.fromString("2")); + DeleteObjectsResponse r = client.deleteObjects(DeleteObjectsRequest.builder().bucket(name) + .delete(Delete.builder().objects(ObjectIdentifier.builder().key(k1).build(), ObjectIdentifier.builder().key(k2).build()).build()).build()); + return (r.errors().isEmpty() ? CapabilityStatus.SUPPORTED : CapabilityStatus.UNKNOWN); + }); + probe(results, alias, Capability.MULTIPART, () -> + { + String key = base + "/mp"; + cleanup.add(key); + String uploadId = client.createMultipartUpload(b -> b.bucket(name).key(key)).uploadId(); + try + { + String e1 = client.uploadPart(UploadPartRequest.builder().bucket(name).key(key).uploadId(uploadId).partNumber(1).build(), RequestBody.fromBytes(new byte[MIN_PART_BYTES])).eTag(); + String e2 = client.uploadPart(UploadPartRequest.builder().bucket(name).key(key).uploadId(uploadId).partNumber(2).build(), RequestBody.fromBytes(new byte[1024])).eTag(); + client.completeMultipartUpload(CompleteMultipartUploadRequest.builder().bucket(name).key(key).uploadId(uploadId).multipartUpload(CompletedMultipartUpload.builder() + .parts(CompletedPart.builder().partNumber(1).eTag(e1).build(), CompletedPart.builder().partNumber(2).eTag(e2).build()).build()).build()); + return (CapabilityStatus.SUPPORTED); + } + catch(RuntimeException e) + { + try + { + client.abortMultipartUpload(b -> b.bucket(name).key(key).uploadId(uploadId)); + } + catch(RuntimeException ignored) + { + LOG.debug("abort after a failed multipart probe", ignored); + } + throw (e); + } + }); + probe(results, alias, Capability.CONDITIONAL_WRITE, () -> + { + String key = base + "/cond"; + cleanup.add(key); + client.putObject(PutObjectRequest.builder().bucket(name).key(key).build(), RequestBody.fromString("1")); + try + { + client.putObject(PutObjectRequest.builder().bucket(name).key(key).ifNoneMatch("*").build(), RequestBody.fromString("2")); + details.put(alias + ":" + Capability.CONDITIONAL_WRITE, "If-None-Match was ignored"); + return (CapabilityStatus.UNSUPPORTED); + } + catch(S3Exception e) + { + if(e.statusCode() == 412) + { + return (CapabilityStatus.SUPPORTED); + } + throw (e); + } + }); + } + finally + { + for(String key : cleanup) + { + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + client.deleteObject(DeleteObjectRequest.builder().bucket(name).key(key).build()); + } + catch(RuntimeException e) + { + LOG.warn("Could not delete a probe object", e, logPair("key", key)); + } + } + } + } + + + + /******************************************************************************* + ** The presign probes: presign, then a plain HTTP request to the URL. + *******************************************************************************/ + private static void presignProbes(ConnectionRuntime connection, StorageBucketConfig bucket, Map results, Report report) + { + String name = name(bucket); + String alias = bucket.getAlias(); + String key = PROBE_PREFIX + UUID.randomUUID() + "/presign"; + HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + try + { + probe(results, alias, Capability.PRESIGNED_PUT, () -> + { + String url = connection.presigner().presignPutObject(PutObjectPresignRequest.builder().signatureDuration(Duration.ofMinutes(2)) + .putObjectRequest(b -> b.bucket(name).key(key).contentType("text/plain")).build()).url().toString(); + HttpResponse r = http.send(HttpRequest.newBuilder(URI.create(url)).header("Content-Type", "text/plain").PUT(HttpRequest.BodyPublishers.ofString("probe")).build(), + HttpResponse.BodyHandlers.ofString()); + report.presignHostReachable = true; + return (httpStatus(alias, Capability.PRESIGNED_PUT, r.statusCode(), r.body())); + }); + probe(results, alias, Capability.PRESIGNED_GET, () -> + { + String url = connection.presigner().presignGetObject(GetObjectPresignRequest.builder().signatureDuration(Duration.ofMinutes(2)) + .getObjectRequest(b -> b.bucket(name).key(key)).build()).url().toString(); + HttpResponse r = http.send(HttpRequest.newBuilder(URI.create(url)).GET().build(), HttpResponse.BodyHandlers.ofString()); + report.presignHostReachable = true; + if(r.statusCode() == 200 && !"probe".equals(r.body())) + { + details.put(alias + ":" + Capability.PRESIGNED_GET, "the presigned GET returned unexpected content (does it reach the same store?)"); + return (CapabilityStatus.UNKNOWN); + } + return (httpStatus(alias, Capability.PRESIGNED_GET, r.statusCode(), r.body())); + }); + probe(results, alias, Capability.PRESIGNED_POST, () -> + { + String[] creds = connection.presignCredentials(); + PresignedPost post = PostPolicySigner.sign(creds[0], creds[1], creds[2], creds[3], connection.getConfig().effectiveAddressingStyle(), name, key + "-post", + null, 0L, 1024L, Instant.now(), Duration.ofMinutes(2)); + String boundary = "----qqqprobe" + UUID.randomUUID(); + StringBuilder body = new StringBuilder(); + for(Map.Entry field : post.getFields().entrySet()) + { + body.append("--").append(boundary).append("\r\nContent-Disposition: form-data; name=\"").append(field.getKey()).append("\"\r\n\r\n").append(field.getValue()).append("\r\n"); + } + body.append("--").append(boundary).append("\r\nContent-Disposition: form-data; name=\"file\"; filename=\"probe.txt\"\r\nContent-Type: text/plain\r\n\r\nprobe\r\n--").append(boundary).append("--\r\n"); + HttpResponse r = http.send(HttpRequest.newBuilder(URI.create(post.getUrl())).header("Content-Type", "multipart/form-data; boundary=" + boundary) + .POST(HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8)).build(), HttpResponse.BodyHandlers.ofString()); + return (httpStatus(alias, Capability.PRESIGNED_POST, r.statusCode(), r.body())); + }); + } + finally + { + for(String k : new String[] { key, key + "-post" }) + { + try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire()) + { + connection.client().deleteObject(DeleteObjectRequest.builder().bucket(name).key(k).build()); + } + catch(RuntimeException e) + { + LOG.debug("Could not delete a presign probe object", e); + } + } + } + if(results.get(Capability.PRESIGNED_PUT) == CapabilityStatus.UNKNOWN && report.presignHostReachable == null) + { + report.presignHostReachable = false; + report.problems.add(alias + ": the presign host could not be reached"); + } + } + + + + /******************************************************************************* + ** Map an HTTP status from a presigned request. + *******************************************************************************/ + private static CapabilityStatus httpStatus(String alias, Capability capability, int status, String body) + { + if(status >= 200 && status < 300) + { + return (CapabilityStatus.SUPPORTED); + } + details.put(alias + ":" + capability, "HTTP " + status + (body == null ? "" : " " + body.replaceAll("\\s+", " ").substring(0, Math.min(200, body.length())))); + if(status == 403) + { + return (CapabilityStatus.NOT_PERMITTED); + } + if(status == 501 || status == 405) + { + return (CapabilityStatus.UNSUPPORTED); + } + return (CapabilityStatus.UNKNOWN); + } + + + + /******************************************************************************* + ** Something a probe does. + *******************************************************************************/ + @FunctionalInterface + private interface ProbeCall + { + /******************************************************************************* + ** Do it. + *******************************************************************************/ + CapabilityStatus run() throws Exception; + } + + + + /******************************************************************************* + ** Run one probe, mapping any failure to a status. Never throws. + *******************************************************************************/ + private static void probe(Map results, String alias, Capability capability, ProbeCall call) + { + try + { + results.put(capability, call.run()); + } + catch(Exception e) + { + results.put(capability, classify(alias, capability, e)); + } + } + + + + /******************************************************************************* + ** Map a failure to a capability status. + *******************************************************************************/ + static CapabilityStatus classify(String alias, Capability capability, Exception e) + { + String detail = shortMessage(e); + details.put(alias + ":" + capability, detail); + if(e instanceof S3Exception s3) + { + String code = ErrorMapper.errorCode(s3); + String lower = (code == null ? "" : code.toLowerCase()) + " " + (s3.getMessage() == null ? "" : s3.getMessage().toLowerCase()); + if(s3.statusCode() == 403 || "AccessDenied".equals(code)) + { + return (CapabilityStatus.NOT_PERMITTED); + } + if(s3.statusCode() == 501 || s3.statusCode() == 405 || lower.contains("notimplemented") || lower.contains("not implemented") || lower.contains("not supported") + || lower.contains("unsupported")) + { + return (CapabilityStatus.UNSUPPORTED); + } + } + return (CapabilityStatus.UNKNOWN); + } + + + + /******************************************************************************* + ** A short description of a failure (no secrets: SDK messages never carry them). + *******************************************************************************/ + private static String shortMessage(Throwable e) + { + String code = ErrorMapper.errorCode(e); + String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + String text = (code == null ? "" : code + ": ") + message; + return (text.length() > 300 ? text.substring(0, 300) : text); + } + + + + /******************************************************************************* + ** The bucket name on the provider. + *******************************************************************************/ + private static String name(StorageBucketConfig bucket) + { + return (SecretRefs.resolveSetting(bucket.getBucketName())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/internal/CapabilityStore.java b/src/main/java/com/kingsrook/qbits/objectstorage/internal/CapabilityStore.java new file mode 100644 index 0000000..10fbe9c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/internal/CapabilityStore.java @@ -0,0 +1,204 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import com.kingsrook.qbits.objectstorage.api.Capability; +import com.kingsrook.qbits.objectstorage.api.CapabilityStatus; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** Capability rows (per connection and per bucket): reads them (cached), falls + ** back to the provider defaults, and records probe results. UNKNOWN is treated + ** as unsupported for writes by the UI. + *******************************************************************************/ +public final class CapabilityStore +{ + private static final QLogger LOG = QLogger.getLogger(CapabilityStore.class); + + /** Capabilities that are properties of the connection (the rest are per bucket). */ + public static final Set CONNECTION_SCOPE = EnumSet.of(Capability.LIST_V2, Capability.PRESIGNED_GET, Capability.PRESIGNED_PUT, + Capability.PRESIGNED_POST, Capability.USAGE_ADMIN_API, Capability.QUOTA_ADMIN_API); + + private static final Map CACHE = new ConcurrentHashMap<>(); + private static final Set LOADED = ConcurrentHashMap.newKeySet(); + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private CapabilityStore() + { + } + + + + /******************************************************************************* + ** Drop everything cached. + *******************************************************************************/ + public static void clearCache() + { + CACHE.clear(); + LOADED.clear(); + } + + + + /******************************************************************************* + ** The status of a capability for a bucket alias. + *******************************************************************************/ + public static CapabilityStatus status(String bucketAlias, Capability capability) + { + StorageBucketConfig bucket = ObjectStorageRuntime.bucketConfig(bucketAlias); + loadIfNeeded(bucket); + String key = scopeKey(bucket, capability); + CapabilityStatus found = CACHE.get(key); + if(found != null) + { + return (found); + } + return (ProviderDefaults.defaultStatus(ObjectStorageRuntime.getConfig().findConnection(bucket.getConnection()).getProviderType(), capability)); + } + + + + /******************************************************************************* + ** Whether the status allows using the feature (known good). + *******************************************************************************/ + public static boolean usable(CapabilityStatus status) + { + return (status == CapabilityStatus.SUPPORTED || status == CapabilityStatus.PARTIAL); + } + + + + /******************************************************************************* + ** Whether the status rules a feature out (known bad), as opposed to untested. + *******************************************************************************/ + public static boolean ruledOut(CapabilityStatus status) + { + return (status == CapabilityStatus.UNSUPPORTED || status == CapabilityStatus.NOT_PERMITTED); + } + + + + /******************************************************************************* + ** Record one probe result (cache and, when the tables are there, the row). + *******************************************************************************/ + public static void record(StorageBucketConfig bucket, Capability capability, CapabilityStatus status, String detail) + { + CACHE.put(scopeKey(bucket, capability), status); + if(!Db.available(ObjectStorageTables.BUCKET_CAPABILITY)) + { + return; + } + try + { + boolean connectionScope = CONNECTION_SCOPE.contains(capability); + String table = connectionScope ? ObjectStorageTables.CONNECTION_CAPABILITY : ObjectStorageTables.BUCKET_CAPABILITY; + String idField = connectionScope ? "connectionId" : "bucketId"; + Integer id = connectionScope ? ConfigSync.connectionId(bucket.getConnection()) : ConfigSync.bucketId(bucket.getAlias()); + if(id == null) + { + return; + } + Db.asSystem(() -> + { + QRecord row = Db.first(table, idField, id, "capability", capability.name()); + QRecord values = new QRecord().withValue(idField, id).withValue("capability", capability.name()).withValue("status", status.name()) + .withValue("detail", detail).withValue("source", "PROBE").withValue("probedAt", ObjectStorageRuntime.clock().instant()); + if(row == null) + { + Db.insert(table, values); + } + else + { + Db.update(table, List.of(values.withValue("id", row.getValue("id")))); + } + return (null); + }); + } + catch(Exception e) + { + LOG.warn("Could not record a storage capability", e); + } + } + + + + /******************************************************************************* + ** The cache key: connection-scoped capabilities share one key per connection. + *******************************************************************************/ + private static String scopeKey(StorageBucketConfig bucket, Capability capability) + { + return (CONNECTION_SCOPE.contains(capability) ? "c:" + bucket.getConnection() + ":" + capability : "b:" + bucket.getAlias() + ":" + capability); + } + + + + /******************************************************************************* + ** Load the persisted rows for a bucket (and its connection) once. + *******************************************************************************/ + private static void loadIfNeeded(StorageBucketConfig bucket) + { + if(!LOADED.add(bucket.getAlias()) || !Db.available(ObjectStorageTables.BUCKET_CAPABILITY)) + { + return; + } + try + { + Integer bucketId = ConfigSync.bucketId(bucket.getAlias()); + Integer connectionId = ConfigSync.connectionId(bucket.getConnection()); + Db.asSystem(() -> + { + if(bucketId != null) + { + for(QRecord row : Db.query(ObjectStorageTables.BUCKET_CAPABILITY, "bucketId", bucketId)) + { + CACHE.putIfAbsent("b:" + bucket.getAlias() + ":" + row.getValueString("capability"), CapabilityStatus.valueOf(row.getValueString("status"))); + } + } + if(connectionId != null) + { + for(QRecord row : Db.query(ObjectStorageTables.CONNECTION_CAPABILITY, "connectionId", connectionId)) + { + CACHE.putIfAbsent("c:" + bucket.getConnection() + ":" + row.getValueString("capability"), CapabilityStatus.valueOf(row.getValueString("status"))); + } + } + return (null); + }); + } + catch(Exception e) + { + LOG.warn("Could not load storage capabilities", e); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/internal/ConfigSync.java b/src/main/java/com/kingsrook/qbits/objectstorage/internal/ConfigSync.java new file mode 100644 index 0000000..3ff0db2 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/internal/ConfigSync.java @@ -0,0 +1,276 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig; +import com.kingsrook.qbits.objectstorage.config.CredentialsMode; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** Upserts one object_storage_connection row and one object_storage_bucket row + ** per configured entry, so the tables always show the real configuration. + ** Identity and secret-reference fields come from code and are read-only in the + ** UI; operational fields (rate limits, timeouts, presign TTLs, thresholds) are + ** set on insert and then belong to the row. + *******************************************************************************/ +public final class ConfigSync +{ + private static final QLogger LOG = QLogger.getLogger(ConfigSync.class); + + private static final Map BUCKET_IDS = new ConcurrentHashMap<>(); + private static final Map CONNECTION_IDS = new ConcurrentHashMap<>(); + + private static volatile boolean synced = false; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private ConfigSync() + { + } + + + + /******************************************************************************* + ** Forget what was synced (tests, reinstall). + *******************************************************************************/ + public static void reset() + { + synced = false; + BUCKET_IDS.clear(); + CONNECTION_IDS.clear(); + } + + + + /******************************************************************************* + ** Sync once per process (and per context that has the tables); never throws. + *******************************************************************************/ + public static void ensureSynced() + { + if(!synced && Db.available(ObjectStorageTables.CONNECTION)) + { + try + { + sync(); + } + catch(Exception e) + { + LOG.warn("Could not sync the storage configuration to its tables", e); + } + } + } + + + + /******************************************************************************* + ** Upsert every configured connection and bucket. + *******************************************************************************/ + public static synchronized void sync() throws QException + { + ObjectStorageQBitConfig config = ObjectStorageRuntime.getConfig(); + Db.asSystem(() -> + { + for(StorageConnectionConfig connection : config.getConnections()) + { + CONNECTION_IDS.put(connection.getName(), upsertConnection(connection, config)); + } + for(StorageBucketConfig bucket : config.getBuckets()) + { + BUCKET_IDS.put(bucket.getAlias(), upsertBucket(bucket, config)); + } + return (null); + }); + synced = true; + } + + + + /******************************************************************************* + ** The row id of a bucket alias (syncing first if needed), or null. + *******************************************************************************/ + public static Integer bucketId(String alias) + { + Integer id = BUCKET_IDS.get(alias); + if(id == null) + { + ensureSynced(); + id = BUCKET_IDS.get(alias); + } + return (id); + } + + + + /******************************************************************************* + ** The row id of a connection name (syncing first if needed), or null. + *******************************************************************************/ + public static Integer connectionId(String name) + { + Integer id = CONNECTION_IDS.get(name); + if(id == null) + { + ensureSynced(); + id = CONNECTION_IDS.get(name); + } + return (id); + } + + + + /******************************************************************************* + ** The alias of a bucket row id, or null. + *******************************************************************************/ + public static String aliasOf(Integer bucketId) + { + for(Map.Entry entry : BUCKET_IDS.entrySet()) + { + if(entry.getValue().equals(bucketId)) + { + return (entry.getKey()); + } + } + return (null); + } + + + + /******************************************************************************* + ** Upsert one connection row. + *******************************************************************************/ + private static Integer upsertConnection(StorageConnectionConfig c, ObjectStorageQBitConfig config) throws QException + { + boolean accessResolved = c.getCredentialsMode() != CredentialsMode.STATIC_REFS + || (SecretRefs.resolve(c.getAccessKeyRef()) != null && SecretRefs.resolve(c.getSecretKeyRef()) != null); + boolean hasIncoming = config.getBuckets().stream().anyMatch(b -> c.getName().equals(b.getConnection()) + && b.getRole() != null && (b.getRole().name().equals("INCOMING") || b.getRole().name().equals("PUBLIC"))); + String warning = !c.hasPresignKey() && (StringUtils.hasContent(c.getPresignEndpoint()) || hasIncoming) + ? "Presigned URLs are signed with the main key, whose access key ID is visible in every URL. Configure a separate least-privilege presign key." : null; + + QRecord identity = new QRecord() + .withValue("name", c.getName()).withValue("source", "CONFIG").withValue("providerType", c.getProviderType().name()) + .withValue("endpoint", c.getEndpoint()).withValue("presignEndpoint", c.getPresignEndpoint()).withValue("region", c.getRegion()) + .withValue("addressingStyle", c.effectiveAddressingStyle().name()).withValue("signatureVersion", c.getSignatureVersion()) + .withValue("checksumMode", c.effectiveChecksumMode().name()).withValue("payloadSigning", c.getPayloadSigning().name()) + .withValue("tlsVerify", c.getTlsVerify()).withValue("credentialsMode", c.getCredentialsMode().name()) + .withValue("accessKeyRef", c.getAccessKeyRef()).withValue("secretKeyRef", c.getSecretKeyRef()) + .withValue("presignAccessKeyRef", c.getPresignAccessKeyRef()).withValue("presignSecretKeyRef", c.getPresignSecretKeyRef()) + .withValue("credentialsResolved", accessResolved).withValue("presignKeyConfigured", c.hasPresignKey()) + .withValue("presignWarning", warning).withValue("adminApiType", c.getAdminApiType().name()) + .withValue("settingsHash", new ConnectionRuntime(c).getSettingsHash()); + + QRecord existing = Db.first(ObjectStorageTables.CONNECTION, "name", c.getName()); + if(existing == null) + { + identity.withValue("maxOpsPerSecond", c.getMaxOpsPerSecond()).withValue("maxConcurrency", c.getMaxConcurrency()) + .withValue("connectTimeoutMs", c.getConnectTimeoutMs()).withValue("readTimeoutMs", c.getReadTimeoutMs()); + return (Db.insert(ObjectStorageTables.CONNECTION, identity).getValueInteger("id")); + } + identity.withValue("id", existing.getValue("id")); + Db.update(ObjectStorageTables.CONNECTION, List.of(identity)); + return (existing.getValueInteger("id")); + } + + + + /******************************************************************************* + ** Upsert one bucket row. + *******************************************************************************/ + private static Integer upsertBucket(StorageBucketConfig b, ObjectStorageQBitConfig config) throws QException + { + QRecord identity = new QRecord() + .withValue("alias", b.getAlias()).withValue("connectionId", CONNECTION_IDS.get(b.getConnection())).withValue("bucketName", b.getBucketName()) + .withValue("source", "CONFIG").withValue("role", b.getRole().name()).withValue("publicBaseUrl", b.getPublicBaseUrl()) + .withValue("indexed", b.getIndexed()).withValue("managedBy", b.getManagedBy().name()) + .withValue("trashBucketAlias", Boolean.TRUE.equals(b.getTrashEnabled()) ? (b.getTrashBucket() == null ? b.getAlias() : b.getTrashBucket()) : null) + .withValue("trashPrefix", Boolean.TRUE.equals(b.getTrashEnabled()) ? b.getTrashPrefix() : null) + .withValue("referenceTracked", b.getReferenceTracked()); + + QRecord existing = Db.first(ObjectStorageTables.BUCKET, "alias", b.getAlias()); + if(existing == null) + { + identity.withValue("maxPresignGetSeconds", b.getMaxPresignGetSeconds()).withValue("maxPresignPutSeconds", b.getMaxPresignPutSeconds()); + return (Db.insert(ObjectStorageTables.BUCKET, identity).getValueInteger("id")); + } + identity.withValue("id", existing.getValue("id")); + Db.update(ObjectStorageTables.BUCKET, List.of(identity)); + LOG.debug("Updated a storage bucket row", logPair("alias", b.getAlias()), logPair("at", Instant.now())); + return (existing.getValueInteger("id")); + } + + + + /******************************************************************************* + ** Operational overrides edited in the UI (rate limits, timeouts), applied to a + ** copy of the code config when a connection runtime is first built. + *******************************************************************************/ + public static StorageConnectionConfig withOverrides(StorageConnectionConfig base) + { + if(!Db.available(ObjectStorageTables.CONNECTION)) + { + return (base); + } + try + { + QRecord row = Db.asSystem(() -> Db.first(ObjectStorageTables.CONNECTION, "name", base.getName())); + if(row == null) + { + return (base); + } + if(row.getValueInteger("maxOpsPerSecond") != null) + { + base.setMaxOpsPerSecond(row.getValueInteger("maxOpsPerSecond")); + } + if(row.getValueInteger("maxConcurrency") != null) + { + base.setMaxConcurrency(row.getValueInteger("maxConcurrency")); + } + if(row.getValueInteger("connectTimeoutMs") != null) + { + base.setConnectTimeoutMs(row.getValueInteger("connectTimeoutMs")); + } + if(row.getValueInteger("readTimeoutMs") != null) + { + base.setReadTimeoutMs(row.getValueInteger("readTimeoutMs")); + } + } + catch(Exception e) + { + LOG.debug("Could not read connection overrides", e); + } + return (base); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/internal/ConnectionRuntime.java b/src/main/java/com/kingsrook/qbits/objectstorage/internal/ConnectionRuntime.java new file mode 100644 index 0000000..f5f31d6 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/internal/ConnectionRuntime.java @@ -0,0 +1,443 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import java.io.ByteArrayInputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.Collection; +import com.kingsrook.qbits.objectstorage.config.AddressingStyle; +import com.kingsrook.qbits.objectstorage.config.ChecksumMode; +import com.kingsrook.qbits.objectstorage.config.CredentialsMode; +import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.checksums.RequestChecksumCalculation; +import software.amazon.awssdk.core.checksums.ResponseChecksumValidation; +import software.amazon.awssdk.core.retry.RetryMode; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpConfigurationOption; +import software.amazon.awssdk.http.apache.ApacheHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3ClientBuilder; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.utils.AttributeMap; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** One connection's live state: the settings, one cached SDK v2 S3Client and + ** S3Presigner, and the rate limiter. + ** + ** STAND-IN: this is the small internal client factory used until QQQ core's + ** S3ClientFactory (qqq #1095, RC.5) is released; replace it then. Nothing + ** outside this package depends on how the client is built. + *******************************************************************************/ +public class ConnectionRuntime +{ + private static final QLogger LOG = QLogger.getLogger(ConnectionRuntime.class); + + private final StorageConnectionConfig config; + private final RateLimiter rateLimiter; + private final String settingsHash; + + private S3Client client; + private S3Presigner presigner; + private String presignAccessKeyId; + private String presignSecretKey; + private String presignRegion; + private String presignHost; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + public ConnectionRuntime(StorageConnectionConfig config) + { + this.config = config; + this.rateLimiter = new RateLimiter(config.getMaxOpsPerSecond(), config.getMaxConcurrency()); + this.settingsHash = Integer.toHexString(String.valueOf(config.getEndpoint() + "|" + config.getPresignEndpoint() + "|" + config.getRegion() + "|" + config.effectiveAddressingStyle() + + "|" + config.effectiveChecksumMode() + "|" + config.getTlsVerify() + "|" + config.getCredentialsMode()).hashCode()); + } + + + + /******************************************************************************* + ** Getter for config. + *******************************************************************************/ + public StorageConnectionConfig getConfig() + { + return (config); + } + + + + /******************************************************************************* + ** Getter for rateLimiter. + *******************************************************************************/ + public RateLimiter getRateLimiter() + { + return (rateLimiter); + } + + + + /******************************************************************************* + ** Hash of the settings that shape the client (clients rebuild when it changes). + *******************************************************************************/ + public String getSettingsHash() + { + return (settingsHash); + } + + + + /******************************************************************************* + ** The (lazily built, cached) S3 client. + *******************************************************************************/ + public synchronized S3Client client() + { + if(client == null) + { + client = buildClient(); + } + return (client); + } + + + + /******************************************************************************* + ** The (lazily built, cached) presigner. Uses the presign endpoint and the + ** separate least-privilege presign key when configured. + *******************************************************************************/ + public synchronized S3Presigner presigner() + { + if(presigner == null) + { + presigner = buildPresigner(); + } + return (presigner); + } + + + + /******************************************************************************* + ** Credentials used for presigned POST (the presign key when there is one). + *******************************************************************************/ + public synchronized String[] presignCredentials() + { + presigner(); + return (new String[] { presignAccessKeyId, presignSecretKey, presignRegion, presignHost }); + } + + + + /******************************************************************************* + ** Close the client and presigner. + *******************************************************************************/ + public synchronized void close() + { + if(client != null) + { + client.close(); + client = null; + } + if(presigner != null) + { + presigner.close(); + presigner = null; + } + } + + + + /******************************************************************************* + ** The region to sign for. + *******************************************************************************/ + private String effectiveRegion() + { + String region = SecretRefs.resolveSetting(config.getRegion()); + if(StringUtils.hasContent(region)) + { + return (region); + } + return (StringUtils.hasContent(config.getEndpoint()) ? "us-east-1" : null); + } + + + + /******************************************************************************* + ** Whether path-style addressing applies. + *******************************************************************************/ + private boolean pathStyle() + { + return (config.effectiveAddressingStyle() == AddressingStyle.PATH); + } + + + + /******************************************************************************* + ** Build the S3 client from the settings. + *******************************************************************************/ + private S3Client buildClient() + { + S3ClientBuilder builder = S3Client.builder() + .credentialsProvider(credentials(config.getAccessKeyRef(), config.getSecretKeyRef())) + .httpClient(httpClient()) + .forcePathStyle(pathStyle()) + .overrideConfiguration(o -> o.retryStrategy(RetryMode.ADAPTIVE_V2)); + + String endpoint = SecretRefs.resolveSetting(config.getEndpoint()); + if(StringUtils.hasContent(endpoint)) + { + builder.endpointOverride(URI.create(endpoint)); + } + String region = effectiveRegion(); + if(region != null) + { + builder.region(Region.of(region)); + } + + if(config.effectiveChecksumMode() == ChecksumMode.WHEN_REQUIRED) + { + builder.requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED); + builder.responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED); + } + else + { + builder.requestChecksumCalculation(RequestChecksumCalculation.WHEN_SUPPORTED); + builder.responseChecksumValidation(ResponseChecksumValidation.WHEN_SUPPORTED); + } + + LOG.debug("Building S3 client", logPair("connection", config.getName()), logPair("endpoint", endpoint), logPair("checksumMode", config.effectiveChecksumMode())); + return (builder.build()); + } + + + + /******************************************************************************* + ** Build the presigner. + *******************************************************************************/ + private S3Presigner buildPresigner() + { + boolean separateKey = config.hasPresignKey(); + String accessRef = separateKey ? config.getPresignAccessKeyRef() : config.getAccessKeyRef(); + String secretRef = separateKey ? config.getPresignSecretKeyRef() : config.getSecretKeyRef(); + String endpoint = SecretRefs.resolveSetting(StringUtils.hasContent(config.getPresignEndpoint()) ? config.getPresignEndpoint() : config.getEndpoint()); + String region = effectiveRegion(); + + S3Presigner.Builder builder = S3Presigner.builder() + .credentialsProvider(credentials(accessRef, secretRef)) + .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(pathStyle()).build()); + if(StringUtils.hasContent(endpoint)) + { + builder.endpointOverride(URI.create(endpoint)); + } + if(region != null) + { + builder.region(Region.of(region)); + } + + presignAccessKeyId = SecretRefs.resolve(accessRef); + presignSecretKey = SecretRefs.resolve(secretRef); + presignRegion = region == null ? "us-east-1" : region; + presignHost = endpoint; + return (builder.build()); + } + + + + /******************************************************************************* + ** Static keys from the references, or the SDK default chain. + *******************************************************************************/ + private AwsCredentialsProvider credentials(String accessRef, String secretRef) + { + if(config.getCredentialsMode() == CredentialsMode.STATIC_REFS) + { + String accessKey = SecretRefs.resolve(accessRef); + String secretKey = SecretRefs.resolve(secretRef); + if(accessKey != null && secretKey != null) + { + String token = SecretRefs.resolve(config.getSessionTokenRef()); + return (StaticCredentialsProvider.create(token == null ? AwsBasicCredentials.create(accessKey, secretKey) + : AwsSessionCredentials.create(accessKey, secretKey, token))); + } + } + return (DefaultCredentialsProvider.builder().build()); + } + + + + /******************************************************************************* + ** Apache HTTP client with the connection's timeouts and TLS settings. + *******************************************************************************/ + private SdkHttpClient httpClient() + { + ApacheHttpClient.Builder builder = ApacheHttpClient.builder() + .connectionTimeout(Duration.ofMillis(config.getConnectTimeoutMs())) + .socketTimeout(Duration.ofMillis(config.getReadTimeoutMs())); + + if(Boolean.FALSE.equals(config.getTlsVerify())) + { + LOG.warn("TLS verification is disabled for a storage connection; use only for development", logPair("connection", config.getName())); + return (builder.buildWithDefaults(AttributeMap.builder().put(SdkHttpConfigurationOption.TRUST_ALL_CERTIFICATES, Boolean.TRUE).build())); + } + + String caPem = SecretRefs.resolve(config.getCaBundleRef()); + if(StringUtils.hasContent(caPem)) + { + TrustManager[] trustManagers = trustManagers(caPem); + builder.tlsTrustManagersProvider(() -> trustManagers); + } + return (builder.build()); + } + + + + /******************************************************************************* + ** Trust the given PEM bundle in addition to the JVM default trust store. + *******************************************************************************/ + static TrustManager[] trustManagers(String pem) + { + try + { + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + Collection certificates = certificateFactory.generateCertificates(new ByteArrayInputStream(pem.getBytes(StandardCharsets.UTF_8))); + if(certificates.isEmpty()) + { + throw (new IllegalArgumentException("The CA bundle contains no certificates")); + } + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + int index = 0; + for(Certificate certificate : certificates) + { + keyStore.setCertificateEntry("object-storage-ca-" + index++, certificate); + } + X509TrustManager custom = x509(keyStore); + X509TrustManager fallback = x509(null); + return (new TrustManager[] { new Composite(custom, fallback) }); + } + catch(IllegalArgumentException e) + { + throw (e); + } + catch(Exception e) + { + throw (new IllegalArgumentException("Could not load the CA bundle for a storage connection", e)); + } + } + + + + /******************************************************************************* + ** The X509 trust manager of a key store (null: the JVM default). + *******************************************************************************/ + private static X509TrustManager x509(KeyStore keyStore) throws Exception + { + TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + factory.init(keyStore); + for(TrustManager trustManager : factory.getTrustManagers()) + { + if(trustManager instanceof X509TrustManager x) + { + return (x); + } + } + throw (new IllegalStateException("No X509TrustManager available")); + } + + + + /******************************************************************************* + ** Trusts a chain if either the private-CA or the default trust manager does. + *******************************************************************************/ + private record Composite(X509TrustManager custom, X509TrustManager fallback) implements X509TrustManager + { + /******************************************************************************* + ** Client certs are not used. + *******************************************************************************/ + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException + { + try + { + custom.checkClientTrusted(chain, authType); + } + catch(CertificateException e) + { + fallback.checkClientTrusted(chain, authType); + } + } + + + + /******************************************************************************* + ** Server certs. + *******************************************************************************/ + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException + { + try + { + custom.checkServerTrusted(chain, authType); + } + catch(CertificateException e) + { + fallback.checkServerTrusted(chain, authType); + } + } + + + + /******************************************************************************* + ** Accepted issuers of both. + *******************************************************************************/ + @Override + public X509Certificate[] getAcceptedIssuers() + { + X509Certificate[] a = custom.getAcceptedIssuers(); + X509Certificate[] b = fallback.getAcceptedIssuers(); + X509Certificate[] all = new X509Certificate[a.length + b.length]; + System.arraycopy(a, 0, all, 0, a.length); + System.arraycopy(b, 0, all, a.length, b.length); + return (all); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/internal/Db.java b/src/main/java/com/kingsrook/qbits/objectstorage/internal/Db.java new file mode 100644 index 0000000..3caaf91 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/internal/Db.java @@ -0,0 +1,302 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.CapturedContext; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; +import com.kingsrook.qqq.backend.core.model.session.QSession; + + +/******************************************************************************* + ** Small helpers for reading and writing the qbit's own tables as the system + ** user (the service writes its audit log and index regardless of the calling + ** user's per-bucket scope; the calling user's permissions were checked first). + *******************************************************************************/ +public final class Db +{ + /******************************************************************************* + ** Something that may throw. + *******************************************************************************/ + @FunctionalInterface + public interface Work + { + /******************************************************************************* + ** Do the work. + *******************************************************************************/ + T run() throws Exception; + } + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private Db() + { + } + + + + /******************************************************************************* + ** Whether the instance and the table exist in the current context. + *******************************************************************************/ + public static boolean available(String table) + { + return (QContext.getQInstance() != null && QContext.getQInstance().getTable(table) != null); + } + + + + /******************************************************************************* + ** Run work in the system-user context, restoring the caller's afterwards. + *******************************************************************************/ + @SuppressWarnings("unchecked") + public static T asSystem(Work work) throws QException + { + if(QContext.getQInstance() == null) + { + throw (new QException("No QQQ context is active")); + } + CapturedContext original = QContext.capture(); + Object[] holder = new Object[1]; + Exception[] error = new Exception[1]; + try + { + QContext.init(new CapturedContext(original.qInstance(), systemSession(original.qInstance()))); + try + { + holder[0] = work.run(); + } + catch(Exception e) + { + error[0] = e; + } + } + finally + { + QContext.init(original); + } + if(error[0] != null) + { + if(error[0] instanceof QException qe) + { + throw (qe); + } + if(error[0] instanceof RuntimeException re) + { + throw (re); + } + throw (new QException("Storage table access failed", error[0])); + } + return ((T) holder[0]); + } + + + + /******************************************************************************* + ** A session that holds every permission and every all-access security key of + ** the given instance (computed per instance, never cached across instances). + *******************************************************************************/ + private static QSession systemSession(QInstance instance) + { + QSession session = new QSession() + { + /******************************************************************************* + ** The system user holds every permission. + *******************************************************************************/ + @Override + public boolean hasPermission(String permissionName) + { + return (true); + } + }; + for(QSecurityKeyType keyType : instance.getSecurityKeyTypes().values()) + { + if(keyType.getAllAccessKeyName() != null) + { + session.withSecurityKeyValue(keyType.getAllAccessKeyName(), true); + } + } + return (session); + } + + + + /******************************************************************************* + ** Query a table with equals criteria (name, value pairs). + *******************************************************************************/ + public static List query(String table, Object... nameValuePairs) throws QException + { + QQueryFilter filter = new QQueryFilter(); + for(int i = 0; i + 1 < nameValuePairs.length; i += 2) + { + filter.withCriteria(new QFilterCriteria((String) nameValuePairs[i], QCriteriaOperator.EQUALS, (Serializable) nameValuePairs[i + 1])); + } + return (query(table, filter)); + } + + + + /******************************************************************************* + ** Query a table with a filter. + *******************************************************************************/ + public static List query(String table, QQueryFilter filter) throws QException + { + return (new QueryAction().execute(new QueryInput(table).withFilter(filter)).getRecords()); + } + + + + /******************************************************************************* + ** Run a query page by page (ordered by id), handing each page to the consumer. + ** The filter's own skip and limit are ignored. + *******************************************************************************/ + public static void forEachPage(String table, QQueryFilter filter, int pageSize, java.util.function.Consumer> consumer) throws QException + { + int skip = 0; + while(true) + { + QQueryFilter page = filter.clone(); + page.setOrderBys(new ArrayList<>(List.of(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("id")))); + page.setSkip(skip); + page.setLimit(pageSize); + QueryInput input = new QueryInput(table).withFilter(page); + input.setShouldGenerateDisplayValues(false); + input.setShouldTranslatePossibleValues(false); + List records = new QueryAction().execute(input).getRecords(); + if(records.isEmpty()) + { + return; + } + consumer.accept(records); + if(records.size() < pageSize) + { + return; + } + skip += pageSize; + } + } + + + + /******************************************************************************* + ** Count the records matching a filter. + *******************************************************************************/ + public static int count(String table, QQueryFilter filter) throws QException + { + return (new com.kingsrook.qqq.backend.core.actions.tables.CountAction().execute(new com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput(table).withFilter(filter)).getCount()); + } + + + + /******************************************************************************* + ** An equals criteria. + *******************************************************************************/ + public static QFilterCriteria eq(String field, Serializable value) + { + return (new QFilterCriteria(field, QCriteriaOperator.EQUALS, value)); + } + + + + /******************************************************************************* + ** The first record of an equals query, or null. + *******************************************************************************/ + public static QRecord first(String table, Object... nameValuePairs) throws QException + { + List records = query(table, nameValuePairs); + return (records.isEmpty() ? null : records.get(0)); + } + + + + /******************************************************************************* + ** Insert records, returning them with their ids. + *******************************************************************************/ + public static List insert(String table, List records) throws QException + { + return (new InsertAction().execute(new InsertInput(table).withRecords(records)).getRecords()); + } + + + + /******************************************************************************* + ** Insert one record. + *******************************************************************************/ + public static QRecord insert(String table, QRecord record) throws QException + { + List list = new ArrayList<>(); + list.add(record); + return (insert(table, list).get(0)); + } + + + + /******************************************************************************* + ** Update records (each must carry its primary key). + *******************************************************************************/ + public static List update(String table, List records) throws QException + { + return (new UpdateAction().execute(new UpdateInput(table).withRecords(records)).getRecords()); + } + + + + /******************************************************************************* + ** Delete records by primary key. + *******************************************************************************/ + public static void delete(String table, List ids) throws QException + { + if(!ids.isEmpty()) + { + new DeleteAction().execute(new DeleteInput(table).withPrimaryKeys(ids)); + } + } + + + + /******************************************************************************* + ** A string value of a record field, or null. + *******************************************************************************/ + public static String str(QRecord record, String field) + { + return (record == null ? null : record.getValueString(field)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/internal/DefaultObjectHeaderPolicy.java b/src/main/java/com/kingsrook/qbits/objectstorage/internal/DefaultObjectHeaderPolicy.java new file mode 100644 index 0000000..6a20a7f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/internal/DefaultObjectHeaderPolicy.java @@ -0,0 +1,146 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import java.util.Comparator; +import java.util.List; +import java.util.regex.Pattern; +import com.kingsrook.qbits.objectstorage.api.ObjectHeaderPolicy; +import com.kingsrook.qbits.objectstorage.api.ObjectHeaders; +import com.kingsrook.qbits.objectstorage.config.BucketRole; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** The default header policy: the first enabled object_storage_header_rule (by + ** ascending priority) whose bucket, key glob or regex, and content-type pattern + ** match decides the headers. BACKUP buckets are never touched. Rules are + ** ordinary audited data, cached briefly. + *******************************************************************************/ +public class DefaultObjectHeaderPolicy implements ObjectHeaderPolicy +{ + private static final QLogger LOG = QLogger.getLogger(DefaultObjectHeaderPolicy.class); + + private static final long CACHE_MILLIS = 15_000L; + + private static volatile List cachedRules; + private static volatile long cachedAt; + + + + /******************************************************************************* + ** Drop cached rules (after the rules change). + *******************************************************************************/ + public static void clearCache() + { + cachedRules = null; + } + + + + /******************************************************************************* + ** The headers the policy gives an object (empty when no rule matches). + *******************************************************************************/ + @Override + public ObjectHeaders headersFor(StorageBucketConfig bucket, String key, String contentType) + { + if(bucket.getRole() == BucketRole.BACKUP) + { + return (new ObjectHeaders()); + } + Integer bucketId = ConfigSync.bucketId(bucket.getAlias()); + for(QRecord rule : rules()) + { + Integer ruleBucket = rule.getValueInteger("bucketId"); + if(ruleBucket != null && !ruleBucket.equals(bucketId)) + { + continue; + } + if(matches(rule, key, contentType)) + { + return (new ObjectHeaders().withCacheControl(rule.getValueString("cacheControl")).withContentDisposition(rule.getValueString("contentDisposition"))); + } + } + return (new ObjectHeaders()); + } + + + + /******************************************************************************* + ** Whether a rule matches the key and content type. + *******************************************************************************/ + static boolean matches(QRecord rule, String key, String contentType) + { + String glob = rule.getValueString("keyGlob"); + String regex = rule.getValueString("keyRegex"); + String ct = rule.getValueString("contentTypePattern"); + if(StringUtils.hasContent(glob) && !Globs.matches(glob, key)) + { + return (false); + } + if(StringUtils.hasContent(regex) && !Pattern.compile(regex).matcher(key).matches()) + { + return (false); + } + if(StringUtils.hasContent(ct) && (contentType == null || !Globs.matches(ct, contentType))) + { + return (false); + } + return (true); + } + + + + /******************************************************************************* + ** The enabled rules by ascending priority (empty when the table is not there). + *******************************************************************************/ + private static List rules() + { + List rules = cachedRules; + if(rules != null && System.currentTimeMillis() - cachedAt < CACHE_MILLIS) + { + return (rules); + } + if(!Db.available(ObjectStorageTables.HEADER_RULE)) + { + return (List.of()); + } + try + { + rules = Db.asSystem(() -> Db.query(ObjectStorageTables.HEADER_RULE, "enabled", true)); + rules.sort(Comparator.comparing((QRecord r) -> r.getValueInteger("priority") == null ? 100 : r.getValueInteger("priority")) + .thenComparing(r -> r.getValueInteger("id") == null ? 0 : r.getValueInteger("id"))); + cachedRules = rules; + cachedAt = System.currentTimeMillis(); + return (rules); + } + catch(Exception e) + { + LOG.warn("Could not read storage header rules", e); + return (List.of()); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/internal/DefaultPublicUrlResolver.java b/src/main/java/com/kingsrook/qbits/objectstorage/internal/DefaultPublicUrlResolver.java new file mode 100644 index 0000000..32cdb44 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/internal/DefaultPublicUrlResolver.java @@ -0,0 +1,102 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import java.util.Optional; +import com.kingsrook.qbits.objectstorage.api.BucketKey; +import com.kingsrook.qbits.objectstorage.api.PublicUrlResolver; +import com.kingsrook.qbits.objectstorage.api.StorageKeys; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** publicBaseUrl + "/" + percent-encoded key, and the reverse. + *******************************************************************************/ +public class DefaultPublicUrlResolver implements PublicUrlResolver +{ + /******************************************************************************* + ** The public URL of a key, or null when the bucket has no public base URL. + *******************************************************************************/ + @Override + public String resolve(StorageBucketConfig bucket, String key) + { + String base = SecretRefs.resolveSetting(bucket.getPublicBaseUrl()); + if(!StringUtils.hasContent(base)) + { + return (null); + } + return (stripTrailingSlash(base) + "/" + StorageKeys.encodeForUrl(key)); + } + + + + /******************************************************************************* + ** The bucket and key a public URL points at, when one configured base URL + ** (longest match) is a prefix of it. + *******************************************************************************/ + @Override + public Optional parse(String url) + { + if(!StringUtils.hasContent(url) || !ObjectStorageRuntime.isInstalled()) + { + return (Optional.empty()); + } + StorageBucketConfig best = null; + String bestBase = ""; + for(StorageBucketConfig bucket : ObjectStorageRuntime.getConfig().getBuckets()) + { + String base = stripTrailingSlash(SecretRefs.resolveSetting(bucket.getPublicBaseUrl()) == null ? "" : SecretRefs.resolveSetting(bucket.getPublicBaseUrl())); + if(!base.isEmpty() && url.startsWith(base + "/") && base.length() > bestBase.length()) + { + best = bucket; + bestBase = base; + } + } + if(best == null) + { + return (Optional.empty()); + } + String rest = url.substring(bestBase.length() + 1); + int q = rest.indexOf('?'); + if(q >= 0) + { + rest = rest.substring(0, q); + } + return (Optional.of(new BucketKey(best.getAlias(), StorageKeys.decodeFromUrl(rest)))); + } + + + + /******************************************************************************* + ** Remove trailing slashes. + *******************************************************************************/ + private static String stripTrailingSlash(String s) + { + String r = s; + while(r.endsWith("/")) + { + r = r.substring(0, r.length() - 1); + } + return (r); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/internal/ErrorMapper.java b/src/main/java/com/kingsrook/qbits/objectstorage/internal/ErrorMapper.java new file mode 100644 index 0000000..aae06a0 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/internal/ErrorMapper.java @@ -0,0 +1,101 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import com.kingsrook.qbits.objectstorage.api.StorageException; +import com.kingsrook.qbits.objectstorage.api.StorageException.Reason; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.core.exception.SdkClientException; + + +/******************************************************************************* + ** Maps SDK exceptions to the stable StorageException reasons. + *******************************************************************************/ +public final class ErrorMapper +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private ErrorMapper() + { + } + + + + /******************************************************************************* + ** The error code the provider returned, or null. + *******************************************************************************/ + public static String errorCode(Throwable t) + { + if(t instanceof AwsServiceException ase && ase.awsErrorDetails() != null) + { + return (ase.awsErrorDetails().errorCode()); + } + return (null); + } + + + + /******************************************************************************* + ** Map any throwable. + *******************************************************************************/ + public static StorageException map(Throwable t, String what) + { + if(t instanceof StorageException se) + { + return (se); + } + if(t instanceof AwsServiceException ase) + { + int status = ase.statusCode(); + String code = errorCode(ase); + String requestId = ase.requestId(); + Reason reason = Reason.PROVIDER_ERROR; + if(status == 404 || "NoSuchKey".equals(code) || "NoSuchBucket".equals(code) || "NotFound".equals(code)) + { + reason = Reason.NOT_FOUND; + } + else if(status == 403 || status == 401 || "AccessDenied".equals(code) || "InvalidAccessKeyId".equals(code) || "SignatureDoesNotMatch".equals(code)) + { + reason = Reason.ACCESS_DENIED; + } + else if(status == 412 || "PreconditionFailed".equals(code)) + { + reason = Reason.PRECONDITION_FAILED; + } + else if(status == 503 || "SlowDown".equals(code) || "Throttling".equals(code) || status == 429) + { + reason = Reason.THROTTLED; + } + else if(status == 501 || "NotImplemented".equals(code) || "MethodNotAllowed".equals(code) || status == 405) + { + reason = Reason.UNSUPPORTED; + } + return (new StorageException(reason, what + " failed: " + (code == null ? "HTTP " + status : code), requestId, status, ase)); + } + if(t instanceof SdkClientException) + { + return (new StorageException(Reason.PROVIDER_ERROR, what + " failed: " + t.getMessage(), null, null, t)); + } + return (new StorageException(Reason.PROVIDER_ERROR, what + " failed: " + t, null, null, t)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/internal/Globs.java b/src/main/java/com/kingsrook/qbits/objectstorage/internal/Globs.java new file mode 100644 index 0000000..4ef82ce --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/internal/Globs.java @@ -0,0 +1,75 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import java.util.regex.Pattern; + + +/******************************************************************************* + ** Glob matching for keys: * matches any run of characters (slashes included), + ** ? matches one character. + *******************************************************************************/ +public final class Globs +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private Globs() + { + } + + + + /******************************************************************************* + ** Whether a glob matches a whole string. + *******************************************************************************/ + public static boolean matches(String glob, String value) + { + return (toPattern(glob).matcher(value == null ? "" : value).matches()); + } + + + + /******************************************************************************* + ** Compile a glob. + *******************************************************************************/ + public static Pattern toPattern(String glob) + { + StringBuilder sb = new StringBuilder(); + for(char c : glob.toCharArray()) + { + if(c == '*') + { + sb.append(".*"); + } + else if(c == '?') + { + sb.append('.'); + } + else + { + sb.append(Pattern.quote(String.valueOf(c))); + } + } + return (Pattern.compile(sb.toString(), Pattern.DOTALL)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/internal/IndexHook.java b/src/main/java/com/kingsrook/qbits/objectstorage/internal/IndexHook.java new file mode 100644 index 0000000..ec1d57d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/internal/IndexHook.java @@ -0,0 +1,47 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import com.kingsrook.qbits.objectstorage.api.StoredObject; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; + + +/******************************************************************************* + ** Keeps the object index in step with writes made through the service + ** (write-through). An index failure never fails the write; the next scan + ** repairs it. + *******************************************************************************/ +public interface IndexHook +{ + /******************************************************************************* + ** An object was created or changed (the HEAD result after the write). + *******************************************************************************/ + void objectWritten(StorageBucketConfig bucket, StoredObject object); + + + + /******************************************************************************* + ** An object was removed from the live listing. When soft is true the delete is + ** reversible (delete marker or trash), so the row is kept as deleted. + *******************************************************************************/ + void objectRemoved(StorageBucketConfig bucket, String key, boolean soft); +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/internal/ObjectStorageRuntime.java b/src/main/java/com/kingsrook/qbits/objectstorage/internal/ObjectStorageRuntime.java new file mode 100644 index 0000000..56ec9bd --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/internal/ObjectStorageRuntime.java @@ -0,0 +1,298 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import java.time.Clock; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig; +import com.kingsrook.qbits.objectstorage.api.ObjectHeaderPolicy; +import com.kingsrook.qbits.objectstorage.api.PublicUrlResolver; +import com.kingsrook.qbits.objectstorage.api.StorageAlertSink; +import com.kingsrook.qbits.objectstorage.api.StorageChangeListener; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig; + + +/******************************************************************************* + ** Process-wide state of the qbit: the installed config, one ConnectionRuntime + ** per connection (clients are cached and rebuilt when settings change), the + ** listeners, the header policy, the URL resolver, the alert sink and a clock + ** (replaceable in tests). + *******************************************************************************/ +public final class ObjectStorageRuntime +{ + private static final Map CONNECTIONS = new ConcurrentHashMap<>(); + private static final List LISTENERS = new CopyOnWriteArrayList<>(); + private static final Map PROCESS_PERMISSIONS = new ConcurrentHashMap<>(); + + private static volatile ObjectStorageQBitConfig config; + private static volatile ObjectHeaderPolicy headerPolicy; + private static volatile PublicUrlResolver publicUrlResolver; + private static volatile StorageAlertSink alertSink = StorageAlertSink.noop(); + private static volatile Clock clock = Clock.systemUTC(); + private static volatile IndexHook indexHook; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private ObjectStorageRuntime() + { + } + + + + /******************************************************************************* + ** Install the config (called by the producer). Connections are rebuilt. + *******************************************************************************/ + public static synchronized void install(ObjectStorageQBitConfig newConfig) + { + closeConnections(); + config = newConfig; + headerPolicy = newConfig.getHeaderPolicy() != null ? newConfig.getHeaderPolicy() : new DefaultObjectHeaderPolicy(); + publicUrlResolver = newConfig.getPublicUrlResolver() != null ? newConfig.getPublicUrlResolver() : new DefaultPublicUrlResolver(); + alertSink = newConfig.getAlertSink() != null ? newConfig.getAlertSink() : StorageAlertSink.noop(); + CapabilityStore.clearCache(); + indexHook = new com.kingsrook.qbits.objectstorage.index.ObjectIndexHook(); + } + + + + /******************************************************************************* + ** Forget everything (tests). + *******************************************************************************/ + public static synchronized void reset() + { + closeConnections(); + LISTENERS.clear(); + PROCESS_PERMISSIONS.clear(); + config = null; + headerPolicy = null; + publicUrlResolver = null; + alertSink = StorageAlertSink.noop(); + clock = Clock.systemUTC(); + indexHook = null; + CapabilityStore.clearCache(); + } + + + + /******************************************************************************* + ** Close and drop every cached client. + *******************************************************************************/ + private static void closeConnections() + { + for(ConnectionRuntime connection : CONNECTIONS.values()) + { + connection.close(); + } + CONNECTIONS.clear(); + } + + + + /******************************************************************************* + ** The installed config; fails clearly when the qbit is not installed. + *******************************************************************************/ + public static ObjectStorageQBitConfig getConfig() + { + if(config == null) + { + throw (new IllegalStateException("The Object Storage qbit is not installed")); + } + return (config); + } + + + + /******************************************************************************* + ** Whether the qbit is installed. + *******************************************************************************/ + public static boolean isInstalled() + { + return (config != null); + } + + + + /******************************************************************************* + ** The (cached) runtime of a connection. + *******************************************************************************/ + public static ConnectionRuntime connection(String name) + { + StorageConnectionConfig connectionConfig = getConfig().findConnection(name); + if(connectionConfig == null) + { + throw (new IllegalArgumentException("Unknown storage connection: " + name)); + } + return (CONNECTIONS.computeIfAbsent(name, n -> new ConnectionRuntime(ConfigSync.withOverrides(connectionConfig)))); + } + + + + /******************************************************************************* + ** The configured bucket of an alias; fails clearly when unknown. + *******************************************************************************/ + public static StorageBucketConfig bucketConfig(String alias) + { + StorageBucketConfig bucket = getConfig().findBucket(alias); + if(bucket == null) + { + throw (new IllegalArgumentException("Unknown storage bucket alias: " + alias)); + } + return (bucket); + } + + + + /******************************************************************************* + ** Register a change listener. + *******************************************************************************/ + public static void addChangeListener(StorageChangeListener listener) + { + LISTENERS.add(listener); + } + + + + /******************************************************************************* + ** Remove a change listener. + *******************************************************************************/ + public static void removeChangeListener(StorageChangeListener listener) + { + LISTENERS.remove(listener); + } + + + + /******************************************************************************* + ** The registered listeners. + *******************************************************************************/ + public static List listeners() + { + return (LISTENERS); + } + + + + /******************************************************************************* + ** The header policy in force. + *******************************************************************************/ + public static ObjectHeaderPolicy headerPolicy() + { + return (headerPolicy == null ? new DefaultObjectHeaderPolicy() : headerPolicy); + } + + + + /******************************************************************************* + ** The public URL resolver in force. + *******************************************************************************/ + public static PublicUrlResolver publicUrlResolver() + { + return (publicUrlResolver == null ? new DefaultPublicUrlResolver() : publicUrlResolver); + } + + + + /******************************************************************************* + ** Replace the public URL resolver (the CDN qbit does this). + *******************************************************************************/ + public static void setPublicUrlResolver(PublicUrlResolver resolver) + { + publicUrlResolver = resolver; + } + + + + /******************************************************************************* + ** The alert sink. + *******************************************************************************/ + public static StorageAlertSink alertSink() + { + return (alertSink); + } + + + + /******************************************************************************* + ** Replace the alert sink (an Alerts qbit adapter does this). + *******************************************************************************/ + public static void setAlertSink(StorageAlertSink sink) + { + alertSink = sink == null ? StorageAlertSink.noop() : sink; + } + + + + /******************************************************************************* + ** The clock. + *******************************************************************************/ + public static Clock clock() + { + return (clock); + } + + + + /******************************************************************************* + ** Replace the clock (tests). + *******************************************************************************/ + public static void setClock(Clock newClock) + { + clock = newClock; + } + + + + /******************************************************************************* + ** The write-through index hook, or null when the index is not active. + *******************************************************************************/ + public static IndexHook indexHook() + { + return (indexHook); + } + + + + /******************************************************************************* + ** Set the write-through index hook. + *******************************************************************************/ + public static void setIndexHook(IndexHook hook) + { + indexHook = hook; + } + + + + /******************************************************************************* + ** The permission each process needs (default object_storage.admin). + *******************************************************************************/ + public static Map processPermissions() + { + return (PROCESS_PERMISSIONS); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/internal/OperationLog.java b/src/main/java/com/kingsrook/qbits/objectstorage/internal/OperationLog.java new file mode 100644 index 0000000..8825093 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/internal/OperationLog.java @@ -0,0 +1,219 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import com.kingsrook.qbits.objectstorage.api.StorageCaller; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** Writes object_storage_operation: the storage audit log. Every write from + ** every caller (UI action, app code, scheduled job) lands here. Presigned URLs + ** are logged without the signature (key, method, expiry and issuer only), + ** because the URL itself is a bearer token. A logging failure never fails the + ** operation it describes. + *******************************************************************************/ +public final class OperationLog +{ + private static final QLogger LOG = QLogger.getLogger(OperationLog.class); + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private OperationLog() + { + } + + + + /******************************************************************************* + ** One log entry under construction. + *******************************************************************************/ + public static class Entry + { + private final StorageCaller caller; + private final String action; + private final String bucketAlias; + private String key; + private String destinationKey; + private Long bytes; + private String result = "OK"; + private String errorMessage; + private String requestId; + private String detail; + private String processRunId; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + public Entry(StorageCaller caller, String action, String bucketAlias) + { + this.caller = caller; + this.action = action; + this.bucketAlias = bucketAlias; + this.processRunId = caller.getRunId(); + } + + + + /******************************************************************************* + ** Set the key. + *******************************************************************************/ + public Entry key(String key) + { + this.key = key; + return (this); + } + + + + /******************************************************************************* + ** Set the destination key. + *******************************************************************************/ + public Entry destination(String destinationKey) + { + this.destinationKey = destinationKey; + return (this); + } + + + + /******************************************************************************* + ** Set the byte count. + *******************************************************************************/ + public Entry bytes(Long bytes) + { + this.bytes = bytes; + return (this); + } + + + + /******************************************************************************* + ** Set the provider request id. + *******************************************************************************/ + public Entry requestId(String requestId) + { + this.requestId = requestId; + return (this); + } + + + + /******************************************************************************* + ** Set the detail text. + *******************************************************************************/ + public Entry detail(String detail) + { + this.detail = detail; + return (this); + } + + + + /******************************************************************************* + ** Set the process run id (UI actions). + *******************************************************************************/ + public Entry processRunId(String processRunId) + { + this.processRunId = processRunId; + return (this); + } + + + + /******************************************************************************* + ** Mark failed. + *******************************************************************************/ + public Entry failed(String errorMessage) + { + this.result = "FAILED"; + this.errorMessage = errorMessage; + return (this); + } + + + + /******************************************************************************* + ** Mark skipped. + *******************************************************************************/ + public Entry skipped(String reason) + { + this.result = "SKIPPED"; + this.errorMessage = reason; + return (this); + } + + + + /******************************************************************************* + ** Write it. + *******************************************************************************/ + public void write() + { + OperationLog.write(this); + } + } + + + + /******************************************************************************* + ** Insert the row (as the system user), never throwing. + *******************************************************************************/ + private static void write(Entry e) + { + if(!Db.available(ObjectStorageTables.OPERATION)) + { + LOG.info("Storage operation (no operation table in context)", logPair("action", e.action), logPair("bucket", e.bucketAlias), logPair("key", e.key), logPair("result", e.result)); + return; + } + try + { + Integer bucketId = ConfigSync.bucketId(e.bucketAlias); + Db.asSystem(() -> Db.insert(ObjectStorageTables.OPERATION, new QRecord() + .withValue("occurredAt", ObjectStorageRuntime.clock().instant()) + .withValue("actor", e.caller.describe()) + .withValue("action", e.action) + .withValue("bucketId", bucketId) + .withValue("bucketAlias", e.bucketAlias) + .withValue("objectKey", e.key) + .withValue("destinationKey", e.destinationKey) + .withValue("bytes", e.bytes) + .withValue("result", e.result) + .withValue("errorMessage", e.errorMessage) + .withValue("providerRequestId", e.requestId) + .withValue("processRunId", e.processRunId) + .withValue("detail", e.detail))); + } + catch(Exception ex) + { + LOG.warn("Could not write a storage operation log row", ex, logPair("action", e.action), logPair("key", e.key)); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/internal/PostPolicySigner.java b/src/main/java/com/kingsrook/qbits/objectstorage/internal/PostPolicySigner.java new file mode 100644 index 0000000..0675d7b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/internal/PostPolicySigner.java @@ -0,0 +1,152 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.HexFormat; +import com.kingsrook.qbits.objectstorage.api.PresignedPost; +import com.kingsrook.qbits.objectstorage.api.StorageException; +import com.kingsrook.qbits.objectstorage.config.AddressingStyle; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** Signs a browser-form POST policy (SigV4) with a content-length-range + ** condition, which can express a maximum upload size. The AWS SDK v2 has no + ** helper for this. + *******************************************************************************/ +public final class PostPolicySigner +{ + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyyMMdd").withZone(ZoneOffset.UTC); + private static final DateTimeFormatter AMZ_DATE = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC); + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private PostPolicySigner() + { + } + + + + /******************************************************************************* + ** Build the URL and form fields. + *******************************************************************************/ + public static PresignedPost sign(String accessKey, String secretKey, String region, String endpoint, AddressingStyle style, String bucket, String key, + String contentType, Long minBytes, Long maxBytes, Instant now, Duration ttl) + { + if(accessKey == null || secretKey == null) + { + throw (new StorageException(StorageException.Reason.ACCESS_DENIED, "No static credentials are available to sign a POST policy")); + } + String date = DATE.format(now); + String amzDate = AMZ_DATE.format(now); + String credential = accessKey + "/" + date + "/" + region + "/s3/aws4_request"; + String expiration = DateTimeFormatter.ISO_INSTANT.format(now.plus(ttl)); + + StringBuilder policy = new StringBuilder("{\"expiration\":\"").append(expiration).append("\",\"conditions\":["); + policy.append("{\"bucket\":\"").append(json(bucket)).append("\"},"); + policy.append("{\"key\":\"").append(json(key)).append("\"},"); + if(StringUtils.hasContent(contentType)) + { + policy.append("{\"Content-Type\":\"").append(json(contentType)).append("\"},"); + } + if(maxBytes != null) + { + policy.append("[\"content-length-range\",").append(minBytes == null ? 0 : minBytes).append(",").append(maxBytes).append("],"); + } + policy.append("{\"x-amz-algorithm\":\"AWS4-HMAC-SHA256\"},{\"x-amz-credential\":\"").append(json(credential)).append("\"},{\"x-amz-date\":\"").append(amzDate).append("\"}]}"); + + String policyBase64 = Base64.getEncoder().encodeToString(policy.toString().getBytes(StandardCharsets.UTF_8)); + byte[] signingKey = hmac(hmac(hmac(hmac(("AWS4" + secretKey).getBytes(StandardCharsets.UTF_8), date), region), "s3"), "aws4_request"); + String signature = HexFormat.of().formatHex(hmac(signingKey, policyBase64)); + + PresignedPost post = new PresignedPost().withUrl(formUrl(endpoint, style, bucket, region)); + post.getFields().put("bucket", bucket); + post.getFields().put("key", key); + if(StringUtils.hasContent(contentType)) + { + post.getFields().put("Content-Type", contentType); + } + post.getFields().put("x-amz-algorithm", "AWS4-HMAC-SHA256"); + post.getFields().put("x-amz-credential", credential); + post.getFields().put("x-amz-date", amzDate); + post.getFields().put("policy", policyBase64); + post.getFields().put("x-amz-signature", signature); + return (post); + } + + + + /******************************************************************************* + ** The form action URL for the endpoint and addressing style. + *******************************************************************************/ + static String formUrl(String endpoint, AddressingStyle style, String bucket, String region) + { + if(!StringUtils.hasContent(endpoint)) + { + return ("https://" + bucket + ".s3." + region + ".amazonaws.com/"); + } + URI uri = URI.create(endpoint); + String base = uri.getScheme() + "://" + (style == AddressingStyle.VIRTUAL_HOST ? bucket + "." : "") + uri.getAuthority(); + return (style == AddressingStyle.VIRTUAL_HOST ? base + "/" : base + "/" + bucket); + } + + + + /******************************************************************************* + ** HMAC-SHA256. + *******************************************************************************/ + private static byte[] hmac(byte[] key, String data) + { + try + { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return (mac.doFinal(data.getBytes(StandardCharsets.UTF_8))); + } + catch(Exception e) + { + throw (new IllegalStateException("HmacSHA256 is not available", e)); + } + } + + + + /******************************************************************************* + ** Escape a JSON string value. + *******************************************************************************/ + private static String json(String s) + { + return (s.replace("\\", "\\\\").replace("\"", "\\\"")); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/internal/ProtectionRules.java b/src/main/java/com/kingsrook/qbits/objectstorage/internal/ProtectionRules.java new file mode 100644 index 0000000..65670be --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/internal/ProtectionRules.java @@ -0,0 +1,149 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.objectstorage.api.StorageCaller; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** Evaluates object_storage_protection_rule: a protected bucket and prefix, with + ** a mode (READ_ONLY blocks every write, NO_DELETE blocks delete and move, WARN + ** only notes it) and optional allowedSystemCallers (for example "backups") + ** whose system-context permanent delete may pass a NO_DELETE rule. + *******************************************************************************/ +public final class ProtectionRules +{ + private static final QLogger LOG = QLogger.getLogger(ProtectionRules.class); + + /******************************************************************************* + ** What an operation is, for rule matching. + *******************************************************************************/ + public enum Op + { + WRITE, + DELETE + } + + + + /******************************************************************************* + ** The result of a check. + *******************************************************************************/ + public record Result(boolean blocked, String reason, boolean warned) + { + /******************************************************************************* + ** Not protected. + *******************************************************************************/ + public static Result open() + { + return (new Result(false, null, false)); + } + } + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private ProtectionRules() + { + } + + + + /******************************************************************************* + ** Check a key for an operation. Never throws: when the table cannot be read, + ** nothing is protected by rule (the bucket-level protectedBucket flag still + ** applies in the caller). + *******************************************************************************/ + public static Result check(String bucketAlias, String key, Op op, StorageCaller caller) + { + if(!Db.available(ObjectStorageTables.PROTECTION_RULE)) + { + return (Result.open()); + } + try + { + Integer bucketId = ConfigSync.bucketId(bucketAlias); + List rules = Db.asSystem(() -> Db.query(ObjectStorageTables.PROTECTION_RULE, "enabled", true)); + List warnings = new ArrayList<>(); + for(QRecord rule : rules) + { + if(rule.getValueInteger("bucketId") != null && !rule.getValueInteger("bucketId").equals(bucketId)) + { + continue; + } + if(!Globs.matches(rule.getValueString("prefixGlob"), key)) + { + continue; + } + String mode = rule.getValueString("mode"); + String reason = "Protected by a rule" + (StringUtils.hasContent(rule.getValueString("reason")) ? ": " + rule.getValueString("reason") : ""); + if("WARN".equals(mode)) + { + warnings.add(reason); + } + else if("READ_ONLY".equals(mode) || ("NO_DELETE".equals(mode) && op == Op.DELETE)) + { + if(op == Op.DELETE && caller.isSystem() && allowed(rule.getValueString("allowedSystemCallers"), caller.getCallerName())) + { + continue; + } + return (new Result(true, reason, false)); + } + } + return (new Result(false, warnings.isEmpty() ? null : String.join("; ", warnings), !warnings.isEmpty())); + } + catch(Exception e) + { + LOG.warn("Could not evaluate protection rules", e); + return (Result.open()); + } + } + + + + /******************************************************************************* + ** Whether a caller name is in a comma-separated allowed list. + *******************************************************************************/ + private static boolean allowed(String allowedList, String callerName) + { + if(!StringUtils.hasContent(allowedList) || callerName == null) + { + return (false); + } + for(String name : allowedList.split(",")) + { + if(name.trim().equals(callerName)) + { + return (true); + } + } + return (false); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/internal/ProviderDefaults.java b/src/main/java/com/kingsrook/qbits/objectstorage/internal/ProviderDefaults.java new file mode 100644 index 0000000..71506f5 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/internal/ProviderDefaults.java @@ -0,0 +1,111 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import java.util.EnumMap; +import java.util.Map; +import com.kingsrook.qbits.objectstorage.api.Capability; +import com.kingsrook.qbits.objectstorage.api.CapabilityStatus; +import com.kingsrook.qbits.objectstorage.config.ProviderType; +import static com.kingsrook.qbits.objectstorage.api.CapabilityStatus.PARTIAL; +import static com.kingsrook.qbits.objectstorage.api.CapabilityStatus.SUPPORTED; +import static com.kingsrook.qbits.objectstorage.api.CapabilityStatus.UNKNOWN; +import static com.kingsrook.qbits.objectstorage.api.CapabilityStatus.UNSUPPORTED; + + +/******************************************************************************* + ** Seed capabilities per provider type (spec section 3.3). Cells the provider's + ** documentation does not state clearly are UNKNOWN, never a guess. The probe + ** overrides every one of these. + *******************************************************************************/ +public final class ProviderDefaults +{ + private static final Map> TABLE = new EnumMap<>(ProviderType.class); + + static + { + // versioning lock lifecycle policy cors acl tagging post conditional + put(ProviderType.AWS_S3, SUPPORTED, SUPPORTED, SUPPORTED, SUPPORTED, SUPPORTED, PARTIAL, SUPPORTED, SUPPORTED, SUPPORTED); + put(ProviderType.MINIO, SUPPORTED, UNKNOWN, SUPPORTED, PARTIAL, UNKNOWN, PARTIAL, SUPPORTED, SUPPORTED, SUPPORTED); + put(ProviderType.CLOUDFLARE_R2, UNSUPPORTED, UNSUPPORTED, PARTIAL, UNSUPPORTED, SUPPORTED, UNSUPPORTED, UNSUPPORTED, UNKNOWN, UNKNOWN); + put(ProviderType.BACKBLAZE_B2, UNKNOWN, UNKNOWN, UNKNOWN, UNKNOWN, UNKNOWN, PARTIAL, UNSUPPORTED, UNSUPPORTED, UNKNOWN); + put(ProviderType.WASABI, SUPPORTED, SUPPORTED, UNKNOWN, SUPPORTED, SUPPORTED, PARTIAL, SUPPORTED, UNKNOWN, UNKNOWN); + put(ProviderType.DO_SPACES, SUPPORTED, UNKNOWN, PARTIAL, PARTIAL, SUPPORTED, PARTIAL, UNKNOWN, UNKNOWN, UNKNOWN); + put(ProviderType.CEPH_RGW, SUPPORTED, SUPPORTED, SUPPORTED, SUPPORTED, SUPPORTED, SUPPORTED, SUPPORTED, SUPPORTED, UNKNOWN); + put(ProviderType.GARAGE, UNSUPPORTED, UNSUPPORTED, PARTIAL, UNSUPPORTED, SUPPORTED, UNSUPPORTED, UNSUPPORTED, SUPPORTED, UNKNOWN); + } + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private ProviderDefaults() + { + } + + + + /******************************************************************************* + ** One provider's row. + *******************************************************************************/ + private static void put(ProviderType type, CapabilityStatus versioning, CapabilityStatus lock, CapabilityStatus lifecycle, CapabilityStatus policy, + CapabilityStatus cors, CapabilityStatus acl, CapabilityStatus tagging, CapabilityStatus post, CapabilityStatus conditional) + { + Map m = new EnumMap<>(Capability.class); + m.put(Capability.VERSIONING, versioning); + m.put(Capability.OBJECT_LOCK, lock); + m.put(Capability.LIFECYCLE, lifecycle); + m.put(Capability.BUCKET_POLICY, policy); + m.put(Capability.CORS, cors); + m.put(Capability.ACL, acl); + m.put(Capability.OBJECT_TAGGING, tagging); + m.put(Capability.PRESIGNED_POST, post); + m.put(Capability.CONDITIONAL_WRITE, conditional); + TABLE.put(type, m); + } + + + + /******************************************************************************* + ** The default status of a capability for a provider type. + *******************************************************************************/ + public static CapabilityStatus defaultStatus(ProviderType type, Capability capability) + { + switch(capability) + { + case LIST_V2, PUT_GET_DELETE, COPY_REPLACE_METADATA, BATCH_DELETE, MULTIPART, PRESIGNED_GET, PRESIGNED_PUT -> + { + return (type == ProviderType.OTHER ? UNKNOWN : SUPPORTED); + } + case USAGE_ADMIN_API, QUOTA_ADMIN_API -> + { + return (UNSUPPORTED); + } + default -> + { + Map m = TABLE.get(type); + return (m == null ? UNKNOWN : m.getOrDefault(capability, UNKNOWN)); + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/internal/RateLimiter.java b/src/main/java/com/kingsrook/qbits/objectstorage/internal/RateLimiter.java new file mode 100644 index 0000000..aa23227 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/internal/RateLimiter.java @@ -0,0 +1,145 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import java.util.concurrent.Semaphore; + + +/******************************************************************************* + ** A per-connection token bucket (operations per second) plus a concurrency cap. + ** One per app instance; multi-replica apps get per-replica limits. + *******************************************************************************/ +public class RateLimiter +{ + private final double opsPerSecond; + private final Semaphore concurrency; + + private double tokens; + private long lastRefillNanos; + private long throttledWaits = 0; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + public RateLimiter(int opsPerSecond, int maxConcurrency) + { + this.opsPerSecond = Math.max(1, opsPerSecond); + this.concurrency = new Semaphore(Math.max(1, maxConcurrency)); + this.tokens = this.opsPerSecond; + this.lastRefillNanos = System.nanoTime(); + } + + + + /******************************************************************************* + ** Wait for a token and a concurrency slot. Close the permit when the call ends. + *******************************************************************************/ + public Permit acquire() + { + try + { + concurrency.acquire(); + waitForToken(); + return (new Permit(concurrency)); + } + catch(InterruptedException e) + { + Thread.currentThread().interrupt(); + throw (new IllegalStateException("Interrupted while waiting for the storage rate limiter", e)); + } + } + + + + /******************************************************************************* + ** Block until a token is available. + *******************************************************************************/ + private void waitForToken() throws InterruptedException + { + while(true) + { + long sleepMillis; + synchronized(this) + { + long now = System.nanoTime(); + tokens = Math.min(opsPerSecond, tokens + (now - lastRefillNanos) / 1_000_000_000.0 * opsPerSecond); + lastRefillNanos = now; + if(tokens >= 1.0) + { + tokens -= 1.0; + return; + } + throttledWaits++; + sleepMillis = Math.max(1, (long) ((1.0 - tokens) / opsPerSecond * 1000)); + } + Thread.sleep(sleepMillis); + } + } + + + + /******************************************************************************* + ** How many times a caller had to wait for a token. + *******************************************************************************/ + public synchronized long getThrottledWaits() + { + return (throttledWaits); + } + + + + /******************************************************************************* + ** Releases the concurrency slot. + *******************************************************************************/ + public static class Permit implements AutoCloseable + { + private final Semaphore semaphore; + private boolean closed = false; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + Permit(Semaphore semaphore) + { + this.semaphore = semaphore; + } + + + + /******************************************************************************* + ** Release. + *******************************************************************************/ + @Override + public void close() + { + if(!closed) + { + closed = true; + semaphore.release(); + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/internal/SecretRefs.java b/src/main/java/com/kingsrook/qbits/objectstorage/internal/SecretRefs.java new file mode 100644 index 0000000..ce8aa1f --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/internal/SecretRefs.java @@ -0,0 +1,134 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.regex.Pattern; +import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** Resolves secret references (${env.X}, ${prop.X}, ${file.PATH}) and enforces + ** the allow-list. Resolved values live only in memory, never in a record, a log + ** line or a process value. + ** + ** STAND-IN: ${file.PATH} moves to QMetaDataVariableInterpreter in QQQ core + ** (spec section 8.2); then this class delegates entirely. + *******************************************************************************/ +public final class SecretRefs +{ + public static final List DEFAULT_ALLOWED_PATTERNS = List.of("env.S3_*", "env.STORAGE_*", "file./var/run/secrets/storage/*"); + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SecretRefs() + { + } + + + + /******************************************************************************* + ** Whether the reference is of the form ${...} and its inside matches an + ** allowed glob. + *******************************************************************************/ + public static boolean isAllowed(String ref, List allowedPatterns) + { + if(!StringUtils.hasContent(ref)) + { + return (true); + } + String trimmed = ref.trim(); + if(!trimmed.startsWith("${") || !trimmed.endsWith("}")) + { + return (false); + } + String inside = trimmed.substring(2, trimmed.length() - 1); + for(String pattern : allowedPatterns) + { + if(globToRegex(pattern).matcher(inside).matches()) + { + return (true); + } + } + return (false); + } + + + + /******************************************************************************* + ** The value a reference points at, or null when it is empty or unset. + *******************************************************************************/ + public static String resolve(String ref) + { + if(!StringUtils.hasContent(ref)) + { + return (null); + } + String trimmed = ref.trim(); + if(trimmed.startsWith("${file.") && trimmed.endsWith("}")) + { + try + { + return (Files.readString(Path.of(trimmed.substring("${file.".length(), trimmed.length() - 1)), StandardCharsets.UTF_8).trim()); + } + catch(IOException e) + { + return (null); + } + } + String value = new QMetaDataVariableInterpreter().interpret(trimmed); + return (value == null || value.startsWith("${") ? null : value); + } + + + + /******************************************************************************* + ** Resolve a non-secret setting (endpoint, region) that may be a variable. + *******************************************************************************/ + public static String resolveSetting(String value) + { + return (StringUtils.hasContent(value) ? resolve(value) : null); + } + + + + /******************************************************************************* + ** Glob (only * is special) to regex. + *******************************************************************************/ + private static Pattern globToRegex(String glob) + { + StringBuilder sb = new StringBuilder(); + for(char c : glob.toCharArray()) + { + sb.append(c == '*' ? ".*" : Pattern.quote(String.valueOf(c))); + } + return (Pattern.compile(sb.toString())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/model/ObjectPreviewCustomizer.java b/src/main/java/com/kingsrook/qbits/objectstorage/model/ObjectPreviewCustomizer.java new file mode 100644 index 0000000..ffbd911 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/model/ObjectPreviewCustomizer.java @@ -0,0 +1,93 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.model; + + +import java.util.List; +import com.kingsrook.qbits.objectstorage.api.ObjectStorage; +import com.kingsrook.qbits.objectstorage.config.BucketRole; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.internal.ConfigSync; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostQueryCustomizer; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** Fills the virtual previewUrl field of object records with a short-lived + ** (5 minute) presigned GET, so the FILE_PREVIEW adornment can show a thumbnail + ** and a lightbox without the browser ever holding a credential. Only records the + ** user could query get one (record locks applied first), and BACKUP buckets get + ** none. At most 200 records per query. + *******************************************************************************/ +public class ObjectPreviewCustomizer extends AbstractPostQueryCustomizer +{ + private static final QLogger LOG = QLogger.getLogger(ObjectPreviewCustomizer.class); + + private static final int MAX_PER_QUERY = 200; + + + + /******************************************************************************* + ** Add the preview URLs. + *******************************************************************************/ + @Override + public List apply(List records) + { + if(!ObjectStorageRuntime.isInstalled()) + { + return (records); + } + int done = 0; + for(QRecord record : records) + { + if(done >= MAX_PER_QUERY) + { + break; + } + if(Boolean.TRUE.equals(record.getValueBoolean("deleted")) || record.getValueString("objectKey") == null) + { + continue; + } + try + { + String alias = ConfigSync.aliasOf(record.getValueInteger("bucketId")); + if(alias == null) + { + continue; + } + StorageBucketConfig bucket = ObjectStorageRuntime.getConfig().findBucket(alias); + if(bucket == null || bucket.getRole() == BucketRole.BACKUP) + { + continue; + } + record.setValue("previewUrl", ObjectStorage.bucket(alias).previewUrl(record.getValueString("objectKey")).toString()); + done++; + } + catch(RuntimeException e) + { + LOG.debug("Could not make a preview URL", e); + } + } + return (records); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/model/ObjectStorageSchema.java b/src/main/java/com/kingsrook/qbits/objectstorage/model/ObjectStorageSchema.java new file mode 100644 index 0000000..896afe2 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/model/ObjectStorageSchema.java @@ -0,0 +1,171 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.model; + + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig; +import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** The PostgreSQL schema of the qbit's tables, generated from the same metadata + ** the app runs on, so an adopter's migration (Liquibase, Flyway) cannot drift + ** from the tables. Column names are the snake_case of the field names (what the + ** QQQ RDBMS backend uses), table names are the QQQ table names. + ** + ** The statements are idempotent (CREATE ... IF NOT EXISTS) and include the + ** unique constraints and the browse indexes the design calls for + ** ((bucket, key) unique; bucket with folder, last modified, size, content type). + *******************************************************************************/ +public final class ObjectStorageSchema +{ + /** Unique constraints: table to column lists. */ + private static final Map>> UNIQUES = Map.of( + ObjectStorageTables.CONNECTION, List.of(List.of("name")), + ObjectStorageTables.BUCKET, List.of(List.of("alias")), + ObjectStorageTables.CONNECTION_CAPABILITY, List.of(List.of("connectionId", "capability")), + ObjectStorageTables.BUCKET_CAPABILITY, List.of(List.of("bucketId", "capability")), + ObjectStorageTables.FOLDER, List.of(List.of("bucketId", "path")), + ObjectStorageTables.OBJECT, List.of(List.of("bucketId", "objectKey")), + ObjectStorageTables.USAGE_DAILY, List.of(List.of("bucketId", "snapshotDate"))); + + /** Browse and lookup indexes: table to column lists. */ + private static final Map>> INDEXES = Map.of( + ObjectStorageTables.OBJECT, List.of(List.of("bucketId", "folderId"), List.of("bucketId", "lastModified"), List.of("bucketId", "size"), List.of("bucketId", "contentType"), + List.of("bucketId", "deleted", "referenceCount"), List.of("bucketId", "headersKnown", "deleted")), + ObjectStorageTables.OBJECT_REFERENCE, List.of(List.of("bucketId", "objectId"), List.of("bucketId", "missing")), + ObjectStorageTables.OPERATION, List.of(List.of("bucketId", "occurredAt"), List.of("objectKey", "occurredAt")), + ObjectStorageTables.FOLDER, List.of(List.of("parentFolderId")), + ObjectStorageTables.OBJECT_VERSION, List.of(List.of("bucketId", "objectKey"))); + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private ObjectStorageSchema() + { + } + + + + /******************************************************************************* + ** The CREATE TABLE and CREATE INDEX statements for every qbit table, in + ** dependency order, one statement per list entry. + *******************************************************************************/ + public static List postgresStatements(ObjectStorageQBitConfig config) + { + List statements = new ArrayList<>(); + List order = List.of(ObjectStorageTables.CONNECTION, ObjectStorageTables.CONNECTION_CAPABILITY, ObjectStorageTables.BUCKET, ObjectStorageTables.BUCKET_CAPABILITY, + ObjectStorageTables.FOLDER, ObjectStorageTables.OBJECT, ObjectStorageTables.OBJECT_VERSION, ObjectStorageTables.OBJECT_REFERENCE, ObjectStorageTables.HEADER_RULE, + ObjectStorageTables.PROTECTION_RULE, ObjectStorageTables.OPERATION, ObjectStorageTables.USAGE_DAILY); + List tables = ObjectStorageTables.tables(config); + for(String name : order) + { + for(QTableMetaData table : tables) + { + if(table.getName().equals(name)) + { + statements.add(createTable(table)); + int n = 0; + for(List columns : INDEXES.getOrDefault(name, List.of())) + { + n++; + statements.add("CREATE INDEX IF NOT EXISTS ix_" + name + "_" + n + " ON " + name + " (" + String.join(", ", columns.stream().map(ObjectStorageSchema::column).toList()) + ")"); + } + } + } + } + return (statements); + } + + + + /******************************************************************************* + ** The statements as one script. + *******************************************************************************/ + public static String postgresScript(ObjectStorageQBitConfig config) + { + return (String.join(";\n", postgresStatements(config)) + ";\n"); + } + + + + /******************************************************************************* + ** One table. + *******************************************************************************/ + private static String createTable(QTableMetaData table) + { + List lines = new ArrayList<>(); + for(QFieldMetaData field : table.getFields().values()) + { + if("id".equals(field.getName())) + { + lines.add(" id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL"); + continue; + } + lines.add(" " + column(field.getName()) + " " + sqlType(field) + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : "")); + } + lines.add(" CONSTRAINT pk_" + table.getName() + " PRIMARY KEY (id)"); + int n = 0; + for(List columns : UNIQUES.getOrDefault(table.getName(), List.of())) + { + n++; + lines.add(" CONSTRAINT uq_" + table.getName() + "_" + n + " UNIQUE (" + String.join(", ", columns.stream().map(ObjectStorageSchema::column).toList()) + ")"); + } + return ("CREATE TABLE IF NOT EXISTS " + table.getName() + " (\n" + String.join(",\n", lines) + "\n)"); + } + + + + /******************************************************************************* + ** The snake_case column of a field name. + *******************************************************************************/ + static String column(String fieldName) + { + return (QInstanceEnricher.inferBackendName(fieldName)); + } + + + + /******************************************************************************* + ** The PostgreSQL type of a field. + *******************************************************************************/ + private static String sqlType(QFieldMetaData field) + { + return (switch(field.getType()) + { + case INTEGER -> "INTEGER"; + case LONG -> "BIGINT"; + case BOOLEAN -> "BOOLEAN"; + case DATE -> "DATE"; + case DATE_TIME -> "TIMESTAMP WITHOUT TIME ZONE"; + case TEXT, HTML -> "TEXT"; + case DECIMAL -> "NUMERIC(18,4)"; + default -> "VARCHAR(" + (field.getMaxLength() == null ? 250 : field.getMaxLength()) + ")"; + }); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/model/ObjectStorageTables.java b/src/main/java/com/kingsrook/qbits/objectstorage/model/ObjectStorageTables.java new file mode 100644 index 0000000..6e3af8c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/model/ObjectStorageTables.java @@ -0,0 +1,744 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.model; + + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig; +import com.kingsrook.qbits.objectstorage.permissions.ObjectStoragePermissionChecker; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; +import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer; +import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; +import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; +import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType; +import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability; +import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory; +import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; + + +/******************************************************************************* + ** The qbit's tables: standard, visible QQQ tables named object_storage_* + ** (lowercase snake_case, the full qbit name as prefix). Field names are + ** camelCase. No table stores a secret value. + *******************************************************************************/ +public final class ObjectStorageTables +{ + public static final String CONNECTION = "object_storage_connection"; + public static final String CONNECTION_CAPABILITY = "object_storage_connection_capability"; + public static final String BUCKET = "object_storage_bucket"; + public static final String BUCKET_CAPABILITY = "object_storage_bucket_capability"; + public static final String FOLDER = "object_storage_folder"; + public static final String OBJECT = "object_storage_object"; + public static final String OBJECT_VERSION = "object_storage_object_version"; + public static final String OBJECT_REFERENCE = "object_storage_object_reference"; + public static final String HEADER_RULE = "object_storage_header_rule"; + public static final String PROTECTION_RULE = "object_storage_protection_rule"; + public static final String OPERATION = "object_storage_operation"; + public static final String USAGE_DAILY = "object_storage_usage_daily"; + + /** Tables written only by the qbit (history and observed state): read-only in the UI and API. */ + public static final List READ_ONLY = List.of(CONNECTION_CAPABILITY, BUCKET_CAPABILITY, FOLDER, OBJECT, OBJECT_VERSION, OBJECT_REFERENCE, OPERATION, USAGE_DAILY); + + public static final List ALL = List.of(CONNECTION, CONNECTION_CAPABILITY, BUCKET, BUCKET_CAPABILITY, FOLDER, OBJECT, OBJECT_VERSION, + OBJECT_REFERENCE, HEADER_RULE, PROTECTION_RULE, OPERATION, USAGE_DAILY); + + public static final String KEY_TYPE_BUCKET = "object_storage_bucket"; + public static final String KEY_TYPE_CONNECTION = "object_storage_connection"; + public static final String ALL_ACCESS_BUCKET = "object_storage_bucket_all_access"; + public static final String ALL_ACCESS_CONN = "object_storage_connection_all_access"; + + public static final String PVS_PROVIDER_TYPE = "object_storage_provider_type"; + public static final String PVS_BUCKET_ROLE = "object_storage_bucket_role"; + public static final String PVS_MANAGED_BY = "object_storage_managed_by"; + public static final String PVS_CAPABILITY = "object_storage_capability"; + public static final String PVS_CAP_STATUS = "object_storage_capability_status"; + public static final String PVS_CAP_SOURCE = "object_storage_capability_source"; + public static final String PVS_PROTECTION = "object_storage_protection_mode"; + public static final String PVS_OPERATION = "object_storage_operation_action"; + public static final String PVS_RESULT = "object_storage_operation_result"; + public static final String PVS_SOURCE = "object_storage_config_source"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private ObjectStorageTables() + { + } + + + + /******************************************************************************* + ** Permission rules shared by every qbit table, app, widget and process. + *******************************************************************************/ + public static QPermissionRules permissionRules() + { + return (new QPermissionRules() + .withLevel(PermissionLevel.HAS_ACCESS_PERMISSION) + .withPermissionBaseName(ObjectStorageQBitConfig.PERMISSION_BASE_NAME) + .withCustomPermissionChecker(new QCodeReference(ObjectStoragePermissionChecker.class))); + } + + + + /******************************************************************************* + ** The enum possible-value sources and the table references. + *******************************************************************************/ + public static List possibleValueSources() + { + List list = new ArrayList<>(); + list.add(enumSource(PVS_PROVIDER_TYPE, "AWS_S3", "MINIO", "CLOUDFLARE_R2", "BACKBLAZE_B2", "WASABI", "DO_SPACES", "CEPH_RGW", "GARAGE", "OTHER")); + list.add(enumSource(PVS_BUCKET_ROLE, "PUBLIC", "PRIVATE", "INCOMING", "BACKUP")); + list.add(enumSource(PVS_MANAGED_BY, "IAC", "UI")); + list.add(enumSource(PVS_CAPABILITY, "LIST_V2", "PUT_GET_DELETE", "COPY_REPLACE_METADATA", "BATCH_DELETE", "MULTIPART", "PRESIGNED_GET", "PRESIGNED_PUT", + "PRESIGNED_POST", "CONDITIONAL_WRITE", "VERSIONING", "OBJECT_LOCK", "LIFECYCLE", "BUCKET_POLICY", "CORS", "OBJECT_TAGGING", "ACL", "USAGE_ADMIN_API", "QUOTA_ADMIN_API")); + list.add(enumSource(PVS_CAP_STATUS, "SUPPORTED", "UNSUPPORTED", "NOT_PERMITTED", "PARTIAL", "UNKNOWN")); + list.add(enumSource(PVS_CAP_SOURCE, "DEFAULT", "PROBE", "OVERRIDE")); + list.add(enumSource(PVS_PROTECTION, "READ_ONLY", "NO_DELETE", "WARN")); + list.add(enumSource(PVS_OPERATION, "PUT", "COPY", "MOVE", "HEADERS", "DELETE", "RESTORE", "PRESIGN_GET", "PRESIGN_PUT", "PRESIGN_POST", "UPLOAD", "SCAN", "PROBE", "PURGE_TRASH")); + list.add(enumSource(PVS_RESULT, "OK", "FAILED", "SKIPPED")); + list.add(enumSource(PVS_SOURCE, "CONFIG", "UI")); + for(String table : ALL) + { + list.add(QPossibleValueSource.newForTable(table)); + } + return (list); + } + + + + /******************************************************************************* + ** An enum possible-value source from plain string ids (the id is the label). + *******************************************************************************/ + private static QPossibleValueSource enumSource(String name, String... values) + { + List> list = new ArrayList<>(); + for(String value : values) + { + list.add(new QPossibleValue<>(value, labelOf(value))); + } + return (new QPossibleValueSource().withName(name).withType(QPossibleValueSourceType.ENUM).withEnumValues(list)); + } + + + + /******************************************************************************* + ** A readable label for an UPPER_SNAKE id. + *******************************************************************************/ + static String labelOf(String id) + { + StringBuilder sb = new StringBuilder(); + for(String part : id.toLowerCase().split("_")) + { + if(!part.isEmpty()) + { + sb.append(sb.isEmpty() ? "" : " ").append(Character.toUpperCase(part.charAt(0))).append(part.substring(1)); + } + } + return (sb.toString()); + } + + + + /******************************************************************************* + ** The security key types for per-bucket and per-connection scoping. + *******************************************************************************/ + public static List securityKeyTypes() + { + return (List.of( + new QSecurityKeyType().withName(KEY_TYPE_BUCKET).withAllAccessKeyName(ALL_ACCESS_BUCKET).withPossibleValueSourceName(BUCKET), + new QSecurityKeyType().withName(KEY_TYPE_CONNECTION).withAllAccessKeyName(ALL_ACCESS_CONN).withPossibleValueSourceName(CONNECTION))); + } + + + + /** Parent to child relations: {parent, child, foreign key on the child, label of the child list}. */ + private static final List RELATIONS = List.of( + new String[] { CONNECTION, BUCKET, "connectionId", "Buckets" }, + new String[] { CONNECTION, CONNECTION_CAPABILITY, "connectionId", "Capabilities" }, + new String[] { BUCKET, BUCKET_CAPABILITY, "bucketId", "Capabilities" }, + new String[] { BUCKET, FOLDER, "bucketId", "Folders" }, + new String[] { BUCKET, OBJECT, "bucketId", "Objects" }, + new String[] { BUCKET, OBJECT_REFERENCE, "bucketId", "References" }, + new String[] { BUCKET, USAGE_DAILY, "bucketId", "Usage" }, + new String[] { BUCKET, OPERATION, "bucketId", "Operations" }, + new String[] { BUCKET, HEADER_RULE, "bucketId", "Header rules" }, + new String[] { BUCKET, PROTECTION_RULE, "bucketId", "Protection rules" }, + new String[] { FOLDER, OBJECT, "folderId", "Objects" }, + new String[] { OBJECT, OBJECT_VERSION, "objectId", "Versions" }, + new String[] { OBJECT, OBJECT_REFERENCE, "objectId", "Referenced by" }); + + /** Tables that are nav entries (the rest appear as relations and child lists on their parents). */ + public static final List NAV_TABLES = List.of(BUCKET, OBJECT, FOLDER, CONNECTION, OPERATION, HEADER_RULE, PROTECTION_RULE); + + + + /******************************************************************************* + ** All tables, with their child lists exposed (relations on the parent record). + *******************************************************************************/ + public static List tables(ObjectStorageQBitConfig config) + { + List tables = new ArrayList<>(List.of(connection(config), connectionCapability(config), bucket(config), bucketCapability(config), + folder(config), object(config), objectVersion(config), objectReference(config), headerRule(config), protectionRule(config), operation(config), usageDaily(config))); + for(QTableMetaData table : tables) + { + layout(table); + QInstanceEnricher.setInferredFieldBackendNames(table); + } + for(String[] relation : RELATIONS) + { + for(QTableMetaData table : tables) + { + if(table.getName().equals(relation[0])) + { + table.withSection(new QFieldSection(joinName(relation), new QIcon("list"), Tier.T2).withLabel(relation[3]).withWidgetName(joinName(relation))); + table.withExposedJoin(new ExposedJoin().withLabel(relation[3]).withJoinTable(relation[1]).withJoinPath(List.of(joinName(relation)))); + } + } + } + return (tables); + } + + + + /** Record screen sections per table: "T1" identity, "T2:Label" detail groups, "T3" bookkeeping. The first name of each row is the section. */ + private static final Map LAYOUTS = Map.ofEntries( + Map.entry(CONNECTION, new String[][] { + { "T1", "name", "providerType", "source", "lastTestedAt", "lastTestStatus" }, + { "T2:Endpoint and signing", "endpoint", "presignEndpoint", "region", "addressingStyle", "checksumMode", "payloadSigning", "signatureVersion", "tlsVerify", "credentialsMode", "adminApiType" }, + { "T2:Credentials (references only, never values)", "accessKeyRef", "secretKeyRef", "presignAccessKeyRef", "presignSecretKeyRef", "credentialsResolved", "presignKeyConfigured", "presignWarning" }, + { "T2:Limits (editable with object_storage.admin)", "maxOpsPerSecond", "maxConcurrency", "connectTimeoutMs", "readTimeoutMs" }, + { "T2:Last test", "lastTestSummary", "clockSkewSeconds" }, + { "T3", "settingsHash", "id", "createDate", "modifyDate" } }), + Map.entry(BUCKET, new String[][] { + { "T1", "alias", "role", "connectionId", "bucketName", "indexed", "managedBy", "referenceTracked", "source" }, + { "T2:Observed settings", "versioningState", "lifecycleSummary", "policySummary", "corsSummary", "configDriftNote" }, + { "T2:Index and health", "objectCount", "totalBytes", "lastScanDate", "lastScanStatus", "headersKnownCount", "headersMatchingPolicyCount", "orphanedCount", "orphanedBytes", "missingCount", "lastReferenceScanDate" }, + { "T2:Settings", "publicBaseUrl", "trashBucketAlias", "trashPrefix", "maxPresignGetSeconds", "maxPresignPutSeconds", "orphanCountAlertThreshold", "quotaBytes" }, + { "T3", "id", "scanGeneration", "scanCursor", "createDate", "modifyDate" } }), + Map.entry(OBJECT, new String[][] { + { "T1", "previewUrl", "objectKey", "bucketId", "folderId", "size", "contentType", "lastModified", "referenceCount" }, + { "T2:Headers and metadata", "cacheControl", "contentDisposition", "userMetadata", "tags", "matchesHeaderPolicy", "headersKnown" }, + { "T2:Storage", "name", "extension", "etag", "storageClass", "versionId", "protection", "deleted" }, + { "T3", "id", "scanGeneration", "indexedAt", "createDate", "modifyDate" } }), + Map.entry(FOLDER, new String[][] { + { "T1", "path", "name", "bucketId", "parentFolderId", "objectCount", "totalBytes", "newestChange" }, + { "T3", "id", "createDate", "modifyDate" } }), + Map.entry(OBJECT_VERSION, new String[][] { + { "T1", "objectKey", "bucketId", "objectId", "versionId", "isDeleteMarker", "isLatest", "size", "lastModified" }, + { "T3", "id", "createDate", "modifyDate" } }), + Map.entry(OBJECT_REFERENCE, new String[][] { + { "T1", "label", "bucketId", "objectId", "keyOrPrefix", "matchMode", "missing" }, + { "T2:Referencing record", "sourceTable", "sourceRecordId", "sourceField", "scannedAt" }, + { "T3", "id", "createDate", "modifyDate" } }), + Map.entry(HEADER_RULE, new String[][] { + { "T1", "name", "bucketId", "enabled", "priority" }, + { "T2:Matches", "keyGlob", "keyRegex", "contentTypePattern" }, + { "T2:Headers to set", "cacheControl", "contentDisposition" }, + { "T3", "id", "createDate", "modifyDate" } }), + Map.entry(PROTECTION_RULE, new String[][] { + { "T1", "bucketId", "prefixGlob", "mode", "enabled" }, + { "T2:Why", "reason", "allowedSystemCallers" }, + { "T3", "id", "createDate", "modifyDate" } }), + Map.entry(OPERATION, new String[][] { + { "T1", "occurredAt", "action", "bucketAlias", "objectKey", "destinationKey", "result", "actor" }, + { "T2:Detail", "bytes", "errorMessage", "providerRequestId", "processRunId", "detail", "bucketId" }, + { "T3", "id", "createDate", "modifyDate" } }), + Map.entry(USAGE_DAILY, new String[][] { + { "T1", "bucketId", "snapshotDate", "objectCount", "totalBytes" }, + { "T2:By top-level folder", "byPrefix" }, + { "T3", "id", "createDate", "modifyDate" } }), + Map.entry(CONNECTION_CAPABILITY, new String[][] { + { "T1", "connectionId", "capability", "status", "source", "probedAt" }, + { "T2:Detail", "detail" }, + { "T3", "id", "createDate", "modifyDate" } }), + Map.entry(BUCKET_CAPABILITY, new String[][] { + { "T1", "bucketId", "capability", "status", "source", "probedAt" }, + { "T2:Detail", "detail" }, + { "T3", "id", "createDate", "modifyDate" } })); + + + + /******************************************************************************* + ** Give a table its record-screen sections (identity first, then detail groups, + ** then bookkeeping) and take the buttons away from tables that change only + ** through actions or from code config. + *******************************************************************************/ + private static void layout(QTableMetaData table) + { + String[][] rows = LAYOUTS.get(table.getName()); + if(rows != null) + { + for(String[] row : rows) + { + String kind = row[0]; + String[] fields = java.util.Arrays.copyOfRange(row, 1, row.length); + if(kind.equals("T1")) + { + table.withSection(SectionFactory.defaultT1(fields)); + } + else if(kind.equals("T3")) + { + table.withSection(SectionFactory.defaultT3(fields)); + } + else + { + String label = kind.substring(kind.indexOf(':') + 1); + table.withSection(new QFieldSection(label.toLowerCase().replaceAll("[^a-z0-9]+", "_"), label, new QIcon("list"), Tier.T2, List.of(fields))); + } + } + } + if(READ_ONLY.contains(table.getName())) + { + table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE); + } + else if(CONNECTION.equals(table.getName()) || BUCKET.equals(table.getName())) + { + table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_DELETE); + } + } + + + + + /******************************************************************************* + ** The join metadata for every parent/child relation. + *******************************************************************************/ + public static List joins() + { + List joins = new ArrayList<>(); + for(String[] relation : RELATIONS) + { + joins.add(new QJoinMetaData().withName(joinName(relation)).withLeftTable(relation[0]).withRightTable(relation[1]).withType(JoinType.ONE_TO_MANY) + .withJoinOn(new JoinOn("id", relation[2]))); + } + return (joins); + } + + + + /******************************************************************************* + ** The child record lists shown on a parent's record screen: a bucket shows its + ** folders, objects, references, usage, operations and rules; a folder and an + ** object show what is beneath or about them. + *******************************************************************************/ + public static List childListWidgets() + { + List widgets = new ArrayList<>(); + for(String[] relation : RELATIONS) + { + QJoinMetaData join = new QJoinMetaData().withName(joinName(relation)).withLeftTable(relation[0]).withRightTable(relation[1]).withType(JoinType.ONE_TO_MANY) + .withJoinOn(new JoinOn("id", relation[2])); + widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(join).withName(joinName(relation)).withLabel(relation[3]).withMaxRows(10).getWidgetMetaData()); + } + return (widgets); + } + + + + /******************************************************************************* + ** A unique join name per relation. + *******************************************************************************/ + private static String joinName(String[] relation) + { + return (relation[0] + "_" + relation[2] + "_" + relation[1]); + } + + + + /******************************************************************************* + ** A table with the common id/createDate/modifyDate fields. + *******************************************************************************/ + private static QTableMetaData base(ObjectStorageQBitConfig config, String name, String label, String icon) + { + return (new QTableMetaData() + .withName(name) + .withLabel(label) + .withIcon(new QIcon(icon)) + .withBackendName(config.getBackendName()) + .withPrimaryKeyField("id") + .withPermissionRules(permissionRules()) + .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false)) + .withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withIsEditable(false)) + .withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withIsEditable(false))); + } + + + + /******************************************************************************* + ** A read-only field. + *******************************************************************************/ + private static QFieldMetaData ro(String name, QFieldType type) + { + return (new QFieldMetaData(name, type).withIsEditable(false)); + } + + + + /******************************************************************************* + ** A read-only string field with a max length. + *******************************************************************************/ + private static QFieldMetaData ros(String name, int max) + { + return (ro(name, QFieldType.STRING).withMaxLength(max)); + } + + + + /******************************************************************************* + ** An editable string field. + *******************************************************************************/ + private static QFieldMetaData str(String name, int max) + { + return (new QFieldMetaData(name, QFieldType.STRING).withMaxLength(max)); + } + + + + /******************************************************************************* + ** Apply a record security lock on a field (when scoping is on). + *******************************************************************************/ + private static QTableMetaData lock(ObjectStorageQBitConfig config, QTableMetaData table, String keyType, String fieldName, boolean nullAllowed) + { + if(Boolean.TRUE.equals(config.getBucketScoping())) + { + table.withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType(keyType).withFieldName(fieldName) + .withNullValueBehavior(nullAllowed ? RecordSecurityLock.NullValueBehavior.ALLOW : RecordSecurityLock.NullValueBehavior.DENY)); + } + return (table); + } + + + + /******************************************************************************* + ** object_storage_connection: one S3 endpoint and its settings. Identity + ** fields come from code config and are read-only; only operational fields are + ** editable (with object_storage.admin). + *******************************************************************************/ + static QTableMetaData connection(ObjectStorageQBitConfig config) + { + return (lock(config, base(config, CONNECTION, "Storage Connection", "cloud") + .withRecordLabelFormatAndFields("%s", "name") + .withField(ros("name", 100)) + .withField(ro("source", QFieldType.STRING).withPossibleValueSourceName(PVS_SOURCE)) + .withField(ro("providerType", QFieldType.STRING).withPossibleValueSourceName(PVS_PROVIDER_TYPE)) + .withField(ros("endpoint", 500)) + .withField(ros("presignEndpoint", 500)) + .withField(ros("region", 100)) + .withField(ros("addressingStyle", 20)) + .withField(ros("signatureVersion", 10)) + .withField(ros("checksumMode", 20)) + .withField(ros("payloadSigning", 30)) + .withField(ro("tlsVerify", QFieldType.BOOLEAN)) + .withField(ros("credentialsMode", 20)) + .withField(ros("accessKeyRef", 200)) + .withField(ros("secretKeyRef", 200)) + .withField(ros("presignAccessKeyRef", 200)) + .withField(ros("presignSecretKeyRef", 200)) + .withField(ro("credentialsResolved", QFieldType.BOOLEAN)) + .withField(ro("presignKeyConfigured", QFieldType.BOOLEAN)) + .withField(ro("presignWarning", QFieldType.STRING).withMaxLength(300)) + .withField(ros("adminApiType", 20)) + .withField(new QFieldMetaData("maxOpsPerSecond", QFieldType.INTEGER).withDefaultValue(20)) + .withField(new QFieldMetaData("maxConcurrency", QFieldType.INTEGER).withDefaultValue(4)) + .withField(new QFieldMetaData("connectTimeoutMs", QFieldType.INTEGER).withDefaultValue(5000)) + .withField(new QFieldMetaData("readTimeoutMs", QFieldType.INTEGER).withDefaultValue(60000)) + .withField(ro("lastTestedAt", QFieldType.DATE_TIME)) + .withField(ros("lastTestStatus", 30)) + .withField(ro("lastTestSummary", QFieldType.TEXT)) + .withField(ro("clockSkewSeconds", QFieldType.INTEGER)) + .withField(ros("settingsHash", 40)), KEY_TYPE_CONNECTION, "id", false)); + } + + + + /******************************************************************************* + ** object_storage_connection_capability. + *******************************************************************************/ + static QTableMetaData connectionCapability(ObjectStorageQBitConfig config) + { + return (lock(config, base(config, CONNECTION_CAPABILITY, "Storage Connection Capability", "fact_check") + .withRecordLabelFormatAndFields("%s", "capability") + .withField(ro("connectionId", QFieldType.INTEGER).withPossibleValueSourceName(CONNECTION)) + .withField(ro("capability", QFieldType.STRING).withPossibleValueSourceName(PVS_CAPABILITY)) + .withField(ro("status", QFieldType.STRING).withPossibleValueSourceName(PVS_CAP_STATUS)) + .withField(ro("detail", QFieldType.TEXT)) + .withField(ro("source", QFieldType.STRING).withPossibleValueSourceName(PVS_CAP_SOURCE)) + .withField(ro("probedAt", QFieldType.DATE_TIME)), KEY_TYPE_CONNECTION, "connectionId", false)); + } + + + + /******************************************************************************* + ** object_storage_bucket: an alias, its config, and the observed state. + *******************************************************************************/ + static QTableMetaData bucket(ObjectStorageQBitConfig config) + { + return (lock(config, base(config, BUCKET, "Storage Bucket", "inventory_2") + .withRecordLabelFormatAndFields("%s", "alias") + .withField(ros("alias", 100)) + .withField(ro("connectionId", QFieldType.INTEGER).withPossibleValueSourceName(CONNECTION)) + .withField(ros("bucketName", 200)) + .withField(ro("source", QFieldType.STRING).withPossibleValueSourceName(PVS_SOURCE)) + .withField(ro("role", QFieldType.STRING).withPossibleValueSourceName(PVS_BUCKET_ROLE)) + .withField(ro("publicBaseUrl", QFieldType.STRING).withMaxLength(500)) + .withField(ro("indexed", QFieldType.BOOLEAN)) + .withField(ro("managedBy", QFieldType.STRING).withPossibleValueSourceName(PVS_MANAGED_BY)) + .withField(ros("trashBucketAlias", 100)) + .withField(ros("trashPrefix", 200)) + .withField(new QFieldMetaData("maxPresignGetSeconds", QFieldType.INTEGER).withDefaultValue(3600)) + .withField(new QFieldMetaData("maxPresignPutSeconds", QFieldType.INTEGER).withDefaultValue(900)) + .withField(ro("referenceTracked", QFieldType.BOOLEAN)) + .withField(new QFieldMetaData("orphanCountAlertThreshold", QFieldType.INTEGER)) + .withField(new QFieldMetaData("quotaBytes", QFieldType.LONG)) + .withField(ro("versioningState", QFieldType.STRING).withMaxLength(30)) + .withField(ro("lifecycleSummary", QFieldType.TEXT)) + .withField(ro("policySummary", QFieldType.TEXT)) + .withField(ro("corsSummary", QFieldType.TEXT)) + .withField(ro("configDriftNote", QFieldType.TEXT)) + .withField(ro("objectCount", QFieldType.LONG)) + .withField(ro("totalBytes", QFieldType.LONG)) + .withField(ro("lastScanDate", QFieldType.DATE_TIME)) + .withField(ros("lastScanStatus", 30)) + .withField(ro("scanGeneration", QFieldType.LONG)) + .withField(ro("scanCursor", QFieldType.STRING).withMaxLength(1100)) + .withField(ro("headersKnownCount", QFieldType.LONG)) + .withField(ro("headersMatchingPolicyCount", QFieldType.LONG)) + .withField(ro("orphanedCount", QFieldType.LONG)) + .withField(ro("orphanedBytes", QFieldType.LONG)) + .withField(ro("missingCount", QFieldType.LONG)) + .withField(ro("lastReferenceScanDate", QFieldType.DATE_TIME)), KEY_TYPE_BUCKET, "id", false)); + } + + + + /******************************************************************************* + ** object_storage_bucket_capability. + *******************************************************************************/ + static QTableMetaData bucketCapability(ObjectStorageQBitConfig config) + { + return (lock(config, base(config, BUCKET_CAPABILITY, "Storage Bucket Capability", "fact_check") + .withRecordLabelFormatAndFields("%s", "capability") + .withField(ro("bucketId", QFieldType.INTEGER).withPossibleValueSourceName(BUCKET)) + .withField(ro("capability", QFieldType.STRING).withPossibleValueSourceName(PVS_CAPABILITY)) + .withField(ro("status", QFieldType.STRING).withPossibleValueSourceName(PVS_CAP_STATUS)) + .withField(ro("detail", QFieldType.TEXT)) + .withField(ro("source", QFieldType.STRING).withPossibleValueSourceName(PVS_CAP_SOURCE)) + .withField(ro("probedAt", QFieldType.DATE_TIME)), KEY_TYPE_BUCKET, "bucketId", false)); + } + + + + /******************************************************************************* + ** object_storage_folder: a prefix in an indexed bucket, rolled up. + *******************************************************************************/ + static QTableMetaData folder(ObjectStorageQBitConfig config) + { + return (lock(config, base(config, FOLDER, "Storage Folder", "folder") + .withRecordLabelFormatAndFields("%s", "path") + .withField(ro("bucketId", QFieldType.INTEGER).withPossibleValueSourceName(BUCKET)) + .withField(ro("parentFolderId", QFieldType.INTEGER).withPossibleValueSourceName(FOLDER)) + .withField(ros("path", 1100)) + .withField(ros("name", 300)) + .withField(ro("objectCount", QFieldType.LONG)) + .withField(ro("totalBytes", QFieldType.LONG)) + .withField(ro("newestChange", QFieldType.DATE_TIME)), KEY_TYPE_BUCKET, "bucketId", false)); + } + + + + /******************************************************************************* + ** object_storage_object: one object in an indexed bucket. Mirrors S3 and is + ** read-only; changes go through actions. + *******************************************************************************/ + static QTableMetaData object(ObjectStorageQBitConfig config) + { + return (lock(config, base(config, OBJECT, "Storage Object", "description") + .withRecordLabelFormatAndFields("%s", "objectKey") + .withField(ro("bucketId", QFieldType.INTEGER).withPossibleValueSourceName(BUCKET)) + .withField(ro("folderId", QFieldType.INTEGER).withPossibleValueSourceName(FOLDER)) + .withField(ros("objectKey", 1100)) + .withField(ros("name", 300)) + .withField(ros("extension", 30)) + .withField(ro("size", QFieldType.LONG)) + .withField(ros("contentType", 200)) + .withField(ros("etag", 100)) + .withField(ro("lastModified", QFieldType.DATE_TIME)) + .withField(ros("storageClass", 40)) + .withField(ros("cacheControl", 300)) + .withField(ros("contentDisposition", 300)) + .withField(ro("userMetadata", QFieldType.TEXT)) + .withField(ro("tags", QFieldType.TEXT)) + .withField(ros("versionId", 200)) + .withField(ro("referenceCount", QFieldType.INTEGER)) + .withField(ros("protection", 30)) + .withField(ro("headersKnown", QFieldType.BOOLEAN)) + .withField(ro("matchesHeaderPolicy", QFieldType.BOOLEAN)) + .withField(ro("deleted", QFieldType.BOOLEAN)) + .withField(ro("scanGeneration", QFieldType.LONG)) + .withField(ro("indexedAt", QFieldType.DATE_TIME)) + .withField(ro("previewUrl", QFieldType.STRING).withMaxLength(2000).withLabel("Preview") + .withFieldAdornment(AdornmentType.FilePreviewValues.newFieldAdornment() + .withValue(AdornmentType.FilePreviewValues.FILE_NAME_FIELD, "name") + .withValue(AdornmentType.FilePreviewValues.MIME_TYPE_FIELD, "contentType") + .withValue(AdornmentType.FilePreviewValues.SIZE_BYTES_FIELD, "size") + .withValue(AdornmentType.FilePreviewValues.THUMBNAIL_MAX_SIZE, 48))) + .withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(ObjectPreviewCustomizer.class)), KEY_TYPE_BUCKET, "bucketId", false)); + } + + + + /******************************************************************************* + ** object_storage_object_version: a non-current version or delete marker. + *******************************************************************************/ + static QTableMetaData objectVersion(ObjectStorageQBitConfig config) + { + return (lock(config, base(config, OBJECT_VERSION, "Storage Object Version", "history") + .withRecordLabelFormatAndFields("%s", "objectKey") + .withField(ro("bucketId", QFieldType.INTEGER).withPossibleValueSourceName(BUCKET)) + .withField(ro("objectId", QFieldType.INTEGER).withPossibleValueSourceName(OBJECT)) + .withField(ros("objectKey", 1100)) + .withField(ros("versionId", 200)) + .withField(ro("isDeleteMarker", QFieldType.BOOLEAN)) + .withField(ro("isLatest", QFieldType.BOOLEAN)) + .withField(ro("size", QFieldType.LONG)) + .withField(ro("lastModified", QFieldType.DATE_TIME)), KEY_TYPE_BUCKET, "bucketId", false)); + } + + + + /******************************************************************************* + ** object_storage_object_reference: a record that points at a key or prefix. + ** objectId is null when the referenced object does not exist (missing). + *******************************************************************************/ + static QTableMetaData objectReference(ObjectStorageQBitConfig config) + { + return (lock(config, base(config, OBJECT_REFERENCE, "Storage Object Reference", "link") + .withRecordLabelFormatAndFields("%s", "label") + .withField(ro("bucketId", QFieldType.INTEGER).withPossibleValueSourceName(BUCKET)) + .withField(ro("objectId", QFieldType.INTEGER).withPossibleValueSourceName(OBJECT)) + .withField(ros("keyOrPrefix", 1100)) + .withField(ros("matchMode", 10)) + .withField(ros("sourceTable", 100)) + .withField(ros("sourceRecordId", 100)) + .withField(ros("sourceField", 100)) + .withField(ros("label", 300)) + .withField(ro("missing", QFieldType.BOOLEAN)) + .withField(ro("scannedAt", QFieldType.DATE_TIME)), KEY_TYPE_BUCKET, "bucketId", false)); + } + + + + /******************************************************************************* + ** object_storage_header_rule: header policy rules (ordinary audited data). + *******************************************************************************/ + static QTableMetaData headerRule(ObjectStorageQBitConfig config) + { + return (lock(config, base(config, HEADER_RULE, "Storage Header Rule", "rule") + .withRecordLabelFormatAndFields("%s", "name") + .withField(str("name", 100).withIsRequired(true)) + .withField(new QFieldMetaData("bucketId", QFieldType.INTEGER).withPossibleValueSourceName(BUCKET)) + .withField(str("keyGlob", 500)) + .withField(str("keyRegex", 500)) + .withField(str("contentTypePattern", 200)) + .withField(str("cacheControl", 300)) + .withField(str("contentDisposition", 300)) + .withField(new QFieldMetaData("priority", QFieldType.INTEGER).withDefaultValue(100)) + .withField(new QFieldMetaData("enabled", QFieldType.BOOLEAN).withDefaultValue(true)), KEY_TYPE_BUCKET, "bucketId", true)); + } + + + + /******************************************************************************* + ** object_storage_protection_rule: a protected bucket/prefix. + *******************************************************************************/ + static QTableMetaData protectionRule(ObjectStorageQBitConfig config) + { + return (lock(config, base(config, PROTECTION_RULE, "Storage Protection Rule", "shield") + .withRecordLabelFormatAndFields("%s", "prefixGlob") + .withField(new QFieldMetaData("bucketId", QFieldType.INTEGER).withPossibleValueSourceName(BUCKET).withIsRequired(true)) + .withField(str("prefixGlob", 500).withIsRequired(true)) + .withField(new QFieldMetaData("mode", QFieldType.STRING).withPossibleValueSourceName(PVS_PROTECTION).withIsRequired(true).withDefaultValue("NO_DELETE")) + .withField(str("reason", 500)) + .withField(str("allowedSystemCallers", 300)) + .withField(new QFieldMetaData("enabled", QFieldType.BOOLEAN).withDefaultValue(true)), KEY_TYPE_BUCKET, "bucketId", false)); + } + + + + /******************************************************************************* + ** object_storage_operation: the storage audit log, written only by the service. + *******************************************************************************/ + static QTableMetaData operation(ObjectStorageQBitConfig config) + { + return (lock(config, base(config, OPERATION, "Storage Operation", "history") + .withRecordLabelFormatAndFields("%s %s", "action", "objectKey") + .withField(ro("occurredAt", QFieldType.DATE_TIME)) + .withField(ros("actor", 300)) + .withField(ro("action", QFieldType.STRING).withPossibleValueSourceName(PVS_OPERATION)) + .withField(ro("bucketId", QFieldType.INTEGER).withPossibleValueSourceName(BUCKET)) + .withField(ros("bucketAlias", 100)) + .withField(ros("objectKey", 1100)) + .withField(ros("destinationKey", 1100)) + .withField(ro("bytes", QFieldType.LONG)) + .withField(ro("result", QFieldType.STRING).withPossibleValueSourceName(PVS_RESULT)) + .withField(ro("errorMessage", QFieldType.TEXT)) + .withField(ros("providerRequestId", 100)) + .withField(ro("processRunId", QFieldType.STRING).withMaxLength(100)) + .withField(ro("detail", QFieldType.TEXT)), KEY_TYPE_BUCKET, "bucketId", true)); + } + + + + /******************************************************************************* + ** object_storage_usage_daily: one snapshot per bucket per day. + *******************************************************************************/ + static QTableMetaData usageDaily(ObjectStorageQBitConfig config) + { + return (lock(config, base(config, USAGE_DAILY, "Storage Usage", "insights") + .withRecordLabelFormatAndFields("%s", "snapshotDate") + .withField(ro("bucketId", QFieldType.INTEGER).withPossibleValueSourceName(BUCKET)) + .withField(ro("snapshotDate", QFieldType.DATE)) + .withField(ro("objectCount", QFieldType.LONG)) + .withField(ro("totalBytes", QFieldType.LONG)) + .withField(ro("byPrefix", QFieldType.TEXT)), KEY_TYPE_BUCKET, "bucketId", false)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/permissions/ObjectStoragePermissionChecker.java b/src/main/java/com/kingsrook/qbits/objectstorage/permissions/ObjectStoragePermissionChecker.java new file mode 100644 index 0000000..c93b933 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/permissions/ObjectStoragePermissionChecker.java @@ -0,0 +1,117 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.permissions; + + +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; + + +/******************************************************************************* + ** One checker for every qbit table, process, app and widget: + ** - table reads, the app and widgets need object_storage.view; + ** - writes to the editable config tables (connection operational fields, header + ** rules, protection rules, bucket thresholds) need object_storage.admin; + ** observed and history tables are never written through the UI or API (they + ** change only through actions and the service); + ** - each process needs the permission registered for it (default admin). + *******************************************************************************/ +public class ObjectStoragePermissionChecker implements CustomPermissionChecker +{ + private static final List ADMIN_EDITABLE = List.of(ObjectStorageTables.CONNECTION, ObjectStorageTables.BUCKET, + ObjectStorageTables.HEADER_RULE, ObjectStorageTables.PROTECTION_RULE); + + + + /******************************************************************************* + ** Check, throwing when denied. + *******************************************************************************/ + @Override + public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaData) throws QPermissionDeniedException + { + String needed = required(actionInput, metaData); + if(needed == null) + { + throw (new QPermissionDeniedException("This storage table changes only through storage actions.")); + } + if(!has(needed)) + { + throw (new QPermissionDeniedException("This needs the " + needed + " permission.")); + } + } + + + + /******************************************************************************* + ** The permission an action needs, or null when nobody may do it directly. + *******************************************************************************/ + static String required(AbstractActionInput actionInput, MetaDataWithPermissionRules metaData) + { + boolean write = actionInput instanceof InsertInput || actionInput instanceof UpdateInput || actionInput instanceof DeleteInput; + if(metaData instanceof QTableMetaData table) + { + if(!write) + { + return (ObjectStoragePermissions.VIEW); + } + return (ADMIN_EDITABLE.contains(table.getName()) ? ObjectStoragePermissions.ADMIN : null); + } + if(metaData instanceof QProcessMetaData process) + { + Map byProcess = ObjectStorageRuntime.processPermissions(); + return (byProcess.getOrDefault(process.getName(), ObjectStoragePermissions.ADMIN)); + } + return (ObjectStoragePermissions.VIEW); + } + + + + /******************************************************************************* + ** Whether the current session holds a permission (admin implies the others), + ** or the instance is fully anonymous and the config allows that (local dev). + *******************************************************************************/ + public static boolean has(String permission) + { + QInstance instance = QContext.getQInstance(); + QSession session = QContext.getQSession(); + if(session != null && (session.hasPermission(permission) || session.hasPermission(ObjectStoragePermissions.ADMIN))) + { + return (true); + } + return (ObjectStorageRuntime.isInstalled() && Boolean.TRUE.equals(ObjectStorageRuntime.getConfig().getOpenWhenAnonymous()) + && instance != null && instance.getAuthentication() != null && QAuthenticationType.FULLY_ANONYMOUS.equals(instance.getAuthentication().getType())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/permissions/ObjectStoragePermissions.java b/src/main/java/com/kingsrook/qbits/objectstorage/permissions/ObjectStoragePermissions.java new file mode 100644 index 0000000..f8e6575 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/permissions/ObjectStoragePermissions.java @@ -0,0 +1,46 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.permissions; + + +/******************************************************************************* + ** The permission names. object_storage.admin implies the others. + *******************************************************************************/ +public final class ObjectStoragePermissions +{ + /** Read all storage tables; preview, download, presign GET. */ + public static final String VIEW = "object_storage.view"; + /** Upload, copy, move/rename, change headers and metadata, apply header policy, presign PUT, restore. */ + public static final String WRITE = "object_storage.write"; + /** Delete objects (soft where possible). */ + public static final String DELETE = "object_storage.delete"; + /** Edit connections (operational fields), header rules, protection rules; write probes; permanent deletes; runs over the bulk cap. */ + public static final String ADMIN = "object_storage.admin"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private ObjectStoragePermissions() + { + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/processes/ActionSupport.java b/src/main/java/com/kingsrook/qbits/objectstorage/processes/ActionSupport.java new file mode 100644 index 0000000..cf41e54 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/processes/ActionSupport.java @@ -0,0 +1,292 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.processes; + + +import java.io.Serializable; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.objectstorage.api.ObjectStorage; +import com.kingsrook.qbits.objectstorage.api.StorageBucket; +import com.kingsrook.qbits.objectstorage.api.StorageCaller; +import com.kingsrook.qbits.objectstorage.api.StoredObject; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.internal.ConfigSync; +import com.kingsrook.qbits.objectstorage.internal.Db; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.internal.ProtectionRules; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qbits.objectstorage.permissions.ObjectStoragePermissionChecker; +import com.kingsrook.qbits.objectstorage.permissions.ObjectStoragePermissions; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** Shared helpers for the record and table actions: resolving a row's bucket, + ** the skip rules every destructive action applies (referenced, protected, + ** object lock), permission re-checks, and per-run caches. + *******************************************************************************/ +public final class ActionSupport +{ + /******************************************************************************* + ** Why an action leaves an object alone. + *******************************************************************************/ + public enum Skip + { + NONE, + REFERENCED, + PROTECTED, + LOCKED, + BACKUP_BUCKET, + NO_BUCKET_ACCESS + } + + + + /******************************************************************************* + ** A classification: the reason, and a human detail for the review line. + *******************************************************************************/ + public record Classification(Skip skip, String detail) + { + /******************************************************************************* + ** Nothing in the way. + *******************************************************************************/ + public static Classification none() + { + return (new Classification(Skip.NONE, null)); + } + } + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private ActionSupport() + { + } + + + + /******************************************************************************* + ** The alias of the bucket an object, folder or reference row belongs to. + *******************************************************************************/ + public static String aliasOf(QRecord row) + { + Integer bucketId = row.getValueInteger("bucketId"); + if(bucketId == null && row.getValueString("alias") != null) + { + return (row.getValueString("alias")); + } + String alias = ConfigSync.aliasOf(bucketId); + if(alias == null && bucketId != null) + { + try + { + QRecord bucket = Db.asSystem(() -> Db.first(ObjectStorageTables.BUCKET, "id", bucketId)); + alias = bucket == null ? null : bucket.getValueString("alias"); + } + catch(QException e) + { + alias = null; + } + } + return (alias); + } + + + + /******************************************************************************* + ** The configured bucket (null when the row's bucket is not in this config). + *******************************************************************************/ + public static StorageBucketConfig config(String alias) + { + return (alias == null ? null : ObjectStorageRuntime.getConfig().findBucket(alias)); + } + + + + /******************************************************************************* + ** Whether the current user holds a permission (admin implies the rest). + *******************************************************************************/ + public static boolean has(String permission) + { + return (ObjectStoragePermissionChecker.has(permission)); + } + + + + /******************************************************************************* + ** Whether the current user is an admin. + *******************************************************************************/ + public static boolean isAdmin() + { + return (has(ObjectStoragePermissions.ADMIN)); + } + + + + /******************************************************************************* + ** Whether the current user can see a bucket row (the per-bucket record lock is + ** applied by the query). The load step uses this before each provider call. + *******************************************************************************/ + public static boolean canSeeBucket(Integer bucketId, Map cache) + { + if(bucketId == null) + { + return (false); + } + return (cache.computeIfAbsent(bucketId, id -> + { + try + { + return (!new QueryAction().execute(new QueryInput(ObjectStorageTables.BUCKET).withFilter(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter(Db.eq("id", id)))).getRecords().isEmpty()); + } + catch(QException e) + { + return (false); + } + })); + } + + + + /******************************************************************************* + ** The service handle for a bucket alias, as the current user. + *******************************************************************************/ + public static StorageBucket storage(String alias) + { + return (ObjectStorage.bucket(alias)); + } + + + + /******************************************************************************* + ** Classify an object for a destructive action: BACKUP buckets are outside the + ** actions (backups manage their own objects); a referenced object is skipped + ** when asked; protection rules apply; an object under lock is skipped. + *******************************************************************************/ + public static Classification classify(QRecord row, ProtectionRules.Op op, boolean checkReferences, boolean checkLock) + { + String alias = aliasOf(row); + StorageBucketConfig config = config(alias); + if(config == null) + { + return (new Classification(Skip.NO_BUCKET_ACCESS, "its bucket is not in the configuration")); + } + if(config.getRole() == com.kingsrook.qbits.objectstorage.config.BucketRole.BACKUP) + { + return (new Classification(Skip.BACKUP_BUCKET, "backup buckets are managed by the backups qbit")); + } + if(checkReferences && row.getValueInteger("referenceCount") != null && row.getValueInteger("referenceCount") > 0) + { + return (new Classification(Skip.REFERENCED, "referenced by " + referencedBy(row))); + } + ProtectionRules.Result protection = ProtectionRules.check(alias, row.getValueString("objectKey"), op, StorageCaller.user()); + if(protection.blocked()) + { + return (new Classification(Skip.PROTECTED, protection.reason())); + } + if(checkLock) + { + try + { + StoredObject head = storage(alias).head(row.getValueString("objectKey")).orElse(null); + if(head != null && head.getLockRetainUntil() != null && head.getLockRetainUntil().isAfter(Instant.now())) + { + return (new Classification(Skip.LOCKED, "object lock until " + head.getLockRetainUntil())); + } + } + catch(RuntimeException e) + { + return (Classification.none()); + } + } + return (Classification.none()); + } + + + + /******************************************************************************* + ** The first few records that reference an object, for the review line. + *******************************************************************************/ + public static String referencedBy(QRecord row) + { + try + { + List refs = Db.asSystem(() -> Db.query(ObjectStorageTables.OBJECT_REFERENCE, "objectId", row.getValue("id"))); + List labels = new ArrayList<>(); + for(QRecord ref : refs) + { + labels.add(StringUtils.hasContent(ref.getValueString("label")) ? ref.getValueString("label") : ref.getValueString("sourceTable") + " #" + ref.getValueString("sourceRecordId")); + if(labels.size() == 3) + { + break; + } + } + int count = row.getValueInteger("referenceCount") == null ? refs.size() : row.getValueInteger("referenceCount"); + return (count + " record" + (count == 1 ? "" : "s") + (labels.isEmpty() ? "" : " (" + String.join(", ", labels) + (count > labels.size() ? ", ..." : "") + ")")); + } + catch(QException e) + { + return ("other records"); + } + } + + + + /******************************************************************************* + ** The object rows of a run, loaded by id when only a partial record came in. + *******************************************************************************/ + public static Map noValues() + { + return (new HashMap<>()); + } + + + + /******************************************************************************* + ** Bytes as KB, MB, GB. + *******************************************************************************/ + public static String bytes(long value) + { + if(value < 1024) + { + return (value + " B"); + } + String[] units = { "KB", "MB", "GB", "TB" }; + double v = value; + int u = -1; + while(v >= 1024 && u < units.length - 1) + { + v /= 1024; + u++; + } + return (String.format("%.1f %s", v, units[u])); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/processes/BaseLoad.java b/src/main/java/com/kingsrook/qbits/objectstorage/processes/BaseLoad.java new file mode 100644 index 0000000..17c611a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/processes/BaseLoad.java @@ -0,0 +1,146 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.processes; + + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import com.kingsrook.qbits.objectstorage.api.StorageException; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface; + + +/******************************************************************************* + ** The execute half of an action. Before each provider call it re-checks the + ** per-bucket lock for the record (defense in depth: the review already filtered, + ** but the state may have changed). A failure on one record is counted and listed + ** with the error; it never stops the others. Every write lands in + ** object_storage_operation through the service. + *******************************************************************************/ +public abstract class BaseLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface +{ + protected final Map bucketAccess = new HashMap<>(); + + protected final ProcessSummaryLine failed = EtlSupport.line(Status.ERROR, "record failed (see Operations for the error)", "records failed (see Operations for the errors)", + "record failed (see Operations for the error)", "records failed (see Operations for the errors)"); + + private final ArrayList failures = new ArrayList<>(); + + + + /******************************************************************************* + ** The bucket row id a record belongs to (the record's bucketId by default). + *******************************************************************************/ + protected Integer bucketIdOf(QRecord record) + { + return (record.getValueInteger("bucketId")); + } + + + + /******************************************************************************* + ** Do the work for one record. + *******************************************************************************/ + protected abstract void apply(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException; + + + + /******************************************************************************* + ** Add this action's own result lines. + *******************************************************************************/ + protected abstract void addLines(ArrayList lines); + + + + /******************************************************************************* + ** Re-check the bucket lock, run the work, count failures. + *******************************************************************************/ + @Override + public final void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + for(QRecord record : input.getRecords()) + { + Integer bucketId = bucketIdOf(record); + if(bucketId != null && !ActionSupport.canSeeBucket(bucketId, bucketAccess)) + { + failed.incrementCountAndAddPrimaryKey(record.getValue("id")); + failures.add("no access to the bucket"); + continue; + } + try + { + apply(record, input, output); + } + catch(StorageException e) + { + failed.incrementCountAndAddPrimaryKey(record.getValue("id")); + if(failures.size() < 5) + { + failures.add(String.valueOf(record.getValueString("objectKey")) + ": " + e.getMessage()); + } + } + catch(RuntimeException e) + { + failed.incrementCountAndAddPrimaryKey(record.getValue("id")); + if(failures.size() < 5) + { + failures.add(String.valueOf(record.getValueString("objectKey")) + ": " + e.getMessage()); + } + } + } + } + + + + /******************************************************************************* + ** Result lines: this action's, then the records that were left alone, then + ** failures. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + ArrayList lines = new ArrayList<>(); + addLines(lines); + if(getTransformStep() instanceof BaseTransform transform) + { + for(ProcessSummaryLine line : transform.skipLines()) + { + line.addSelfToListIfAnyCount(lines); + } + transform.overCap.addSelfToListIfAnyCount(lines); + transform.noAccess.addSelfToListIfAnyCount(lines); + } + if(failed.getCount() != null && failed.getCount() > 0) + { + failed.setBulletsOfText(new ArrayList<>(failures)); + failed.addSelfToListIfAnyCount(lines); + } + return (lines); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/processes/BaseTransform.java b/src/main/java/com/kingsrook/qbits/objectstorage/processes/BaseTransform.java new file mode 100644 index 0000000..a1f687d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/processes/BaseTransform.java @@ -0,0 +1,133 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.processes; + + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface; + + +/******************************************************************************* + ** The Review (dry run) half of an action. It is read-only: it classifies each + ** record, produces the summary lines, and passes the records that will be acted + ** on to the load step. It also applies the bulk cap (10,000 records per run + ** unless the user has object_storage.admin) and the per-bucket record lock. + *******************************************************************************/ +public abstract class BaseTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface +{ + protected final Map bucketAccess = new HashMap<>(); + + protected final ProcessSummaryLine overCap = EtlSupport.line(Status.ERROR, "record is over the bulk cap and needs object_storage.admin (skipped)", + "records are over the bulk cap and need object_storage.admin (skipped)", "record was over the bulk cap (skipped)", "records were over the bulk cap (skipped)"); + protected final ProcessSummaryLine noAccess = EtlSupport.line(Status.ERROR, "record is in a bucket you may not use (skipped)", "records are in buckets you may not use (skipped)", + "record was in a bucket you may not use (skipped)", "records were in buckets you may not use (skipped)"); + + private int processed = 0; + + + + /******************************************************************************* + ** The bucket row id a record belongs to (the record's bucketId by default). + *******************************************************************************/ + protected Integer bucketIdOf(QRecord record) + { + return (record.getValueInteger("bucketId")); + } + + + + /******************************************************************************* + ** Handle one record: classify it, count it, and add it to the output when it + ** will be acted on. + *******************************************************************************/ + protected abstract void handle(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException; + + + + /******************************************************************************* + ** Add this action's own summary lines. + *******************************************************************************/ + protected abstract void addLines(ArrayList lines); + + + + /******************************************************************************* + ** The lines about records that will be left alone, as the Result screen shows + ** them (the load step appends these). + *******************************************************************************/ + protected List skipLines() + { + return (List.of()); + } + + + + /******************************************************************************* + ** Apply the cap and the bucket lock, then hand each record on. + *******************************************************************************/ + @Override + public final void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + for(QRecord record : input.getRecords()) + { + processed++; + if(processed > ObjectStorageRuntime.getConfig().getBulkCap() && !ActionSupport.isAdmin()) + { + overCap.incrementCountAndAddPrimaryKey(record.getValue("id")); + continue; + } + Integer bucketId = bucketIdOf(record); + if(bucketId != null && !ActionSupport.canSeeBucket(bucketId, bucketAccess)) + { + noAccess.incrementCountAndAddPrimaryKey(record.getValue("id")); + continue; + } + handle(record, input, output); + } + } + + + + /******************************************************************************* + ** Review lines. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + ArrayList lines = new ArrayList<>(); + addLines(lines); + overCap.addSelfToListIfAnyCount(lines); + noAccess.addSelfToListIfAnyCount(lines); + return (lines); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/processes/BucketProcesses.java b/src/main/java/com/kingsrook/qbits/objectstorage/processes/BucketProcesses.java new file mode 100644 index 0000000..5d4c71e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/processes/BucketProcesses.java @@ -0,0 +1,742 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.processes; + + +import java.io.Serializable; +import java.net.URL; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import com.kingsrook.qbits.objectstorage.api.Capability; +import com.kingsrook.qbits.objectstorage.api.ObjectHeaders; +import com.kingsrook.qbits.objectstorage.api.PresignGet; +import com.kingsrook.qbits.objectstorage.api.PresignPut; +import com.kingsrook.qbits.objectstorage.api.StorageKeys; +import com.kingsrook.qbits.objectstorage.config.BucketRole; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.index.ObjectIndex; +import com.kingsrook.qbits.objectstorage.index.ReferenceScan; +import com.kingsrook.qbits.objectstorage.internal.CapabilityStore; +import com.kingsrook.qbits.objectstorage.internal.ConnectionRuntime; +import com.kingsrook.qbits.objectstorage.internal.Db; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.internal.ProtectionRules; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qbits.objectstorage.permissions.ObjectStoragePermissions; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import software.amazon.awssdk.services.s3.S3Client; + + +/******************************************************************************* + ** Bucket record actions: Rescan bucket, Check configuration, Apply header policy + ** (for a whole bucket), and Create upload link. Bucket creation and deletion + ** stay in infrastructure-as-code; "Check configuration" only observes, and says + ** when what it sees differs from what the config declares. + *******************************************************************************/ +public final class BucketProcesses +{ + public static final String RESCAN_NAME = "objectStorageRescanBucket"; + public static final String CHECK_NAME = "objectStorageCheckConfiguration"; + public static final String POLICY_NAME = "objectStorageApplyHeaderPolicyToBucket"; + public static final String UPLOAD_LINK = "objectStorageCreateUploadLink"; + + public static final String FIELD_LINK_KEY = "linkKey"; + public static final String FIELD_LINK_TYPE = "linkContentType"; + public static final String FIELD_LINK_LENGTH = "linkContentLength"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private BucketProcesses() + { + } + + + + /******************************************************************************* + ** The processes. + *******************************************************************************/ + public static List processes() + { + return (List.of( + EtlSupport.etl(RESCAN_NAME, "Rescan bucket", "refresh", ObjectStorageTables.BUCKET, RescanTransform.class, RescanLoad.class, + "This is a preview of the buckets that will be listed and re-indexed.", ObjectStoragePermissions.VIEW), + EtlSupport.etl(CHECK_NAME, "Check configuration", "fact_check", ObjectStorageTables.BUCKET, CheckTransform.class, CheckLoad.class, + "This is a preview of the buckets whose settings will be read (read-only calls).", ObjectStoragePermissions.VIEW), + EtlSupport.etl(POLICY_NAME, "Apply header policy", "rule", ObjectStorageTables.BUCKET, BucketPolicyTransform.class, BucketPolicyLoad.class, + "This is a preview of the objects in these buckets whose headers will be rewritten to match the header policy.", ObjectStoragePermissions.WRITE), + EtlSupport.withInput(EtlSupport.etl(UPLOAD_LINK, "Create upload link", "upload", ObjectStorageTables.BUCKET, UploadLinkTransform.class, UploadLinkLoad.class, + "This is a preview of the presigned upload link that will be created.", ObjectStoragePermissions.WRITE), + "Create upload link", "Creates a presigned PUT link for one object key. Anyone with the link can upload that one object until it expires; the content type and length are pinned when given.", + EtlSupport.stringField(FIELD_LINK_KEY, "Object key (for example uploads/photo.jpg)", true), EtlSupport.stringField(FIELD_LINK_TYPE, "Content type (pins the upload to this type)", false), + new QFieldMetaData(FIELD_LINK_LENGTH, QFieldType.LONG).withLabel("Content length in bytes (pins the upload to this size)")))); + } + + + + /******************************************************************************* + ** The bucket id of a bucket record is its own id. + *******************************************************************************/ + abstract static class BucketTransform extends BaseTransform + { + /******************************************************************************* + ** A bucket record's lock key is its id. + *******************************************************************************/ + @Override + protected Integer bucketIdOf(QRecord record) + { + return (record.getValueInteger("id")); + } + } + + + + /******************************************************************************* + ** The bucket id of a bucket record is its own id. + *******************************************************************************/ + abstract static class BucketLoad extends BaseLoad + { + /******************************************************************************* + ** A bucket record's lock key is its id. + *******************************************************************************/ + @Override + protected Integer bucketIdOf(QRecord record) + { + return (record.getValueInteger("id")); + } + } + + + + /******************************************************************************* + ** Rescan bucket, review. + *******************************************************************************/ + public static class RescanTransform extends BucketTransform + { + private final ProcessSummaryLine will = EtlSupport.line(Status.OK, "bucket will be listed and re-indexed", "buckets will be listed and re-indexed", "bucket was re-indexed", "buckets were re-indexed"); + private final ProcessSummaryLine notIndexed = EtlSupport.line(Status.INFO, "bucket is not indexed (skipped)", "buckets are not indexed (skipped)", "bucket was not indexed (skipped)", "buckets were not indexed (skipped)"); + private final ProcessSummaryLine running = EtlSupport.line(Status.WARNING, "bucket already has a scan running (skipped)", "buckets already have scans running (skipped)", + "bucket already had a scan running (skipped)", "buckets already had scans running (skipped)"); + private final List expected = new ArrayList<>(); + + + + /******************************************************************************* + ** Classify one bucket. + *******************************************************************************/ + @Override + protected void handle(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + String alias = record.getValueString("alias"); + StorageBucketConfig config = ActionSupport.config(alias); + if(config == null || !Boolean.TRUE.equals(config.getIndexed())) + { + notIndexed.incrementCountAndAddPrimaryKey(record.getValue("id")); + return; + } + if(ObjectIndex.isScanning(alias)) + { + running.incrementCountAndAddPrimaryKey(record.getValue("id")); + return; + } + will.incrementCountAndAddPrimaryKey(record.getValue("id")); + long objects = record.getValueLong("objectCount") == null ? 0 : record.getValueLong("objectCount"); + expected.add(alias + ": whole bucket; last scan " + (record.getValueInstant("lastScanDate") == null ? "never" : record.getValueInstant("lastScanDate")) + "; about " + (objects / 1000 + 1) + " list calls"); + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Review lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + will.addSelfToListIfAnyCount(lines); + if(will.getCount() != null && will.getCount() > 0) + { + will.setBulletsOfText(new ArrayList<>(expected)); + } + notIndexed.addSelfToListIfAnyCount(lines); + running.addSelfToListIfAnyCount(lines); + } + + + + /******************************************************************************* + ** The skips, as the Result screen shows them. + *******************************************************************************/ + @Override + protected List skipLines() + { + return (List.of(notIndexed, running)); + } + } + + + + /******************************************************************************* + ** Rescan bucket, execute: scan, HEAD backlog, references. + *******************************************************************************/ + public static class RescanLoad extends BucketLoad + { + private final ArrayList results = new ArrayList<>(); + + + + /******************************************************************************* + ** Scan one bucket. + *******************************************************************************/ + @Override + protected void apply(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + String alias = record.getValueString("alias"); + ObjectIndex.ScanResult scan = ObjectIndex.scan(alias); + boolean ok = "OK".equals(scan.status()); + results.add(new ProcessSummaryLine(ok ? Status.OK : Status.WARNING, alias + ": scan " + scan.status() + " - " + scan.seen() + " objects seen, " + scan.added() + " new, " + + scan.updated() + " updated, " + scan.removed() + " removed" + (scan.markedDeleted() > 0 ? ", " + scan.markedDeleted() + " marked deleted" : "") + + (scan.note() == null ? "" : ". " + scan.note()))); + if(ok) + { + int headed = ObjectIndex.headBacklog(alias, ObjectStorageRuntime.getConfig().getHeadBacklogPerRun()); + results.add(EtlSupport.text(Status.INFO, alias + ": headers read for " + headed + " objects")); + if(ReferenceScan.applies(ActionSupport.config(alias))) + { + ReferenceScan.Result r = ReferenceScan.run(alias); + results.add(new ProcessSummaryLine(r.missing() > 0 ? Status.WARNING : Status.INFO, alias + ": " + r.references() + " references, " + r.missing() + " missing, " + r.orphaned() + " orphaned objects")); + } + } + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Result lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + lines.addAll(results); + } + } + + + + /******************************************************************************* + ** Check configuration, review. + *******************************************************************************/ + public static class CheckTransform extends BucketTransform + { + private final ProcessSummaryLine will = EtlSupport.line(Status.OK, "bucket's versioning, lifecycle, policy and CORS will be read", "buckets' versioning, lifecycle, policy and CORS will be read", + "bucket's settings were read", "buckets' settings were read"); + + + + /******************************************************************************* + ** Count. + *******************************************************************************/ + @Override + protected void handle(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + will.incrementCountAndAddPrimaryKey(record.getValue("id")); + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Review lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + will.addSelfToListIfAnyCount(lines); + lines.add(EtlSupport.text(Status.INFO, "Buckets are created and configured in infrastructure-as-code. This only observes; it changes nothing.")); + } + } + + + + /******************************************************************************* + ** What the provider says a bucket is set to. + *******************************************************************************/ + public record Observed(String versioning, String lifecycle, String policy, String cors, boolean versioningEnabled, boolean hasLifecycle, boolean hasPolicy, boolean hasCors) + { + } + + + + /******************************************************************************* + ** Read a bucket's settings (read-only calls). Every call is isolated: a + ** provider that does not implement one reports "not available". + *******************************************************************************/ + public static Observed observe(StorageBucketConfig bucket) + { + ConnectionRuntime connection = ObjectStorageRuntime.connection(bucket.getConnection()); + S3Client client = connection.client(); + String name = com.kingsrook.qbits.objectstorage.internal.SecretRefs.resolveSetting(bucket.getBucketName()); + + String versioning = "not available"; + boolean versioningEnabled = false; + try + { + String status = client.getBucketVersioning(b -> b.bucket(name)).statusAsString(); + versioning = status == null ? "Off" : status; + versioningEnabled = "Enabled".equals(status); + } + catch(RuntimeException e) + { + versioning = "not available (" + shortError(e) + ")"; + } + + String lifecycle = "not available"; + boolean hasLifecycle = false; + try + { + var rules = client.getBucketLifecycleConfiguration(b -> b.bucket(name)).rules(); + hasLifecycle = !rules.isEmpty(); + List parts = new ArrayList<>(); + rules.forEach(r -> parts.add((r.id() == null ? "rule" : r.id()) + (r.expiration() != null && r.expiration().days() != null ? " expires after " + r.expiration().days() + " days" : "") + + (r.noncurrentVersionExpiration() != null && r.noncurrentVersionExpiration().noncurrentDays() != null ? ", noncurrent versions after " + r.noncurrentVersionExpiration().noncurrentDays() + " days" : ""))); + lifecycle = rules.isEmpty() ? "none" : rules.size() + " rule(s): " + String.join("; ", parts); + } + catch(RuntimeException e) + { + lifecycle = isNotConfigured(e, "NoSuchLifecycleConfiguration") ? "none" : "not available (" + shortError(e) + ")"; + } + + String policy = "not available"; + boolean hasPolicy = false; + try + { + String json = client.getBucketPolicy(b -> b.bucket(name)).policy(); + int statements = json == null ? 0 : json.split("\"Effect\"", -1).length - 1; + hasPolicy = statements > 0; + policy = statements + " statement(s)"; + } + catch(RuntimeException e) + { + policy = isNotConfigured(e, "NoSuchBucketPolicy") ? "none" : "not available (" + shortError(e) + ")"; + } + + String cors = "not available"; + boolean hasCors = false; + try + { + int rules = client.getBucketCors(b -> b.bucket(name)).corsRules().size(); + hasCors = rules > 0; + cors = rules == 0 ? "none" : rules + " rule(s)"; + } + catch(RuntimeException e) + { + cors = isNotConfigured(e, "NoSuchCORSConfiguration") ? "none" : "not available (" + shortError(e) + ")"; + } + return (new Observed(versioning, lifecycle, policy, cors, versioningEnabled, hasLifecycle, hasPolicy, hasCors)); + } + + + + /******************************************************************************* + ** The differences between what the config declares and what is observed. + *******************************************************************************/ + public static List drift(StorageBucketConfig bucket, Observed o) + { + List drift = new ArrayList<>(); + if(bucket.getExpectVersioning() != null && bucket.getExpectVersioning() != o.versioningEnabled()) + { + drift.add("expected versioning " + (bucket.getExpectVersioning() ? "enabled" : "off") + ", observed " + o.versioning()); + } + if(bucket.getExpectLifecycle() != null && bucket.getExpectLifecycle() != o.hasLifecycle()) + { + drift.add("expected " + (bucket.getExpectLifecycle() ? "a lifecycle configuration" : "no lifecycle configuration") + ", observed " + o.lifecycle()); + } + if(bucket.getExpectCors() != null && bucket.getExpectCors() != o.hasCors()) + { + drift.add("expected " + (bucket.getExpectCors() ? "CORS rules" : "no CORS rules") + ", observed " + o.cors()); + } + if(bucket.getExpectPolicy() != null && bucket.getExpectPolicy() != o.hasPolicy()) + { + drift.add("expected " + (bucket.getExpectPolicy() ? "a bucket policy" : "no bucket policy") + ", observed " + o.policy()); + } + return (drift); + } + + + + /******************************************************************************* + ** Whether an exception is the documented "not configured" answer. + *******************************************************************************/ + private static boolean isNotConfigured(RuntimeException e, String code) + { + return (code.equals(com.kingsrook.qbits.objectstorage.internal.ErrorMapper.errorCode(e))); + } + + + + /******************************************************************************* + ** A short, safe description of an error. + *******************************************************************************/ + private static String shortError(RuntimeException e) + { + String code = com.kingsrook.qbits.objectstorage.internal.ErrorMapper.errorCode(e); + return (code != null ? code : e.getClass().getSimpleName()); + } + + + + /******************************************************************************* + ** Check configuration, execute. + *******************************************************************************/ + public static class CheckLoad extends BucketLoad + { + private final ArrayList results = new ArrayList<>(); + + + + /******************************************************************************* + ** Observe one bucket and record what was seen. + *******************************************************************************/ + @Override + protected void apply(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + String alias = record.getValueString("alias"); + StorageBucketConfig bucket = ActionSupport.config(alias); + Observed o = observe(bucket); + List drift = drift(bucket, o); + Db.update(ObjectStorageTables.BUCKET, List.of(new QRecord().withValue("id", record.getValue("id")).withValue("versioningState", o.versioning().length() > 30 ? o.versioning().substring(0, 30) : o.versioning()) + .withValue("lifecycleSummary", o.lifecycle()).withValue("policySummary", o.policy()).withValue("corsSummary", o.cors()) + .withValue("configDriftNote", drift.isEmpty() ? null : String.join("; ", drift)))); + results.add(EtlSupport.text(Status.INFO, alias + ": versioning " + o.versioning() + ", lifecycle " + o.lifecycle() + ", policy " + o.policy() + ", CORS " + o.cors() + + " (managed by " + bucket.getManagedBy() + ")")); + for(String d : drift) + { + results.add(EtlSupport.text(Status.WARNING, alias + ": drift - " + d)); + } + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Result lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + lines.addAll(results); + } + } + + + + /******************************************************************************* + ** Apply header policy to a bucket, review: counts from the index. + *******************************************************************************/ + public static class BucketPolicyTransform extends BucketTransform + { + private final ProcessSummaryLine change = EtlSupport.line(Status.OK, "object in this bucket will change to match the policy", "objects in these buckets will change to match the policy", + "object was changed to match the policy", "objects were changed to match the policy"); + private final ProcessSummaryLine unknown = EtlSupport.line(Status.WARNING, "object has unknown headers (not yet read; refresh it or wait for the nightly HEAD backlog)", + "objects have unknown headers (not yet read; refresh them or wait for the nightly HEAD backlog)", "object had unknown headers", "objects had unknown headers"); + private final ProcessSummaryLine backup = EtlSupport.line(Status.INFO, "bucket is a backup bucket (the policy never applies there)", "buckets are backup buckets (the policy never applies there)", + "bucket was a backup bucket (skipped)", "buckets were backup buckets (skipped)"); + private final ProcessSummaryLine notIndexed = EtlSupport.line(Status.INFO, "bucket is not indexed, so its objects cannot be listed here (skipped)", "buckets are not indexed, so their objects cannot be listed here (skipped)", + "bucket was not indexed (skipped)", "buckets were not indexed (skipped)"); + + + + /******************************************************************************* + ** Count what would change. + *******************************************************************************/ + @Override + protected void handle(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + StorageBucketConfig config = ActionSupport.config(record.getValueString("alias")); + if(config == null || config.getRole() == BucketRole.BACKUP) + { + backup.incrementCountAndAddPrimaryKey(record.getValue("id")); + return; + } + if(!Boolean.TRUE.equals(config.getIndexed())) + { + notIndexed.incrementCountAndAddPrimaryKey(record.getValue("id")); + return; + } + Integer id = record.getValueInteger("id"); + int mismatched = Db.count(ObjectStorageTables.OBJECT, new QQueryFilter(Db.eq("bucketId", id), Db.eq("deleted", false), Db.eq("headersKnown", true), Db.eq("matchesHeaderPolicy", false))); + int unknownCount = Db.count(ObjectStorageTables.OBJECT, new QQueryFilter(Db.eq("bucketId", id), Db.eq("deleted", false), Db.eq("headersKnown", false))); + change.incrementCount(mismatched); + unknown.incrementCount(unknownCount); + if(mismatched > ObjectStorageRuntime.getConfig().getBulkCap() && !ActionSupport.isAdmin()) + { + overCap.incrementCount(mismatched - ObjectStorageRuntime.getConfig().getBulkCap()); + } + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Review lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + change.addSelfToListIfAnyCount(lines); + unknown.addSelfToListIfAnyCount(lines); + backup.addSelfToListIfAnyCount(lines); + notIndexed.addSelfToListIfAnyCount(lines); + if(change.getCount() != null && change.getCount() > 0) + { + lines.add(EtlSupport.text(Status.INFO, "Rewriting headers copies each object onto itself, which changes Last-Modified and can change the ETag.")); + } + } + + + + /******************************************************************************* + ** The skips, as the Result screen shows them. + *******************************************************************************/ + @Override + protected List skipLines() + { + return (List.of(unknown, backup, notIndexed)); + } + } + + + + /******************************************************************************* + ** Apply header policy to a bucket, execute: the objects the index says are off. + *******************************************************************************/ + public static class BucketPolicyLoad extends BucketLoad + { + private final ProcessSummaryLine changed = EtlSupport.line(Status.OK, "object will be changed to match the policy", "objects will be changed to match the policy", + "object was changed to match the policy", "objects were changed to match the policy"); + private final ProcessSummaryLine failedObjects = EtlSupport.line(Status.ERROR, "object could not be changed (see Operations)", "objects could not be changed (see Operations)", + "object could not be changed (see Operations)", "objects could not be changed (see Operations)"); + + + + /******************************************************************************* + ** Rewrite the headers of one bucket's mismatching objects (up to the bulk cap + ** unless admin). + *******************************************************************************/ + @Override + protected void apply(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + String alias = record.getValueString("alias"); + StorageBucketConfig config = ActionSupport.config(alias); + int limit = ActionSupport.isAdmin() ? Integer.MAX_VALUE : ObjectStorageRuntime.getConfig().getBulkCap(); + List todo = new ArrayList<>(); + Db.forEachPage(ObjectStorageTables.OBJECT, new QQueryFilter(Db.eq("bucketId", record.getValue("id")), Db.eq("deleted", false), Db.eq("headersKnown", true), Db.eq("matchesHeaderPolicy", false)), 2000, + rows -> rows.forEach(r -> + { + if(todo.size() < limit) + { + todo.add(r); + } + })); + for(QRecord row : todo) + { + String key = row.getValueString("objectKey"); + if(ProtectionRules.check(alias, key, ProtectionRules.Op.WRITE, com.kingsrook.qbits.objectstorage.api.StorageCaller.user()).blocked()) + { + continue; + } + try + { + ObjectHeaders wanted = ObjectStorageRuntime.headerPolicy().headersFor(config, key, row.getValueString("contentType")); + ActionSupport.storage(alias).updateHeaders(key, new ObjectHeaders().withCacheControl(wanted.getCacheControl()).withContentDisposition(wanted.getContentDisposition())); + changed.incrementCount(); + } + catch(RuntimeException e) + { + failedObjects.incrementCount(); + } + } + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Result lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + changed.addSelfToListIfAnyCount(lines); + failedObjects.addSelfToListIfAnyCount(lines); + } + } + + + + /******************************************************************************* + ** Create upload link, review: the key must be valid; the Result shows the link once. + *******************************************************************************/ + public static class UploadLinkTransform extends BucketTransform + { + private final ProcessSummaryLine will = EtlSupport.line(Status.OK, "upload link will be created", "upload links will be created", "upload link was created", "upload links were created"); + private String keyError; + + + + /******************************************************************************* + ** Validate the key and the TTL cap. + *******************************************************************************/ + @Override + protected void handle(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + try + { + StorageKeys.normalize(input.getValueString(FIELD_LINK_KEY)); + } + catch(IllegalArgumentException e) + { + keyError = e.getMessage(); + return; + } + StorageBucketConfig config = ActionSupport.config(record.getValueString("alias")); + if(config == null || config.getRole() == BucketRole.BACKUP) + { + keyError = "upload links are not offered for backup buckets"; + return; + } + will.incrementCountAndAddPrimaryKey(record.getValue("id")); + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Review lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + if(keyError != null) + { + lines.add(EtlSupport.text(Status.ERROR, "The link cannot be created: " + keyError)); + return; + } + will.addSelfToListIfAnyCount(lines); + lines.add(EtlSupport.text(Status.INFO, "A presigned PUT cannot limit how large the upload is, only pin one exact size and type. The link is a bearer token: share it only with the intended uploader.")); + } + } + + + + /******************************************************************************* + ** Create upload link, execute. + *******************************************************************************/ + public static class UploadLinkLoad extends BucketLoad + { + private final ArrayList results = new ArrayList<>(); + + + + /******************************************************************************* + ** Create the link and show it once. + *******************************************************************************/ + @Override + protected void apply(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + String alias = record.getValueString("alias"); + PresignPut request = PresignPut.of(StorageKeys.normalize(input.getValueString(FIELD_LINK_KEY))); + if(StringUtils.hasContent(input.getValueString(FIELD_LINK_TYPE))) + { + request.setContentType(input.getValueString(FIELD_LINK_TYPE)); + } + if(input.getValue(FIELD_LINK_LENGTH) != null && StringUtils.hasContent(String.valueOf(input.getValue(FIELD_LINK_LENGTH)))) + { + request.setContentLength(Long.valueOf(String.valueOf(input.getValue(FIELD_LINK_LENGTH)))); + } + URL url = ActionSupport.storage(alias).presignPut(request); + int minutes = (int) Math.min(ActionSupport.config(alias).getMaxPresignPutSeconds() / 60, 15); + ProcessSummaryLine line = EtlSupport.text(Status.OK, "Upload link for " + alias + "/" + request.getKey() + " (expires in about " + minutes + " minutes; shown once)"); + line.setBulletsOfText(new ArrayList<>(List.of(url.toString()))); + results.add(line); + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Result lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + lines.addAll(results); + } + } + + + + /******************************************************************************* + ** The capability names a link needs (documented in the Review). + *******************************************************************************/ + static boolean linksSupported(String alias) + { + return (!CapabilityStore.ruledOut(ActionSupport.storage(alias).capability(Capability.PRESIGNED_PUT))); + } + + + + /******************************************************************************* + ** A readable TTL. + *******************************************************************************/ + static String describe(Duration ttl) + { + return (String.format(Locale.ROOT, "%d minutes", ttl.toMinutes())); + } + + + + /******************************************************************************* + ** A bulk-edit friendly id list of a page. + *******************************************************************************/ + static List idsOf(List records) + { + List ids = new ArrayList<>(); + records.forEach(r -> ids.add(r.getValue("id"))); + return (ids); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/processes/CopyMoveProcesses.java b/src/main/java/com/kingsrook/qbits/objectstorage/processes/CopyMoveProcesses.java new file mode 100644 index 0000000..a358cdc --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/processes/CopyMoveProcesses.java @@ -0,0 +1,521 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.processes; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.objectstorage.api.CopyRequest; +import com.kingsrook.qbits.objectstorage.api.DeleteRequest; +import com.kingsrook.qbits.objectstorage.api.StorageCaller; +import com.kingsrook.qbits.objectstorage.api.StorageKeys; +import com.kingsrook.qbits.objectstorage.internal.Db; +import com.kingsrook.qbits.objectstorage.internal.ProtectionRules; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qbits.objectstorage.permissions.ObjectStoragePermissions; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** "Copy objects" and "Move or rename objects" (also the engine of the folder + ** rename). A move is a copy followed by a delete, not an atomic operation, and + ** the Review says so. Moves skip referenced, protected and locked objects; + ** copies never overwrite unless asked. + *******************************************************************************/ +public final class CopyMoveProcesses +{ + public static final String COPY_NAME = "objectStorageCopyObjects"; + public static final String MOVE_NAME = "objectStorageMoveObjects"; + + public static final String FIELD_DEST_BUCKET = "destinationBucket"; + public static final String FIELD_DEST_PREFIX = "destinationPrefix"; + public static final String FIELD_KEEP_PATH = "keepPath"; + public static final String FIELD_OVERWRITE = "overwrite"; + public static final String FIELD_NEW_NAME = "newName"; + + private static final long LARGE_OBJECT = 5L * 1024 * 1024 * 1024; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private CopyMoveProcesses() + { + } + + + + /******************************************************************************* + ** The processes. + *******************************************************************************/ + public static List processes() + { + QFieldMetaData bucket = new QFieldMetaData(FIELD_DEST_BUCKET, QFieldType.INTEGER).withLabel("Destination bucket (leave blank for the same bucket)") + .withPossibleValueSourceName(ObjectStorageTables.BUCKET); + QFieldMetaData prefix = EtlSupport.stringField(FIELD_DEST_PREFIX, "Destination folder (for example media/2026/; leave blank for the bucket root)", false); + QFieldMetaData keep = EtlSupport.booleanField(FIELD_KEEP_PATH, "Keep each object's whole path under the destination (otherwise only its file name)", false); + QFieldMetaData over = EtlSupport.booleanField(FIELD_OVERWRITE, "Overwrite objects that already exist at the destination", false); + QFieldMetaData name = EtlSupport.stringField(FIELD_NEW_NAME, "New file name (only when moving one object)", false); + + return (List.of( + EtlSupport.withInput(EtlSupport.etl(COPY_NAME, "Copy objects", "content_copy", ObjectStorageTables.OBJECT, CopyTransform.class, CopyLoad.class, + "This is a preview of the objects that will be copied.", ObjectStoragePermissions.WRITE), + "Copy objects", "Copies the selected objects to a bucket and folder. The originals stay where they are.", bucket, prefix, keep, over), + EtlSupport.withInput(EtlSupport.etl(MOVE_NAME, "Move or rename objects", "drive_file_move", ObjectStorageTables.OBJECT, MoveTransform.class, MoveLoad.class, + "This is a preview of the objects that will be moved.", ObjectStoragePermissions.WRITE), + "Move or rename objects", "Moves the selected objects to a bucket and folder, or renames a single object. A move is a copy followed by a delete, not one atomic step.", + bucket, prefix, keep, over, name))); + } + + + + /******************************************************************************* + ** Where an object goes: the destination folder plus the file name or the whole + ** path; a single-object rename replaces the file name. + *******************************************************************************/ + static String destinationKey(String sourceKey, String destPrefix, boolean keepPath, String newName) + { + String name = sourceKey.substring(sourceKey.lastIndexOf('/') + 1); + String folder = sourceKey.contains("/") ? sourceKey.substring(0, sourceKey.lastIndexOf('/') + 1) : ""; + String prefix = destPrefix == null ? "" : destPrefix.trim(); + if(!prefix.isEmpty() && !prefix.endsWith("/")) + { + prefix = prefix + "/"; + } + boolean rename = StringUtils.hasContent(newName); + if(rename && prefix.isEmpty()) + { + // a rename with no destination folder stays in the object's own folder + return (StorageKeys.normalize(folder + newName.trim())); + } + String leaf = rename ? newName.trim() : name; + return (StorageKeys.normalize(prefix + (keepPath ? folder : "") + leaf)); + } + + + + /******************************************************************************* + ** The alias of a bucket row id (the destination bucket field), or null. + *******************************************************************************/ + static String aliasOfBucketId(Serializable bucketId) + { + if(bucketId == null || !StringUtils.hasContent(String.valueOf(bucketId))) + { + return (null); + } + try + { + QRecord bucket = Db.asSystem(() -> Db.first(ObjectStorageTables.BUCKET, "id", Integer.valueOf(String.valueOf(bucketId)))); + return (bucket == null ? null : bucket.getValueString("alias")); + } + catch(QException | NumberFormatException e) + { + return (null); + } + } + + + + /******************************************************************************* + ** The shared review of copy and move. + *******************************************************************************/ + public abstract static class CopyMoveTransform extends BaseTransform + { + protected final ProcessSummaryLine will; + protected final ProcessSummaryLine overwrites = EtlSupport.line(Status.WARNING, "object will replace one that already exists at the destination", "objects will replace ones that already exist at the destination", + "object replaced one that already existed at the destination", "objects replaced ones that already existed at the destination"); + protected final ProcessSummaryLine exists = EtlSupport.line(Status.WARNING, "object is skipped: the destination already exists (and overwrite is off)", + "objects are skipped: the destinations already exist (and overwrite is off)", "object was skipped: the destination already existed", "objects were skipped: the destinations already existed"); + protected final ProcessSummaryLine same = EtlSupport.line(Status.INFO, "object is skipped: it is already at the destination", "objects are skipped: they are already at the destination", + "object was skipped: it is already at the destination", "objects were skipped: they are already at the destination"); + protected final ProcessSummaryLine invalid = EtlSupport.line(Status.ERROR, "object is skipped: its destination key is not valid", "objects are skipped: their destination keys are not valid", + "object was skipped: its destination key was not valid", "objects were skipped: their destination keys were not valid"); + protected final ProcessSummaryLine referenced = EtlSupport.line(Status.WARNING, "object is skipped: it is referenced", "objects are skipped: they are referenced", + "object was skipped: it is referenced", "objects were skipped: they are referenced"); + protected final ProcessSummaryLine protectedLine = EtlSupport.line(Status.WARNING, "object is skipped: it (or its destination) is protected", "objects are skipped: they (or their destinations) are protected", + "object was skipped: it (or its destination) is protected", "objects were skipped: they (or their destinations) are protected"); + protected final ProcessSummaryLine locked = EtlSupport.line(Status.WARNING, "object is skipped: it is under object lock", "objects are skipped: they are under object lock", + "object was skipped: it is under object lock", "objects were skipped: they are under object lock"); + protected final ProcessSummaryLine backup = EtlSupport.line(Status.INFO, "object is skipped: it is in a backup bucket", "objects are skipped: they are in backup buckets", + "object was skipped: it is in a backup bucket", "objects were skipped: they are in backup buckets"); + protected final ProcessSummaryLine deletedRows = EtlSupport.line(Status.INFO, "object is skipped: it is deleted", "objects are skipped: they are deleted", "object was skipped: it is deleted", + "objects were skipped: they are deleted"); + protected final ProcessSummaryLine large = EtlSupport.line(Status.INFO, "object is over 5 GB and is copied in parts", "objects are over 5 GB and are copied in parts", + "object was over 5 GB and was copied in parts", "objects were over 5 GB and were copied in parts"); + + private long totalBytes = 0; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + protected CopyMoveTransform(ProcessSummaryLine will) + { + this.will = will; + } + + + + /******************************************************************************* + ** Whether this is a move (the source is deleted afterwards). + *******************************************************************************/ + protected abstract boolean isMove(); + + + + /******************************************************************************* + ** Where the object goes, for this run's inputs (the folder rename overrides). + *******************************************************************************/ + protected String destKeyFor(QRecord record, RunBackendStepInput input) + { + Integer total = input.getValueInteger("recordCount"); + String newName = isMove() && total != null && total == 1 ? input.getValueString(FIELD_NEW_NAME) : null; + return (destinationKey(record.getValueString("objectKey"), input.getValueString(FIELD_DEST_PREFIX), Boolean.TRUE.equals(input.getValueBoolean(FIELD_KEEP_PATH)), newName)); + } + + + + /******************************************************************************* + ** Classify one object. + *******************************************************************************/ + @Override + protected void handle(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + Serializable id = record.getValue("id"); + if(Boolean.TRUE.equals(record.getValueBoolean("deleted"))) + { + deletedRows.incrementCountAndAddPrimaryKey(id); + return; + } + ActionSupport.Classification c = ActionSupport.classify(record, isMove() ? ProtectionRules.Op.DELETE : ProtectionRules.Op.WRITE, isMove(), isMove()); + switch(c.skip()) + { + case REFERENCED: + referenced.incrementCountAndAddPrimaryKey(id); + return; + case PROTECTED: + protectedLine.incrementCountAndAddPrimaryKey(id); + return; + case LOCKED: + locked.incrementCountAndAddPrimaryKey(id); + return; + case BACKUP_BUCKET: + backup.incrementCountAndAddPrimaryKey(id); + return; + case NO_BUCKET_ACCESS: + noAccess.incrementCountAndAddPrimaryKey(id); + return; + default: + break; + } + + String srcAlias = ActionSupport.aliasOf(record); + String destAlias = aliasOfBucketId(input.getValue(FIELD_DEST_BUCKET)); + destAlias = destAlias == null ? srcAlias : destAlias; + if(ActionSupport.config(destAlias) != null && !ActionSupport.canSeeBucket(com.kingsrook.qbits.objectstorage.internal.ConfigSync.bucketId(destAlias), bucketAccess)) + { + noAccess.incrementCountAndAddPrimaryKey(id); + return; + } + String destKey; + try + { + destKey = destKeyFor(record, input); + } + catch(IllegalArgumentException e) + { + invalid.incrementCountAndAddPrimaryKey(id); + return; + } + if(destAlias.equals(srcAlias) && destKey.equals(record.getValueString("objectKey"))) + { + same.incrementCountAndAddPrimaryKey(id); + return; + } + if(ActionSupport.config(destAlias) == null || ActionSupport.config(destAlias).getRole() == com.kingsrook.qbits.objectstorage.config.BucketRole.BACKUP + || ProtectionRules.check(destAlias, destKey, ProtectionRules.Op.WRITE, StorageCaller.user()).blocked()) + { + protectedLine.incrementCountAndAddPrimaryKey(id); + return; + } + boolean destExists = false; + try + { + destExists = ActionSupport.storage(destAlias).head(destKey).isPresent(); + } + catch(RuntimeException e) + { + destExists = false; + } + if(destExists) + { + if(!Boolean.TRUE.equals(input.getValueBoolean(FIELD_OVERWRITE))) + { + exists.incrementCountAndAddPrimaryKey(id); + return; + } + overwrites.incrementCountAndAddPrimaryKey(id); + } + Long size = record.getValueLong("size"); + totalBytes += size == null ? 0 : size; + if(size != null && size > LARGE_OBJECT) + { + large.incrementCountAndAddPrimaryKey(id); + } + will.incrementCountAndAddPrimaryKey(id); + record.setValue("_destAlias", destAlias); + record.setValue("_destKey", destKey); + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Review lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + will.addSelfToListIfAnyCount(lines); + if(will.getCount() != null && will.getCount() > 0) + { + lines.add(EtlSupport.text(Status.INFO, ActionSupport.bytes(totalBytes) + " in total")); + if(isMove()) + { + lines.add(EtlSupport.text(Status.INFO, "A move is a copy followed by a delete, not one atomic step. If the delete fails, the object exists in both places.")); + } + } + overwrites.addSelfToListIfAnyCount(lines); + large.addSelfToListIfAnyCount(lines); + for(ProcessSummaryLine line : skipLines()) + { + line.addSelfToListIfAnyCount(lines); + } + } + + + + /******************************************************************************* + ** The skips, as the Result screen shows them. + *******************************************************************************/ + @Override + protected List skipLines() + { + return (List.of(exists, same, invalid, referenced, protectedLine, locked, backup, deletedRows)); + } + } + + + + /******************************************************************************* + ** Copy, review. + *******************************************************************************/ + public static class CopyTransform extends CopyMoveTransform + { + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + public CopyTransform() + { + super(EtlSupport.line(Status.OK, "object will be copied", "objects will be copied", "object was copied", "objects were copied")); + } + + + + /******************************************************************************* + ** Not a move. + *******************************************************************************/ + @Override + protected boolean isMove() + { + return (false); + } + } + + + + /******************************************************************************* + ** Move, review. + *******************************************************************************/ + public static class MoveTransform extends CopyMoveTransform + { + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + public MoveTransform() + { + super(EtlSupport.line(Status.OK, "object will be moved", "objects will be moved", "object was moved", "objects were moved")); + } + + + + /******************************************************************************* + ** A move. + *******************************************************************************/ + @Override + protected boolean isMove() + { + return (true); + } + } + + + + /******************************************************************************* + ** Copy and move, execute. + *******************************************************************************/ + public abstract static class CopyMoveLoad extends BaseLoad + { + private final ProcessSummaryLine done; + private final ProcessSummaryLine copiedNotDeleted = EtlSupport.line(Status.ERROR, "object was copied but the original could not be deleted (it exists in both places)", + "objects were copied but the originals could not be deleted (they exist in both places)", "object was copied but the original could not be deleted (it exists in both places)", + "objects were copied but the originals could not be deleted (they exist in both places)"); + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + protected CopyMoveLoad(ProcessSummaryLine done) + { + this.done = done; + } + + + + /******************************************************************************* + ** Whether this is a move. + *******************************************************************************/ + protected abstract boolean isMove(); + + + + /******************************************************************************* + ** Copy one object, and for a move delete the original. + *******************************************************************************/ + @Override + protected void apply(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + String srcAlias = ActionSupport.aliasOf(record); + String srcKey = record.getValueString("objectKey"); + String destAlias = record.getValueString("_destAlias"); + String destKey = record.getValueString("_destKey"); + if(isMove() && !ActionSupport.has(ObjectStoragePermissions.DELETE)) + { + throw (new com.kingsrook.qbits.objectstorage.api.StorageException(com.kingsrook.qbits.objectstorage.api.StorageException.Reason.ACCESS_DENIED, "a move needs object_storage.delete too")); + } + ActionSupport.storage(srcAlias).copy(CopyRequest.of(srcKey, destKey).withDestinationBucket(destAlias.equals(srcAlias) ? null : destAlias)); + if(isMove()) + { + try + { + ActionSupport.storage(srcAlias).delete(DeleteRequest.of(srcKey)); + } + catch(RuntimeException e) + { + copiedNotDeleted.incrementCountAndAddPrimaryKey(record.getValue("id")); + return; + } + } + done.incrementCountAndAddPrimaryKey(record.getValue("id")); + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Result lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + done.addSelfToListIfAnyCount(lines); + copiedNotDeleted.addSelfToListIfAnyCount(lines); + } + } + + + + /******************************************************************************* + ** Copy, execute. + *******************************************************************************/ + public static class CopyLoad extends CopyMoveLoad + { + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + public CopyLoad() + { + super(EtlSupport.line(Status.OK, "object will be copied", "objects will be copied", "object was copied", "objects were copied")); + } + + + + /******************************************************************************* + ** Not a move. + *******************************************************************************/ + @Override + protected boolean isMove() + { + return (false); + } + } + + + + /******************************************************************************* + ** Move, execute. + *******************************************************************************/ + public static class MoveLoad extends CopyMoveLoad + { + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + public MoveLoad() + { + super(EtlSupport.line(Status.OK, "object will be moved", "objects will be moved", "object was moved", "objects were moved")); + } + + + + /******************************************************************************* + ** A move. + *******************************************************************************/ + @Override + protected boolean isMove() + { + return (true); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/processes/DeleteRestoreProcesses.java b/src/main/java/com/kingsrook/qbits/objectstorage/processes/DeleteRestoreProcesses.java new file mode 100644 index 0000000..cdce7ff --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/processes/DeleteRestoreProcesses.java @@ -0,0 +1,392 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.processes; + + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.objectstorage.api.DeleteMode; +import com.kingsrook.qbits.objectstorage.api.DeleteRequest; +import com.kingsrook.qbits.objectstorage.api.DeleteResult; +import com.kingsrook.qbits.objectstorage.api.StorageBucket; +import com.kingsrook.qbits.objectstorage.config.ProviderType; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.internal.ProtectionRules; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qbits.objectstorage.permissions.ObjectStoragePermissions; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; + + +/******************************************************************************* + ** "Delete objects" and "Restore objects". A delete is soft wherever the bucket + ** allows it (a delete marker on a versioned bucket, a move to the private + ** trash otherwise) and permanent only with object_storage.admin. Referenced + ** objects, protected objects and objects under object lock are skipped, and the + ** Review says why. + *******************************************************************************/ +public final class DeleteRestoreProcesses +{ + public static final String DELETE_NAME = "objectStorageDeleteObjects"; + public static final String RESTORE_NAME = "objectStorageRestoreObjects"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private DeleteRestoreProcesses() + { + } + + + + /******************************************************************************* + ** The processes. + *******************************************************************************/ + public static List processes() + { + return (List.of( + EtlSupport.etl(DELETE_NAME, "Delete objects", "delete", ObjectStorageTables.OBJECT, DeleteTransform.class, DeleteLoad.class, + "This is a preview of the objects that will be deleted. Referenced, protected and locked objects are skipped.", ObjectStoragePermissions.DELETE), + EtlSupport.etl(RESTORE_NAME, "Restore objects", "restore_from_trash", ObjectStorageTables.OBJECT, RestoreTransform.class, RestoreLoad.class, + "This is a preview of the deleted objects that will be restored.", ObjectStoragePermissions.WRITE))); + } + + + + /******************************************************************************* + ** Delete, review. + *******************************************************************************/ + public static class DeleteTransform extends BaseTransform + { + private final ProcessSummaryLine marker = EtlSupport.line(Status.OK, "object will be deleted (a delete marker is added; Restore undoes it)", + "objects will be deleted (a delete marker is added; Restore undoes it)", "object was deleted (a delete marker was added; Restore undoes it)", + "objects were deleted (delete markers were added; Restore undoes it)"); + private final ProcessSummaryLine trash = EtlSupport.line(Status.OK, "object will be moved to the trash (Restore undoes it)", "objects will be moved to the trash (Restore undoes it)", + "object was moved to the trash (Restore undoes it)", "objects were moved to the trash (Restore undoes it)"); + private final ProcessSummaryLine permanent = EtlSupport.line(Status.WARNING, "object will be deleted permanently (this cannot be undone)", + "objects will be deleted permanently (this cannot be undone)", "object was deleted permanently", "objects were deleted permanently"); + private final ProcessSummaryLine needsAdmin = EtlSupport.line(Status.WARNING, "object is skipped: this bucket has no versioning or trash, so the delete would be permanent and needs object_storage.admin", + "objects are skipped: these buckets have no versioning or trash, so the delete would be permanent and needs object_storage.admin", + "object was skipped: a permanent delete needs object_storage.admin", "objects were skipped: a permanent delete needs object_storage.admin"); + private final ProcessSummaryLine referenced = EtlSupport.line(Status.WARNING, "object is skipped: it is referenced", "objects are skipped: they are referenced", + "object was skipped: it is referenced", "objects were skipped: they are referenced"); + private final ProcessSummaryLine protectedLine = EtlSupport.line(Status.WARNING, "object is skipped: it is protected", "objects are skipped: they are protected", + "object was skipped: it is protected", "objects were skipped: they are protected"); + private final ProcessSummaryLine locked = EtlSupport.line(Status.WARNING, "object is skipped: it is under object lock", "objects are skipped: they are under object lock", + "object was skipped: it is under object lock", "objects were skipped: they are under object lock"); + private final ProcessSummaryLine backup = EtlSupport.line(Status.INFO, "object is skipped: it is in a backup bucket", "objects are skipped: they are in backup buckets", + "object was skipped: it is in a backup bucket", "objects were skipped: they are in backup buckets"); + private final ProcessSummaryLine already = EtlSupport.line(Status.INFO, "object is already deleted (no change)", "objects are already deleted (no change)", + "object was already deleted (no change)", "objects were already deleted (no change)"); + + private final Map modes = new HashMap<>(); + private boolean wasabi = false; + + + + /******************************************************************************* + ** Classify one object. + *******************************************************************************/ + @Override + protected void handle(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + java.io.Serializable id = record.getValue("id"); + if(Boolean.TRUE.equals(record.getValueBoolean("deleted"))) + { + already.incrementCountAndAddPrimaryKey(id); + return; + } + ActionSupport.Classification c = ActionSupport.classify(record, ProtectionRules.Op.DELETE, true, true); + switch(c.skip()) + { + case REFERENCED -> skip(referenced, id, record, c); + case PROTECTED -> skip(protectedLine, id, record, c); + case LOCKED -> skip(locked, id, record, c); + case BACKUP_BUCKET -> backup.incrementCountAndAddPrimaryKey(id); + case NO_BUCKET_ACCESS -> noAccess.incrementCountAndAddPrimaryKey(id); + default -> + { + String alias = ActionSupport.aliasOf(record); + DeleteMode mode = modes.computeIfAbsent(alias, a -> ActionSupport.storage(a).plannedDeleteMode()); + StorageBucketConfig config = ActionSupport.config(alias); + wasabi = wasabi || ObjectStorageRuntime.getConfig().findConnection(config.getConnection()).getProviderType() == ProviderType.WASABI; + if(mode == DeleteMode.PERMANENT && !ActionSupport.isAdmin()) + { + needsAdmin.incrementCountAndAddPrimaryKey(id); + return; + } + (mode == DeleteMode.SOFT_DELETE_MARKER ? marker : mode == DeleteMode.SOFT_TRASH ? trash : permanent).incrementCountAndAddPrimaryKey(id); + record.setValue("_mode", mode.name()); + output.getRecords().add(record); + } + } + } + + + + /******************************************************************************* + ** Count a skip and keep the first few reasons as bullets. + *******************************************************************************/ + private void skip(ProcessSummaryLine line, java.io.Serializable id, QRecord record, ActionSupport.Classification c) + { + line.incrementCountAndAddPrimaryKey(id); + if(line.getBulletsOfText() == null) + { + line.setBulletsOfText(new ArrayList<>()); + } + if(line.getBulletsOfText().size() < 5) + { + line.getBulletsOfText().add(record.getValueString("objectKey") + ": " + c.detail()); + } + } + + + + /******************************************************************************* + ** The lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + marker.addSelfToListIfAnyCount(lines); + trash.addSelfToListIfAnyCount(lines); + permanent.addSelfToListIfAnyCount(lines); + needsAdmin.addSelfToListIfAnyCount(lines); + referenced.addSelfToListIfAnyCount(lines); + protectedLine.addSelfToListIfAnyCount(lines); + locked.addSelfToListIfAnyCount(lines); + backup.addSelfToListIfAnyCount(lines); + already.addSelfToListIfAnyCount(lines); + if(wasabi) + { + lines.add(EtlSupport.text(Status.INFO, "Wasabi bills deleted objects for their minimum storage duration, so deleting does not reduce the bill at once.")); + } + } + + + + /******************************************************************************* + ** The skips, as the Result screen shows them. + *******************************************************************************/ + @Override + protected List skipLines() + { + return (List.of(needsAdmin, referenced, protectedLine, locked, backup, already)); + } + } + + + + /******************************************************************************* + ** Delete, execute. + *******************************************************************************/ + public static class DeleteLoad extends BaseLoad + { + private final ProcessSummaryLine marker = EtlSupport.line(Status.OK, "object will be deleted (a delete marker is added; Restore undoes it)", + "objects will be deleted (a delete marker is added; Restore undoes it)", "object was deleted (a delete marker was added; Restore undoes it)", + "objects were deleted (delete markers were added; Restore undoes it)"); + private final ProcessSummaryLine trash = EtlSupport.line(Status.OK, "object will be moved to the trash", "objects will be moved to the trash", "object was moved to the trash (Restore undoes it)", + "objects were moved to the trash (Restore undoes it)"); + private final ProcessSummaryLine permanent = EtlSupport.line(Status.WARNING, "object will be deleted permanently", "objects will be deleted permanently", "object was deleted permanently", + "objects were deleted permanently"); + private final ProcessSummaryLine lockedNow = EtlSupport.line(Status.WARNING, "object was skipped: object lock", "objects were skipped: object lock", "object was skipped: object lock", + "objects were skipped: object lock"); + private final ProcessSummaryLine gone = EtlSupport.line(Status.INFO, "object was already gone", "objects were already gone", "object was already gone", "objects were already gone"); + + + + /******************************************************************************* + ** Delete one object through the service (which logs it, updates the index and + ** tells the listeners). + *******************************************************************************/ + @Override + protected void apply(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + String alias = ActionSupport.aliasOf(record); + if("PERMANENT".equals(record.getValueString("_mode")) && !ActionSupport.isAdmin()) + { + throw (new com.kingsrook.qbits.objectstorage.api.StorageException(com.kingsrook.qbits.objectstorage.api.StorageException.Reason.ACCESS_DENIED, "a permanent delete needs object_storage.admin")); + } + DeleteResult result = ActionSupport.storage(alias).delete(DeleteRequest.of(record.getValueString("objectKey"))); + switch(result.getMode()) + { + case SOFT_DELETE_MARKER -> marker.incrementCountAndAddPrimaryKey(record.getValue("id")); + case SOFT_TRASH -> trash.incrementCountAndAddPrimaryKey(record.getValue("id")); + case PERMANENT -> permanent.incrementCountAndAddPrimaryKey(record.getValue("id")); + case SKIPPED_OBJECT_LOCK -> lockedNow.incrementCountAndAddPrimaryKey(record.getValue("id")); + default -> gone.incrementCountAndAddPrimaryKey(record.getValue("id")); + } + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Result lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + marker.addSelfToListIfAnyCount(lines); + trash.addSelfToListIfAnyCount(lines); + permanent.addSelfToListIfAnyCount(lines); + lockedNow.addSelfToListIfAnyCount(lines); + gone.addSelfToListIfAnyCount(lines); + } + } + + + + /******************************************************************************* + ** Restore, review. + *******************************************************************************/ + public static class RestoreTransform extends BaseTransform + { + private final ProcessSummaryLine unmark = EtlSupport.line(Status.OK, "delete marker will be removed", "delete markers will be removed", "delete marker was removed", "delete markers were removed"); + private final ProcessSummaryLine fromTrash = EtlSupport.line(Status.OK, "object will be copied back from the trash", "objects will be copied back from the trash", + "object was copied back from the trash", "objects were copied back from the trash"); + private final ProcessSummaryLine notDeleted = EtlSupport.line(Status.INFO, "object is not deleted (no change)", "objects are not deleted (no change)", + "object was not deleted (no change)", "objects were not deleted (no change)"); + private final ProcessSummaryLine cannot = EtlSupport.line(Status.WARNING, "object cannot be restored: its bucket has no versioning or trash", "objects cannot be restored: their buckets have no versioning or trash", + "object could not be restored: its bucket has no versioning or trash", "objects could not be restored: their buckets have no versioning or trash"); + private final ProcessSummaryLine protectedLine = EtlSupport.line(Status.WARNING, "object is skipped: it is protected", "objects are skipped: they are protected", + "object was skipped: it is protected", "objects were skipped: they are protected"); + private final Map modes = new HashMap<>(); + + + + /******************************************************************************* + ** Classify one object. + *******************************************************************************/ + @Override + protected void handle(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + java.io.Serializable id = record.getValue("id"); + if(!Boolean.TRUE.equals(record.getValueBoolean("deleted"))) + { + notDeleted.incrementCountAndAddPrimaryKey(id); + return; + } + String alias = ActionSupport.aliasOf(record); + if(ProtectionRules.check(alias, record.getValueString("objectKey"), ProtectionRules.Op.WRITE, com.kingsrook.qbits.objectstorage.api.StorageCaller.user()).blocked()) + { + protectedLine.incrementCountAndAddPrimaryKey(id); + return; + } + DeleteMode mode = modes.computeIfAbsent(alias, a -> ActionSupport.storage(a).plannedDeleteMode()); + if(mode == DeleteMode.SOFT_DELETE_MARKER) + { + unmark.incrementCountAndAddPrimaryKey(id); + } + else if(mode == DeleteMode.SOFT_TRASH) + { + fromTrash.incrementCountAndAddPrimaryKey(id); + } + else + { + cannot.incrementCountAndAddPrimaryKey(id); + return; + } + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** The lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + unmark.addSelfToListIfAnyCount(lines); + fromTrash.addSelfToListIfAnyCount(lines); + notDeleted.addSelfToListIfAnyCount(lines); + cannot.addSelfToListIfAnyCount(lines); + protectedLine.addSelfToListIfAnyCount(lines); + } + + + + /******************************************************************************* + ** The skips, as the Result screen shows them. + *******************************************************************************/ + @Override + protected List skipLines() + { + return (List.of(notDeleted, cannot, protectedLine)); + } + } + + + + /******************************************************************************* + ** Restore, execute. + *******************************************************************************/ + public static class RestoreLoad extends BaseLoad + { + private final ProcessSummaryLine restored = EtlSupport.line(Status.OK, "object will be restored", "objects will be restored", "object was restored", "objects were restored"); + private final ProcessSummaryLine nothing = EtlSupport.line(Status.INFO, "object had nothing to restore (it may have been purged)", "objects had nothing to restore (they may have been purged)", + "object had nothing to restore (it may have been purged)", "objects had nothing to restore (they may have been purged)"); + + + + /******************************************************************************* + ** Restore one object. + *******************************************************************************/ + @Override + protected void apply(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + StorageBucket storage = ActionSupport.storage(ActionSupport.aliasOf(record)); + if(storage.restore(record.getValueString("objectKey"))) + { + restored.incrementCountAndAddPrimaryKey(record.getValue("id")); + output.getRecords().add(record); + } + else + { + nothing.incrementCountAndAddPrimaryKey(record.getValue("id")); + } + } + + + + /******************************************************************************* + ** Result lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + restored.addSelfToListIfAnyCount(lines); + nothing.addSelfToListIfAnyCount(lines); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/processes/EtlSupport.java b/src/main/java/com/kingsrook/qbits/objectstorage/processes/EtlSupport.java new file mode 100644 index 0000000..cd8093b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/processes/EtlSupport.java @@ -0,0 +1,171 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.processes; + + +import java.io.Serializable; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; + + +/******************************************************************************* + ** Builds the qbit's processes as standard streamed ETLs: record input from the + ** selected rows, the current filter or all rows; a transform step that produces + ** the Review summary lines; a load step that does the work; and the standard + ** Result. Processes are table or record actions, never in the nav. + *******************************************************************************/ +public final class EtlSupport +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private EtlSupport() + { + } + + + + /******************************************************************************* + ** A standard streamed ETL process over a table, with full validation (the + ** Review step is the dry run). + *******************************************************************************/ + public static QProcessMetaData etl(String name, String label, String icon, String table, Class transform, + Class load, String preview, String permission) + { + return (etl(name, label, icon, table, table, ExtractViaQueryStep.class, transform, load, preview, permission)); + } + + + + /******************************************************************************* + ** A streamed ETL whose records come from a custom extract step (for example the + ** objects under selected folders), shown as an action on another table. + *******************************************************************************/ + public static QProcessMetaData etl(String name, String label, String icon, String recordTable, String actionTable, Class extract, + Class transform, Class load, String preview, String permission) + { + Map values = new HashMap<>(); + values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, recordTable); + values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, recordTable); + values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true); + values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true); + values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, preview); + QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(extract, transform, load, values); + process.setName(name); + String table = actionTable; + process.setLabel(label); + process.setIcon(new QIcon(icon)); + process.setTableName(table); + process.setPermissionRules(ObjectStorageTables.permissionRules()); + ObjectStorageRuntime.processPermissions().put(name, permission); + return (process); + } + + + + /******************************************************************************* + ** A summary line that is plain text with no count in front of it (the standard + ** line starts at a count of 0, which the screen would show as "0 ..."). + *******************************************************************************/ + public static ProcessSummaryLine text(Status status, String message) + { + ProcessSummaryLine line = new ProcessSummaryLine(status, message); + line.setCount(null); + return (line); + } + + + + /******************************************************************************* + ** Record the permission a process needs. + *******************************************************************************/ + public static void permission(String processName, String permission) + { + ObjectStorageRuntime.processPermissions().put(processName, permission); + } + + + + /******************************************************************************* + ** Put an input form (help text plus fields) in front of an action, the standard + ** way a QQQ process asks for values before its Review. + *******************************************************************************/ + public static QProcessMetaData withInput(QProcessMetaData process, String label, String help, QFieldMetaData... fields) + { + QFrontendStepMetaData input = new QFrontendStepMetaData().withName("input").withLabel(label).withFormFields(List.of(fields)) + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.HELP_TEXT).withValue("text", help)) + .withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)); + process.withStep(0, input); + return (process); + } + + + + /******************************************************************************* + ** A string field for an input form. + *******************************************************************************/ + public static QFieldMetaData stringField(String name, String label, boolean required) + { + return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label).withIsRequired(required)); + } + + + + /******************************************************************************* + ** A boolean field for an input form. + *******************************************************************************/ + public static QFieldMetaData booleanField(String name, String label, boolean defaultValue) + { + return (new QFieldMetaData(name, QFieldType.BOOLEAN).withLabel(label).withDefaultValue(defaultValue)); + } + + + + /******************************************************************************* + ** A summary line with future (Review) and past (Result) wording. + *******************************************************************************/ + public static ProcessSummaryLine line(Status status, String singularFuture, String pluralFuture, String singularPast, String pluralPast) + { + ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null); + line.setSingularFutureMessage(singularFuture); + line.setPluralFutureMessage(pluralFuture); + line.setSingularPastMessage(singularPast); + line.setPluralPastMessage(pluralPast); + return (line); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/processes/FolderProcesses.java b/src/main/java/com/kingsrook/qbits/objectstorage/processes/FolderProcesses.java new file mode 100644 index 0000000..7d14832 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/processes/FolderProcesses.java @@ -0,0 +1,243 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.processes; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.objectstorage.api.StorageKeys; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qbits.objectstorage.permissions.ObjectStoragePermissions; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep; + + +/******************************************************************************* + ** Folder record actions: "Move or rename folder" and "Delete folder contents". + ** A folder is a prefix, so these act on the objects under the selected + ** folders, with exactly the same Review lines, skip rules and Result as the + ** object actions (referenced, protected and locked objects are skipped). + *******************************************************************************/ +public final class FolderProcesses +{ + public static final String RENAME_NAME = "objectStorageRenameFolder"; + public static final String DELETE_NAME = "objectStorageDeleteFolder"; + + public static final String FIELD_NEW_PATH = "newPath"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private FolderProcesses() + { + } + + + + /******************************************************************************* + ** The processes. + *******************************************************************************/ + public static List processes() + { + return (List.of( + EtlSupport.withInput(EtlSupport.etl(RENAME_NAME, "Move or rename folder", "drive_file_rename_outline", ObjectStorageTables.OBJECT, ObjectStorageTables.FOLDER, FolderObjectsExtractStep.class, + FolderMoveTransform.class, CopyMoveProcesses.MoveLoad.class, "This is a preview of the objects under the folder that will be moved.", ObjectStoragePermissions.WRITE), + "Move or rename folder", "Every object under the folder moves to the new folder path. A move is a copy followed by a delete, not one atomic step.", + EtlSupport.stringField(FIELD_NEW_PATH, "New folder path (for example media/archive/2026/)", true), + new QFieldMetaData(CopyMoveProcesses.FIELD_DEST_BUCKET, QFieldType.INTEGER).withLabel("Destination bucket (leave blank for the same bucket)").withPossibleValueSourceName(ObjectStorageTables.BUCKET), + EtlSupport.booleanField(CopyMoveProcesses.FIELD_OVERWRITE, "Overwrite objects that already exist at the destination", false)), + EtlSupport.etl(DELETE_NAME, "Delete folder contents", "folder_delete", ObjectStorageTables.OBJECT, ObjectStorageTables.FOLDER, FolderObjectsExtractStep.class, + DeleteRestoreProcesses.DeleteTransform.class, DeleteRestoreProcesses.DeleteLoad.class, "This is a preview of the objects under the folder that will be deleted. Referenced, protected and locked objects are skipped.", + ObjectStoragePermissions.DELETE))); + } + + + + /******************************************************************************* + ** Extracts the live objects under the selected folders (selected rows, the + ** current filter, or all folders): the folder selection is read as the user + ** (so per-bucket locks apply), then the object rows under those prefixes. + *******************************************************************************/ + public static class FolderObjectsExtractStep extends ExtractViaQueryStep + { + /******************************************************************************* + ** The folders the user selected. + *******************************************************************************/ + public List selectedFolders(RunBackendStepInput input) throws QException + { + QQueryFilter folderFilter = super.getQueryFilter(input); + return (new QueryAction().execute(new QueryInput(ObjectStorageTables.FOLDER).withFilter(folderFilter)).getRecords()); + } + + + + /******************************************************************************* + ** The object filter: live objects under any selected folder (a folder inside + ** another selected folder adds nothing). + *******************************************************************************/ + @Override + protected QQueryFilter getQueryFilter(RunBackendStepInput input) throws QException + { + List folders = selectedFolders(input); + List top = new ArrayList<>(); + for(QRecord folder : folders) + { + boolean nested = false; + for(QRecord other : folders) + { + if(other != folder && other.getValueInteger("bucketId").equals(folder.getValueInteger("bucketId")) && folder.getValueString("path").startsWith(other.getValueString("path")) + && !folder.getValueString("path").equals(other.getValueString("path"))) + { + nested = true; + } + } + if(!nested) + { + top.add(folder); + } + } + if(top.isEmpty()) + { + return (new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, List.of(-1)))); + } + QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR); + for(QRecord folder : top) + { + filter.withSubFilter(new QQueryFilter(new QFilterCriteria("bucketId", QCriteriaOperator.EQUALS, folder.getValueInteger("bucketId")), + new QFilterCriteria("deleted", QCriteriaOperator.EQUALS, false), new QFilterCriteria("objectKey", QCriteriaOperator.STARTS_WITH, folder.getValueString("path")))); + } + return (filter); + } + } + + + + /******************************************************************************* + ** Move or rename folder, review: the objects move under the new folder path. + *******************************************************************************/ + public static class FolderMoveTransform extends CopyMoveProcesses.CopyMoveTransform + { + private List folders; + private String error; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + public FolderMoveTransform() + { + super(EtlSupport.line(Status.OK, "object will be moved", "objects will be moved", "object was moved", "objects were moved")); + } + + + + /******************************************************************************* + ** A move. + *******************************************************************************/ + @Override + protected boolean isMove() + { + return (true); + } + + + + /******************************************************************************* + ** The object's new key: the new folder path plus what followed the old path. + *******************************************************************************/ + @Override + protected String destKeyFor(QRecord record, RunBackendStepInput input) + { + try + { + if(folders == null) + { + folders = new FolderObjectsExtractStep().selectedFolders(input); + } + } + catch(QException e) + { + throw (new IllegalArgumentException("Could not read the selected folders")); + } + if(folders.size() != 1) + { + error = "Rename works on one folder at a time; " + folders.size() + " are selected."; + throw (new IllegalArgumentException(error)); + } + String oldPath = folders.get(0).getValueString("path"); + String newPath = StorageKeys.normalizePrefix(input.getValueString(FIELD_NEW_PATH)); + if(newPath.isEmpty()) + { + error = "A new folder path is required."; + throw (new IllegalArgumentException(error)); + } + if(!newPath.endsWith("/")) + { + newPath = newPath + "/"; + } + return (StorageKeys.normalize(newPath + record.getValueString("objectKey").substring(oldPath.length()))); + } + + + + /******************************************************************************* + ** Review lines, with the input problem first. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + if(error != null) + { + lines.add(EtlSupport.text(Status.ERROR, error)); + } + super.addLines(lines); + } + } + + + + /******************************************************************************* + ** The ids of folders, for tests. + *******************************************************************************/ + static List idsOf(List folders) + { + List ids = new ArrayList<>(); + folders.forEach(f -> ids.add(f.getValue("id"))); + return (ids); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/processes/LinkProcesses.java b/src/main/java/com/kingsrook/qbits/objectstorage/processes/LinkProcesses.java new file mode 100644 index 0000000..5dd85ce --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/processes/LinkProcesses.java @@ -0,0 +1,172 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.processes; + + +import java.net.URL; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.objectstorage.api.PresignGet; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qbits.objectstorage.permissions.ObjectStoragePermissions; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; + + +/******************************************************************************* + ** "Create download link": a presigned GET for the selected objects. The Result + ** shows each link once (the operation log keeps the key, the expiry and who + ** asked, never the signature, because a presigned URL is a bearer token). The + ** link lasts five minutes, or the bucket's maximum if that is shorter. + *******************************************************************************/ +public final class LinkProcesses +{ + public static final String DOWNLOAD_NAME = "objectStorageCreateDownloadLink"; + + private static final int MAX_LINKS_PER_RUN = 25; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private LinkProcesses() + { + } + + + + /******************************************************************************* + ** The processes. + *******************************************************************************/ + public static List processes() + { + return (List.of(EtlSupport.etl(DOWNLOAD_NAME, "Create download link", "link", ObjectStorageTables.OBJECT, DownloadTransform.class, DownloadLoad.class, + "This is a preview of the presigned download links that will be created.", ObjectStoragePermissions.VIEW))); + } + + + + /******************************************************************************* + ** Create download link, review. + *******************************************************************************/ + public static class DownloadTransform extends BaseTransform + { + private final ProcessSummaryLine will = EtlSupport.line(Status.OK, "download link will be created", "download links will be created", "download link was created", "download links were created"); + private final ProcessSummaryLine deleted = EtlSupport.line(Status.INFO, "object is deleted (skipped)", "objects are deleted (skipped)", "object was deleted (skipped)", "objects were deleted (skipped)"); + private final ProcessSummaryLine tooMany = EtlSupport.line(Status.WARNING, "object is skipped: at most " + MAX_LINKS_PER_RUN + " links are made per run", "objects are skipped: at most " + MAX_LINKS_PER_RUN + " links are made per run", + "object was skipped: link limit", "objects were skipped: link limit"); + + + + /******************************************************************************* + ** Count. + *******************************************************************************/ + @Override + protected void handle(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + if(Boolean.TRUE.equals(record.getValueBoolean("deleted"))) + { + deleted.incrementCountAndAddPrimaryKey(record.getValue("id")); + return; + } + if(will.getCount() != null && will.getCount() >= MAX_LINKS_PER_RUN) + { + tooMany.incrementCountAndAddPrimaryKey(record.getValue("id")); + return; + } + will.incrementCountAndAddPrimaryKey(record.getValue("id")); + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Review lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + will.addSelfToListIfAnyCount(lines); + deleted.addSelfToListIfAnyCount(lines); + tooMany.addSelfToListIfAnyCount(lines); + lines.add(EtlSupport.text(Status.INFO, "Each link works for about five minutes and is shown once on the next screen. Anyone who has a link can download that object until it expires.")); + } + + + + /******************************************************************************* + ** The skips, as the Result screen shows them. + *******************************************************************************/ + @Override + protected List skipLines() + { + return (List.of(deleted, tooMany)); + } + } + + + + /******************************************************************************* + ** Create download link, execute. + *******************************************************************************/ + public static class DownloadLoad extends BaseLoad + { + private final ArrayList links = new ArrayList<>(); + + + + /******************************************************************************* + ** Create one link. + *******************************************************************************/ + @Override + protected void apply(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + String alias = ActionSupport.aliasOf(record); + StorageBucketConfig config = ActionSupport.config(alias); + Duration ttl = Duration.ofSeconds(Math.min(300, config.getMaxPresignGetSeconds())); + URL url = ActionSupport.storage(alias).presignGet(PresignGet.of(record.getValueString("objectKey")).ttl(ttl).downloadAs(record.getValueString("name"))); + ProcessSummaryLine line = EtlSupport.text(Status.OK, "Download link for " + alias + "/" + record.getValueString("objectKey") + " (expires in " + ttl.toMinutes() + " minutes; shown once)"); + line.setBulletsOfText(new ArrayList<>(List.of(url.toString()))); + links.add(line); + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Result lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + lines.addAll(links); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/processes/NightlyProcess.java b/src/main/java/com/kingsrook/qbits/objectstorage/processes/NightlyProcess.java new file mode 100644 index 0000000..f581aeb --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/processes/NightlyProcess.java @@ -0,0 +1,97 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.processes; + + +import java.util.List; +import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig; +import com.kingsrook.qbits.objectstorage.index.StorageJobs; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData; + + +/******************************************************************************* + ** The nightly job: scan every indexed bucket, read the HEAD backlog, rescan + ** references, snapshot usage, purge expired trash and raise the freshness + ** alerts. Hidden (never in the nav); scheduled only when the config names a + ** scheduler. + *******************************************************************************/ +public final class NightlyProcess +{ + public static final String NAME = "objectStorageNightly"; + + private static final QLogger LOG = QLogger.getLogger(NightlyProcess.class); + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private NightlyProcess() + { + } + + + + /******************************************************************************* + ** The process (empty when there is nothing to schedule). + *******************************************************************************/ + public static List processes(ObjectStorageQBitConfig config) + { + QProcessMetaData process = new QProcessMetaData().withName(NAME).withLabel("Storage nightly job").withIcon(new QIcon("schedule")).withIsHidden(true) + .withPermissionRules(ObjectStorageTables.permissionRules()) + .withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(NightlyStep.class))); + if(config.getSchedulerName() != null) + { + process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(config.getNightlyIntervalSeconds()).withInitialDelaySeconds(120)); + } + EtlSupport.permission(NAME, com.kingsrook.qbits.objectstorage.permissions.ObjectStoragePermissions.ADMIN); + return (List.of(process)); + } + + + + /******************************************************************************* + ** The step. + *******************************************************************************/ + public static class NightlyStep implements BackendStep + { + /******************************************************************************* + ** Run the nightly work and record what happened. + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + List log = StorageJobs.nightly(); + LOG.info("Storage nightly job finished", com.kingsrook.qqq.backend.core.logging.LogUtils.logPair("steps", log.size())); + output.addValue("log", String.join("\n", log)); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/processes/ObjectStorageProcesses.java b/src/main/java/com/kingsrook/qbits/objectstorage/processes/ObjectStorageProcesses.java new file mode 100644 index 0000000..b20d928 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/processes/ObjectStorageProcesses.java @@ -0,0 +1,61 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.processes; + + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; + + +/******************************************************************************* + ** All of the qbit's processes. Every action on records is a standard streamed + ** ETL (Review is the dry run; Result has summary lines). None is in the nav. + *******************************************************************************/ +public final class ObjectStorageProcesses +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private ObjectStorageProcesses() + { + } + + + + /******************************************************************************* + ** All processes for a config (also records their permissions). + *******************************************************************************/ + public static List processes(ObjectStorageQBitConfig config) + { + List processes = new ArrayList<>(TestConnectionProcess.processes()); + processes.addAll(BucketProcesses.processes()); + processes.addAll(ObjectWriteProcesses.processes()); + processes.addAll(CopyMoveProcesses.processes()); + processes.addAll(DeleteRestoreProcesses.processes()); + processes.addAll(LinkProcesses.processes()); + processes.addAll(FolderProcesses.processes()); + processes.addAll(UploadProcess.processes()); + processes.addAll(NightlyProcess.processes(config)); + return (processes); + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/processes/ObjectWriteProcesses.java b/src/main/java/com/kingsrook/qbits/objectstorage/processes/ObjectWriteProcesses.java new file mode 100644 index 0000000..5180f69 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/processes/ObjectWriteProcesses.java @@ -0,0 +1,762 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.processes; + + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import com.kingsrook.qbits.objectstorage.api.Capability; +import com.kingsrook.qbits.objectstorage.api.ObjectHeaders; +import com.kingsrook.qbits.objectstorage.api.StorageBucket; +import com.kingsrook.qbits.objectstorage.api.StorageCaller; +import com.kingsrook.qbits.objectstorage.api.StoredObject; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.index.ObjectIndex; +import com.kingsrook.qbits.objectstorage.internal.CapabilityStore; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.internal.ProtectionRules; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qbits.objectstorage.permissions.ObjectStoragePermissions; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** Header and metadata actions on objects: Refresh from bucket, Apply header + ** policy (which replaces app-specific backfills such as the CDN qbit's + ** cdnBackfillObjectHeaders), Change headers and metadata, and Edit tags. + ** Rewriting headers copies an object onto itself, which changes Last-Modified + ** and can change the ETag; the Review says so. + *******************************************************************************/ +public final class ObjectWriteProcesses +{ + public static final String REFRESH_NAME = "objectStorageRefreshObjects"; + public static final String POLICY_NAME = "objectStorageApplyHeaderPolicy"; + public static final String HEADERS_NAME = "objectStorageChangeHeaders"; + public static final String TAGS_NAME = "objectStorageEditTags"; + + public static final String FIELD_CACHE_CONTROL = "cacheControl"; + public static final String FIELD_CONTENT_TYPE = "contentType"; + public static final String FIELD_CONTENT_DISPOSITION = "contentDisposition"; + public static final String FIELD_USER_METADATA = "userMetadata"; + public static final String FIELD_TAGS = "tags"; + + /** Typed into a header field to remove that header. */ + public static final String CLEAR = "-"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private ObjectWriteProcesses() + { + } + + + + /******************************************************************************* + ** The processes. + *******************************************************************************/ + public static List processes() + { + return (List.of( + EtlSupport.etl(REFRESH_NAME, "Refresh from bucket", "sync", ObjectStorageTables.OBJECT, RefreshTransform.class, RefreshLoad.class, + "This is a preview of the objects that will be read from the bucket.", ObjectStoragePermissions.VIEW), + EtlSupport.etl(POLICY_NAME, "Apply header policy", "rule", ObjectStorageTables.OBJECT, PolicyTransform.class, PolicyLoad.class, + "This is a preview of the objects whose headers will be rewritten to match the header policy.", ObjectStoragePermissions.WRITE), + EtlSupport.withInput(EtlSupport.etl(HEADERS_NAME, "Change headers and metadata", "edit_note", ObjectStorageTables.OBJECT, HeadersTransform.class, HeadersLoad.class, + "This is a preview of the headers that will change.", ObjectStoragePermissions.WRITE), + "Change headers and metadata", "Fill in only what you want to change; blank fields are left alone, and a single - removes that header. Metadata is one key=value per line; key= removes a key.", + EtlSupport.stringField(FIELD_CACHE_CONTROL, "Cache-Control", false), EtlSupport.stringField(FIELD_CONTENT_TYPE, "Content-Type", false), + EtlSupport.stringField(FIELD_CONTENT_DISPOSITION, "Content-Disposition", false), new QFieldMetaData(FIELD_USER_METADATA, QFieldType.TEXT).withLabel("Metadata (key=value per line)")), + EtlSupport.withInput(EtlSupport.etl(TAGS_NAME, "Edit tags", "sell", ObjectStorageTables.OBJECT, TagsTransform.class, TagsLoad.class, + "This is a preview of the objects whose tags will be replaced.", ObjectStoragePermissions.WRITE), + "Edit tags", "Replaces the tags of the selected objects. One key=value per line; leave it empty to remove all tags. Providers without object tagging are skipped.", + new QFieldMetaData(FIELD_TAGS, QFieldType.TEXT).withLabel("Tags (key=value per line)")))); + } + + + + /******************************************************************************* + ** Blank to null. + *******************************************************************************/ + static String blankToNull(String s) + { + return (StringUtils.hasContent(s) ? s.trim() : null); + } + + + + /******************************************************************************* + ** Parse "key=value" lines. A key with an empty value is kept with the empty + ** value (the caller decides what that means). + *******************************************************************************/ + public static Map parseKeyValues(String text) + { + Map map = new LinkedHashMap<>(); + if(text == null) + { + return (map); + } + for(String line : text.split("\\r?\\n")) + { + String trimmed = line.trim(); + if(trimmed.isEmpty()) + { + continue; + } + int eq = trimmed.indexOf('='); + if(eq <= 0) + { + throw (new IllegalArgumentException("Not key=value: " + trimmed)); + } + map.put(trimmed.substring(0, eq).trim(), trimmed.substring(eq + 1).trim()); + } + return (map); + } + + + + /******************************************************************************* + ** The current headers of an object row: from the index when known, else HEAD. + ** Empty when the object cannot be read. + *******************************************************************************/ + static Optional currentHeaders(QRecord row, StorageBucket storage) + { + if(Boolean.TRUE.equals(row.getValueBoolean("headersKnown"))) + { + ObjectHeaders h = new ObjectHeaders().withCacheControl(row.getValueString("cacheControl")).withContentType(row.getValueString("contentType")) + .withContentDisposition(row.getValueString("contentDisposition")); + try + { + h.setUserMetadata(StringUtils.hasContent(row.getValueString("userMetadata")) ? com.kingsrook.qqq.backend.core.utils.JsonUtils.toObject(row.getValueString("userMetadata"), new com.fasterxml.jackson.core.type.TypeReference>() + { + }) : new LinkedHashMap<>()); + } + catch(java.io.IOException e) + { + h.setUserMetadata(new LinkedHashMap<>()); + } + return (Optional.of(h)); + } + try + { + Optional head = storage.head(row.getValueString("objectKey")); + return (head.map(StoredObject::getHeaders)); + } + catch(RuntimeException e) + { + return (Optional.empty()); + } + } + + + + /******************************************************************************* + ** Refresh from bucket, review. + *******************************************************************************/ + public static class RefreshTransform extends BaseTransform + { + private final ProcessSummaryLine will = EtlSupport.line(Status.OK, "object will be read from the bucket", "objects will be read from the bucket", "object was refreshed", "objects were refreshed"); + + + + /******************************************************************************* + ** Count. + *******************************************************************************/ + @Override + protected void handle(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + will.incrementCountAndAddPrimaryKey(record.getValue("id")); + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Review lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + will.addSelfToListIfAnyCount(lines); + } + } + + + + /******************************************************************************* + ** Refresh from bucket, execute. + *******************************************************************************/ + public static class RefreshLoad extends BaseLoad + { + private final ProcessSummaryLine refreshed = EtlSupport.line(Status.OK, "object will be refreshed", "objects will be refreshed", "object was refreshed from the bucket", "objects were refreshed from the bucket"); + private final ProcessSummaryLine gone = EtlSupport.line(Status.WARNING, "object no longer exists and will be marked deleted", "objects no longer exist and will be marked deleted", + "object no longer exists and was marked deleted", "objects no longer exist and were marked deleted"); + private final ProcessSummaryLine unreadable = EtlSupport.line(Status.ERROR, "object could not be read", "objects could not be read", "object could not be read", "objects could not be read"); + + + + /******************************************************************************* + ** Refresh one object (a page at a time would be faster; HEAD is per object anyway). + *******************************************************************************/ + @Override + protected void apply(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + int[] counts = ObjectIndex.refresh(ActionSupport.aliasOf(record), List.of(record)); + if(counts[2] > 0) + { + unreadable.incrementCountAndAddPrimaryKey(record.getValue("id")); + } + else if(counts[1] > 0) + { + gone.incrementCountAndAddPrimaryKey(record.getValue("id")); + } + else + { + refreshed.incrementCountAndAddPrimaryKey(record.getValue("id")); + } + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Result lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + refreshed.addSelfToListIfAnyCount(lines); + gone.addSelfToListIfAnyCount(lines); + unreadable.addSelfToListIfAnyCount(lines); + } + } + + + + /******************************************************************************* + ** Apply header policy, review: already correct / will change / failed to read. + *******************************************************************************/ + public static class PolicyTransform extends BaseTransform + { + private final ProcessSummaryLine correct = EtlSupport.line(Status.INFO, "object already matches the policy (or no rule applies)", "objects already match the policy (or no rule applies)", + "object already matched the policy (no change)", "objects already matched the policy (no change)"); + private final ProcessSummaryLine change = EtlSupport.line(Status.OK, "object will change to match the policy", "objects will change to match the policy", + "object was changed to match the policy", "objects were changed to match the policy"); + private final ProcessSummaryLine unreadable = EtlSupport.line(Status.ERROR, "object could not be read, so it is skipped", "objects could not be read, so they are skipped", + "object could not be read, so it was skipped", "objects could not be read, so they were skipped"); + private final ProcessSummaryLine protectedLine = EtlSupport.line(Status.WARNING, "object is skipped: it is protected", "objects are skipped: they are protected", + "object was skipped: it is protected", "objects were skipped: they are protected"); + private final ProcessSummaryLine backup = EtlSupport.line(Status.INFO, "object is skipped: it is in a backup bucket (the policy never applies there)", + "objects are skipped: they are in backup buckets (the policy never applies there)", "object was skipped: backup bucket", "objects were skipped: backup buckets"); + private final ProcessSummaryLine deleted = EtlSupport.line(Status.INFO, "object is skipped: it is deleted", "objects are skipped: they are deleted", "object was skipped: it is deleted", + "objects were skipped: they are deleted"); + + + + /******************************************************************************* + ** Compare one object with the policy. + *******************************************************************************/ + @Override + protected void handle(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + Serializable id = record.getValue("id"); + if(Boolean.TRUE.equals(record.getValueBoolean("deleted"))) + { + deleted.incrementCountAndAddPrimaryKey(id); + return; + } + ActionSupport.Classification c = ActionSupport.classify(record, ProtectionRules.Op.WRITE, false, false); + switch(c.skip()) + { + case PROTECTED: + protectedLine.incrementCountAndAddPrimaryKey(id); + return; + case BACKUP_BUCKET: + backup.incrementCountAndAddPrimaryKey(id); + return; + case NO_BUCKET_ACCESS: + noAccess.incrementCountAndAddPrimaryKey(id); + return; + default: + break; + } + String alias = ActionSupport.aliasOf(record); + StorageBucketConfig config = ActionSupport.config(alias); + Optional current = currentHeaders(record, ActionSupport.storage(alias)); + if(current.isEmpty()) + { + unreadable.incrementCountAndAddPrimaryKey(id); + return; + } + String key = record.getValueString("objectKey"); + ObjectHeaders wanted = ObjectStorageRuntime.headerPolicy().headersFor(config, key, current.get().getContentType()); + if(ObjectIndex.matchesPolicy(config, key, current.get())) + { + correct.incrementCountAndAddPrimaryKey(id); + return; + } + change.incrementCountAndAddPrimaryKey(id); + if(change.getBulletsOfText() == null) + { + change.setBulletsOfText(new ArrayList<>()); + } + if(change.getBulletsOfText().size() < 5) + { + change.getBulletsOfText().add(key + ": Cache-Control " + (current.get().getCacheControl() == null ? "(none)" : current.get().getCacheControl()) + " -> " + + (wanted.getCacheControl() == null ? "(unchanged)" : wanted.getCacheControl())); + } + record.setValue("_wantCache", wanted.getCacheControl()); + record.setValue("_wantDisposition", wanted.getContentDisposition()); + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Review lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + change.addSelfToListIfAnyCount(lines); + if(change.getCount() != null && change.getCount() > 0) + { + lines.add(EtlSupport.text(Status.INFO, "Rewriting headers copies each object onto itself, which changes Last-Modified and can change the ETag.")); + } + correct.addSelfToListIfAnyCount(lines); + unreadable.addSelfToListIfAnyCount(lines); + protectedLine.addSelfToListIfAnyCount(lines); + backup.addSelfToListIfAnyCount(lines); + deleted.addSelfToListIfAnyCount(lines); + } + + + + /******************************************************************************* + ** The skips, as the Result screen shows them. + *******************************************************************************/ + @Override + protected List skipLines() + { + return (List.of(correct, unreadable, protectedLine, backup, deleted)); + } + } + + + + /******************************************************************************* + ** Apply header policy, execute. + *******************************************************************************/ + public static class PolicyLoad extends BaseLoad + { + private final ProcessSummaryLine changed = EtlSupport.line(Status.OK, "object will change to match the policy", "objects will change to match the policy", + "object was changed to match the policy", "objects were changed to match the policy"); + + + + /******************************************************************************* + ** Rewrite one object's headers. + *******************************************************************************/ + @Override + protected void apply(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + ActionSupport.storage(ActionSupport.aliasOf(record)).updateHeaders(record.getValueString("objectKey"), + new ObjectHeaders().withCacheControl(record.getValueString("_wantCache")).withContentDisposition(record.getValueString("_wantDisposition"))); + changed.incrementCountAndAddPrimaryKey(record.getValue("id")); + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Result lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + changed.addSelfToListIfAnyCount(lines); + } + } + + + + /******************************************************************************* + ** Change headers and metadata, review: before -> after. + *******************************************************************************/ + public static class HeadersTransform extends BaseTransform + { + private final ProcessSummaryLine change = EtlSupport.line(Status.OK, "object will change", "objects will change", "object was changed", "objects were changed"); + private final ProcessSummaryLine same = EtlSupport.line(Status.INFO, "object already has these values (no change)", "objects already have these values (no change)", + "object already had these values (no change)", "objects already had these values (no change)"); + private final ProcessSummaryLine unreadable = EtlSupport.line(Status.ERROR, "object could not be read, so it is skipped", "objects could not be read, so they are skipped", + "object could not be read, so it was skipped", "objects could not be read, so they were skipped"); + private final ProcessSummaryLine protectedLine = EtlSupport.line(Status.WARNING, "object is skipped: it is protected", "objects are skipped: they are protected", + "object was skipped: it is protected", "objects were skipped: they are protected"); + private final ProcessSummaryLine backup = EtlSupport.line(Status.INFO, "object is skipped: it is in a backup bucket", "objects are skipped: they are in backup buckets", + "object was skipped: backup bucket", "objects were skipped: backup buckets"); + private boolean nothing = false; + private String inputError; + + + + /******************************************************************************* + ** Compare before and after for one object. + *******************************************************************************/ + @Override + protected void handle(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + Serializable id = record.getValue("id"); + ObjectHeaders wanted = wanted(input); + if(inputError != null) + { + return; + } + if(wanted.isEmpty()) + { + nothing = true; + return; + } + ActionSupport.Classification c = ActionSupport.classify(record, ProtectionRules.Op.WRITE, false, false); + switch(c.skip()) + { + case PROTECTED: + protectedLine.incrementCountAndAddPrimaryKey(id); + return; + case BACKUP_BUCKET: + backup.incrementCountAndAddPrimaryKey(id); + return; + case NO_BUCKET_ACCESS: + noAccess.incrementCountAndAddPrimaryKey(id); + return; + default: + break; + } + Optional current = currentHeaders(record, ActionSupport.storage(ActionSupport.aliasOf(record))); + if(current.isEmpty()) + { + unreadable.incrementCountAndAddPrimaryKey(id); + return; + } + ObjectHeaders now = current.get(); + List diffs = new ArrayList<>(); + diff(diffs, "Cache-Control", now.getCacheControl(), wanted.getCacheControl()); + diff(diffs, "Content-Type", now.getContentType(), wanted.getContentType()); + diff(diffs, "Content-Disposition", now.getContentDisposition(), wanted.getContentDisposition()); + if(wanted.getUserMetadata() != null) + { + for(Map.Entry e : wanted.getUserMetadata().entrySet()) + { + String before = now.getUserMetadata() == null ? null : now.getUserMetadata().get(e.getKey()); + diff(diffs, "metadata " + e.getKey(), before, e.getValue().isEmpty() ? CLEAR : e.getValue()); + } + } + if(diffs.isEmpty()) + { + same.incrementCountAndAddPrimaryKey(id); + return; + } + change.incrementCountAndAddPrimaryKey(id); + if(change.getBulletsOfText() == null) + { + change.setBulletsOfText(new ArrayList<>()); + } + if(change.getBulletsOfText().size() < 5) + { + change.getBulletsOfText().add(record.getValueString("objectKey") + ": " + String.join("; ", diffs)); + } + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** One before -> after entry when the wanted value differs. + *******************************************************************************/ + private static void diff(List diffs, String what, String before, String wanted) + { + if(wanted == null) + { + return; + } + String after = CLEAR.equals(wanted) ? null : wanted; + if(!java.util.Objects.equals(before, after)) + { + diffs.add(what + " " + (before == null ? "(none)" : before) + " -> " + (after == null ? "(removed)" : after)); + } + } + + + + /******************************************************************************* + ** What the user asked for, from the input form. + *******************************************************************************/ + private ObjectHeaders wanted(RunBackendStepInput input) + { + ObjectHeaders h = new ObjectHeaders().withCacheControl(ObjectWriteProcesses.blankToNull(input.getValueString(FIELD_CACHE_CONTROL))).withContentType(ObjectWriteProcesses.blankToNull(input.getValueString(FIELD_CONTENT_TYPE))) + .withContentDisposition(ObjectWriteProcesses.blankToNull(input.getValueString(FIELD_CONTENT_DISPOSITION))); + try + { + Map metadata = parseKeyValues(input.getValueString(FIELD_USER_METADATA)); + h.setUserMetadata(metadata.isEmpty() ? null : metadata); + } + catch(IllegalArgumentException e) + { + inputError = e.getMessage(); + } + return (h); + } + + + + /******************************************************************************* + ** Review lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + if(inputError != null) + { + lines.add(EtlSupport.text(Status.ERROR, "The metadata is not valid: " + inputError)); + return; + } + if(nothing) + { + lines.add(EtlSupport.text(Status.ERROR, "Nothing to change: fill in at least one header or metadata value.")); + return; + } + change.addSelfToListIfAnyCount(lines); + if(change.getCount() != null && change.getCount() > 0) + { + lines.add(EtlSupport.text(Status.INFO, "Changing headers copies each object onto itself, which changes Last-Modified and can change the ETag.")); + } + same.addSelfToListIfAnyCount(lines); + unreadable.addSelfToListIfAnyCount(lines); + protectedLine.addSelfToListIfAnyCount(lines); + backup.addSelfToListIfAnyCount(lines); + } + + + + /******************************************************************************* + ** The skips, as the Result screen shows them. + *******************************************************************************/ + @Override + protected List skipLines() + { + return (List.of(same, unreadable, protectedLine, backup)); + } + } + + + + /******************************************************************************* + ** Change headers and metadata, execute. + *******************************************************************************/ + public static class HeadersLoad extends BaseLoad + { + private final ProcessSummaryLine changed = EtlSupport.line(Status.OK, "object will change", "objects will change", "object was changed", "objects were changed"); + + + + /******************************************************************************* + ** Apply the requested headers to one object, merging the metadata. + *******************************************************************************/ + @Override + protected void apply(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + StorageBucket storage = ActionSupport.storage(ActionSupport.aliasOf(record)); + String key = record.getValueString("objectKey"); + StoredObject now = storage.head(key).orElseThrow(() -> new com.kingsrook.qbits.objectstorage.api.StorageException(com.kingsrook.qbits.objectstorage.api.StorageException.Reason.NOT_FOUND, "No such object")); + ObjectHeaders change = new ObjectHeaders(); + change.setCacheControl(resolve(input.getValueString(FIELD_CACHE_CONTROL), now.getHeaders().getCacheControl())); + change.setContentType(resolve(input.getValueString(FIELD_CONTENT_TYPE), now.getHeaders().getContentType())); + change.setContentDisposition(resolve(input.getValueString(FIELD_CONTENT_DISPOSITION), now.getHeaders().getContentDisposition())); + Map metadata = new LinkedHashMap<>(now.getHeaders().getUserMetadata() == null ? Map.of() : now.getHeaders().getUserMetadata()); + for(Map.Entry e : parseKeyValues(input.getValueString(FIELD_USER_METADATA)).entrySet()) + { + if(e.getValue().isEmpty()) + { + metadata.remove(e.getKey()); + } + else + { + metadata.put(e.getKey(), e.getValue()); + } + } + change.setUserMetadata(metadata); + storage.updateHeaders(key, change); + changed.incrementCountAndAddPrimaryKey(record.getValue("id")); + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** The new value of a header: blank keeps the current one, "-" removes it. + *******************************************************************************/ + private static String resolve(String requested, String current) + { + if(!StringUtils.hasContent(requested)) + { + return (current); + } + return (CLEAR.equals(requested.trim()) ? "" : requested.trim()); + } + + + + /******************************************************************************* + ** Result lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + changed.addSelfToListIfAnyCount(lines); + } + } + + + + /******************************************************************************* + ** Edit tags, review: only where tagging is supported. + *******************************************************************************/ + public static class TagsTransform extends BaseTransform + { + private final ProcessSummaryLine will = EtlSupport.line(Status.OK, "object will get these tags", "objects will get these tags", "object got these tags", "objects got these tags"); + private final ProcessSummaryLine unsupported = EtlSupport.line(Status.WARNING, "object is skipped: its provider has no object tagging (or it is untested)", + "objects are skipped: their providers have no object tagging (or it is untested)", "object was skipped: no object tagging", "objects were skipped: no object tagging"); + private final ProcessSummaryLine protectedLine = EtlSupport.line(Status.WARNING, "object is skipped: it is protected", "objects are skipped: they are protected", + "object was skipped: it is protected", "objects were skipped: they are protected"); + private String inputError; + + + + /******************************************************************************* + ** Classify one object. + *******************************************************************************/ + @Override + protected void handle(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + Serializable id = record.getValue("id"); + try + { + parseKeyValues(input.getValueString(FIELD_TAGS)); + } + catch(IllegalArgumentException e) + { + inputError = e.getMessage(); + return; + } + String alias = ActionSupport.aliasOf(record); + if(!CapabilityStore.usable(ActionSupport.storage(alias).capability(Capability.OBJECT_TAGGING))) + { + unsupported.incrementCountAndAddPrimaryKey(id); + return; + } + if(ProtectionRules.check(alias, record.getValueString("objectKey"), ProtectionRules.Op.WRITE, StorageCaller.user()).blocked()) + { + protectedLine.incrementCountAndAddPrimaryKey(id); + return; + } + will.incrementCountAndAddPrimaryKey(id); + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Review lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + if(inputError != null) + { + lines.add(EtlSupport.text(Status.ERROR, "The tags are not valid: " + inputError)); + return; + } + will.addSelfToListIfAnyCount(lines); + unsupported.addSelfToListIfAnyCount(lines); + protectedLine.addSelfToListIfAnyCount(lines); + } + + + + /******************************************************************************* + ** The skips, as the Result screen shows them. + *******************************************************************************/ + @Override + protected List skipLines() + { + return (List.of(unsupported, protectedLine)); + } + } + + + + /******************************************************************************* + ** Edit tags, execute. + *******************************************************************************/ + public static class TagsLoad extends BaseLoad + { + private final ProcessSummaryLine done = EtlSupport.line(Status.OK, "object will get these tags", "objects will get these tags", "object got these tags", "objects got these tags"); + + + + /******************************************************************************* + ** Replace one object's tags. + *******************************************************************************/ + @Override + protected void apply(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + ActionSupport.storage(ActionSupport.aliasOf(record)).setTags(record.getValueString("objectKey"), parseKeyValues(input.getValueString(FIELD_TAGS))); + done.incrementCountAndAddPrimaryKey(record.getValue("id")); + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Result lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + done.addSelfToListIfAnyCount(lines); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/processes/TestConnectionProcess.java b/src/main/java/com/kingsrook/qbits/objectstorage/processes/TestConnectionProcess.java new file mode 100644 index 0000000..066b1a7 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/processes/TestConnectionProcess.java @@ -0,0 +1,292 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.processes; + + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.internal.CapabilityProbe; +import com.kingsrook.qbits.objectstorage.internal.ConfigSync; +import com.kingsrook.qbits.objectstorage.internal.ConnectionRuntime; +import com.kingsrook.qbits.objectstorage.internal.Db; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qbits.objectstorage.permissions.ObjectStoragePermissions; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface; + + +/******************************************************************************* + ** "Test connection" and "Test connection with write tests": record actions on + ** object_storage_connection. Review lists what will be probed; Result writes + ** the capability rows, sets lastTestedAt and lastTestStatus, and shows one + ** summary line per bucket ("8 supported, 3 unsupported, 1 not permitted"), + ** plus clock skew and whether the presign host answers. + *******************************************************************************/ +public final class TestConnectionProcess +{ + public static final String READ_NAME = "objectStorageTestConnection"; + public static final String WRITE_NAME = "objectStorageTestConnectionWithWriteTests"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private TestConnectionProcess() + { + } + + + + /******************************************************************************* + ** The two processes. + *******************************************************************************/ + public static List processes() + { + return (List.of( + EtlSupport.etl(READ_NAME, "Test connection", "network_check", ObjectStorageTables.CONNECTION, ReadTransform.class, ReadLoad.class, + "This is a preview of what will be probed. Only read-only calls are made.", ObjectStoragePermissions.VIEW), + EtlSupport.etl(WRITE_NAME, "Test connection with write tests", "rule", ObjectStorageTables.CONNECTION, WriteTransform.class, WriteLoad.class, + "This is a preview of what will be probed. Write tests put, copy and delete a probe object under .qqq-probe/ in each unprotected bucket.", ObjectStoragePermissions.ADMIN))); + } + + + + /******************************************************************************* + ** Review: what will be probed. + *******************************************************************************/ + public static class ReadTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface + { + private final ProcessSummaryLine connections = EtlSupport.line(Status.OK, "connection will be tested", "connections will be tested", "connection was tested", "connections were tested"); + private final ProcessSummaryLine readBuckets = EtlSupport.line(Status.INFO, "bucket will get read-only probes", "buckets will get read-only probes", "bucket got read-only probes", "buckets got read-only probes"); + private final ProcessSummaryLine writeBuckets = EtlSupport.line(Status.WARNING, "bucket will get write tests", "buckets will get write tests", "bucket got write tests", "buckets got write tests"); + private final ProcessSummaryLine skipped = EtlSupport.line(Status.INFO, "bucket will not get write tests (protected, or object lock on a backup bucket)", + "buckets will not get write tests (protected, or object lock on a backup bucket)", "bucket did not get write tests", "buckets did not get write tests"); + + + + /******************************************************************************* + ** Whether this variant includes write tests. + *******************************************************************************/ + protected boolean includeWrite() + { + return (false); + } + + + + /******************************************************************************* + ** Count what will be probed. + *******************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + for(QRecord record : input.getRecords()) + { + String name = record.getValueString("name"); + if(ObjectStorageRuntime.getConfig().findConnection(name) == null) + { + continue; + } + connections.incrementCountAndAddPrimaryKey(record.getValue("id")); + ConnectionRuntime runtime = ObjectStorageRuntime.connection(name); + for(StorageBucketConfig bucket : ObjectStorageRuntime.getConfig().getBuckets()) + { + if(name.equals(bucket.getConnection())) + { + readBuckets.incrementCount(); + if(includeWrite()) + { + if(CapabilityProbe.writeProbeSkipReason(bucket, runtime) == null) + { + writeBuckets.incrementCount(); + } + else + { + skipped.incrementCount(); + } + } + } + } + output.getRecords().add(record); + } + } + + + + /******************************************************************************* + ** Review lines. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + ArrayList lines = new ArrayList<>(); + connections.addSelfToListIfAnyCount(lines); + readBuckets.addSelfToListIfAnyCount(lines); + writeBuckets.addSelfToListIfAnyCount(lines); + skipped.addSelfToListIfAnyCount(lines); + return (lines); + } + } + + + + /******************************************************************************* + ** Review for the variant with write tests. + *******************************************************************************/ + public static class WriteTransform extends ReadTransform + { + /******************************************************************************* + ** Includes write tests. + *******************************************************************************/ + @Override + protected boolean includeWrite() + { + return (true); + } + } + + + + /******************************************************************************* + ** Execute: run the probe, record the results. + *******************************************************************************/ + public static class ReadLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface + { + private final ArrayList lines = new ArrayList<>(); + + + + /******************************************************************************* + ** Whether this variant includes write tests. + *******************************************************************************/ + protected boolean includeWrite() + { + return (false); + } + + + + /******************************************************************************* + ** Probe each connection and update its row. + *******************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + for(QRecord record : input.getRecords()) + { + String name = record.getValueString("name"); + if(ObjectStorageRuntime.getConfig().findConnection(name) == null) + { + continue; + } + CapabilityProbe.Report report = CapabilityProbe.probeConnection(name, includeWrite()); + boolean ok = report.getProblems().isEmpty(); + for(String alias : report.getByBucket().keySet()) + { + StringBuilder text = new StringBuilder(alias + ": " + report.summaryFor(alias)); + for(String note : report.getNotes().getOrDefault(alias, List.of())) + { + text.append(" (").append(note).append(")"); + } + lines.add(EtlSupport.text(Status.INFO, text.toString())); + } + for(String problem : report.getProblems()) + { + lines.add(EtlSupport.text(Status.ERROR, problem)); + } + if(report.getClockSkewSeconds() != null) + { + boolean skewed = report.getClockSkewSeconds() > 900; + ok = ok && !skewed; + lines.add(new ProcessSummaryLine(skewed ? Status.ERROR : Status.INFO, "Clock skew against the store: " + report.getClockSkewSeconds() + " seconds" + + (skewed ? " (SigV4 fails beyond 15 minutes)" : ""))); + } + if(report.getPresignHostReachable() != null) + { + lines.add(new ProcessSummaryLine(report.getPresignHostReachable() ? Status.INFO : Status.ERROR, report.getPresignHostReachable() + ? "The presign host answers" : "The presign host could not be reached")); + } + + String status = ok ? "OK" : "PROBLEMS"; + String summary = String.join("; ", report.getByBucket().keySet().stream().map(a -> a + ": " + report.summaryFor(a)).toList()); + Db.update(ObjectStorageTables.CONNECTION, List.of(new QRecord().withValue("id", record.getValue("id")) + .withValue("lastTestedAt", Instant.now()).withValue("lastTestStatus", status).withValue("lastTestSummary", summary) + .withValue("clockSkewSeconds", report.getClockSkewSeconds() == null ? null : report.getClockSkewSeconds().intValue()))); + if(!ok) + { + ObjectStorageRuntime.alertSink().raise("storage.connection.failed", "connection:" + name, "error", "Connection " + name + " failed its test: " + String.join("; ", report.getProblems()), java.util.Map.of()); + } + else + { + ObjectStorageRuntime.alertSink().resolve("storage.connection.failed", "connection:" + name); + } + ConfigSync.ensureSynced(); + output.getRecords().add(record); + } + } + + + + /******************************************************************************* + ** Result lines. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + ArrayList result = new ArrayList<>(lines); + if(getTransformStep() instanceof ReadTransform transform) + { + transform.connections.addSelfToListIfAnyCount(result); + } + return (result); + } + } + + + + /******************************************************************************* + ** Execute for the variant with write tests. + *******************************************************************************/ + public static class WriteLoad extends ReadLoad + { + /******************************************************************************* + ** Includes write tests. + *******************************************************************************/ + @Override + protected boolean includeWrite() + { + return (true); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/objectstorage/processes/UploadProcess.java b/src/main/java/com/kingsrook/qbits/objectstorage/processes/UploadProcess.java new file mode 100644 index 0000000..c04ca14 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/objectstorage/processes/UploadProcess.java @@ -0,0 +1,475 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.processes; + + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import com.kingsrook.qbits.objectstorage.api.ObjectStorage; +import com.kingsrook.qbits.objectstorage.api.PutRequest; +import com.kingsrook.qbits.objectstorage.api.StorageCaller; +import com.kingsrook.qbits.objectstorage.api.StorageException; +import com.kingsrook.qbits.objectstorage.api.StorageKeys; +import com.kingsrook.qbits.objectstorage.config.BucketRole; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.internal.Db; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.internal.ProtectionRules; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qbits.objectstorage.permissions.ObjectStoragePermissions; +import com.kingsrook.qqq.backend.core.actions.tables.StorageAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.actions.tables.storage.StorageInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** "Upload files": v1 uploads go through the app server (up to the bucket's + ** upload limit, 100 MB per file by default) and are streamed to the store in + ** parts. The file arrives the standard QQQ way (a file-upload form field, kept + ** in the app's uploaded-file archive table), so the app needs its + ** uploadedFileArchiveTableName set, as for any file upload. Direct browser + ** uploads for larger files come later. + ** + ** From a folder record the destination is that folder; from the table you pick + ** the bucket and folder. Existing objects are never overwritten unless asked, + ** and the header policy decides the new objects' headers. + *******************************************************************************/ +public final class UploadProcess +{ + public static final String UPLOAD_NAME = "objectStorageUploadFiles"; + public static final String UPLOAD_TO_NAME = "objectStorageUploadToFolder"; + + public static final String FIELD_FILE = "theFile"; + public static final String FIELD_BUCKET = "uploadBucket"; + public static final String FIELD_PREFIX = "uploadPrefix"; + public static final String FIELD_OVER = "uploadOverwrite"; + + /** How uploaded files are opened; replaceable in tests (the default reads the app's archive table). */ + private static final Function DEFAULT_OPENER = file -> + { + try + { + return (new StorageAction().getInputStream((StorageInput) file)); + } + catch(QException e) + { + throw (new IllegalStateException("Could not open the uploaded file: " + e.getMessage(), e)); + } + }; + + private static volatile Function opener = DEFAULT_OPENER; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private UploadProcess() + { + } + + + + /******************************************************************************* + ** Replace how uploaded files are opened (tests). + *******************************************************************************/ + public static void setOpener(Function newOpener) + { + opener = newOpener == null ? DEFAULT_OPENER : newOpener; + } + + + + /******************************************************************************* + ** The processes. + *******************************************************************************/ + public static List processes() + { + QFieldMetaData file = new QFieldMetaData(FIELD_FILE, QFieldType.BLOB).withLabel("Files").withIsRequired(true) + .withFieldAdornment(AdornmentType.FileUploadAdornment.newFieldAdornment().withValue(AdornmentType.FileUploadAdornment.formatDragAndDrop()).withValue(AdornmentType.FileUploadAdornment.widthFull())); + QFieldMetaData bucket = new QFieldMetaData(FIELD_BUCKET, QFieldType.INTEGER).withLabel("Bucket").withPossibleValueSourceName(ObjectStorageTables.BUCKET); + QFieldMetaData prefix = EtlSupport.stringField(FIELD_PREFIX, "Folder (for example media/2026/; leave blank for the bucket root)", false); + QFieldMetaData over = EtlSupport.booleanField(FIELD_OVER, "Overwrite objects that already exist", false); + String help = "Uploads go through the app server and are limited per file (100 MB by default). The header policy decides the headers of the new objects."; + + QFieldMetaData requiredBucket = new QFieldMetaData(FIELD_BUCKET, QFieldType.INTEGER).withLabel("Bucket").withIsRequired(true).withPossibleValueSourceName(ObjectStorageTables.BUCKET); + return (List.of( + EtlSupport.withInput(EtlSupport.etl(UPLOAD_NAME, "Upload files", "upload_file", ObjectStorageTables.OBJECT, ObjectStorageTables.OBJECT, UploadExtractStep.class, + UploadTransform.class, UploadLoad.class, "This is a preview of the files that will be uploaded.", ObjectStoragePermissions.WRITE), "Upload files", help, file, requiredBucket, prefix, over), + EtlSupport.withInput(EtlSupport.etl(UPLOAD_TO_NAME, "Upload files to this folder", "upload_file", ObjectStorageTables.OBJECT, ObjectStorageTables.FOLDER, UploadExtractStep.class, + UploadTransform.class, UploadLoad.class, "This is a preview of the files that will be uploaded to the folder.", ObjectStoragePermissions.WRITE), + "Upload files to this folder", "The files go into the folder you started from (or the bucket and folder you fill in). " + help, file, bucket, prefix, over))); + } + + + + /******************************************************************************* + ** One record per uploaded file. + *******************************************************************************/ + public static class UploadExtractStep extends AbstractExtractStep + { + /******************************************************************************* + ** The uploaded files, as the file-upload field stored them. + *******************************************************************************/ + @SuppressWarnings("unchecked") + private static List files(RunBackendStepInput input) + { + Serializable value = input.getValue(FIELD_FILE); + return (value instanceof List list ? new ArrayList<>((List) list) : new ArrayList<>()); + } + + + + /******************************************************************************* + ** How many files. + *******************************************************************************/ + @Override + public Integer doCount(RunBackendStepInput runBackendStepInput) + { + return (files(runBackendStepInput).size()); + } + + + + /******************************************************************************* + ** Put one record per file in the pipe. + *******************************************************************************/ + @Override + public void run(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException + { + int index = 0; + for(Serializable file : files(runBackendStepInput)) + { + String reference = file instanceof StorageInput si && si.getReference() != null ? si.getReference() : String.valueOf(file); + String name = reference.replace('\\', '/'); + name = name.substring(name.lastIndexOf('/') + 1); + getRecordPipe().addRecord(new QRecord().withValue("id", ++index).withValue("name", name).withValue("_file", file)); + } + } + } + + + + /******************************************************************************* + ** Where the files go: the chosen bucket and folder, or the folder the action + ** was started from. + *******************************************************************************/ + record Destination(String alias, String prefix, String error) + { + } + + + + /******************************************************************************* + ** Work out the destination from the inputs and, from a folder record, that folder. + *******************************************************************************/ + static Destination destination(RunBackendStepInput input) throws QException + { + String alias = CopyMoveProcesses.aliasOfBucketId(input.getValue(FIELD_BUCKET)); + String prefix = input.getValueString(FIELD_PREFIX); + String folderIds = input.getValueString("recordIds"); + if(alias == null && StringUtils.hasContent(folderIds) && !folderIds.contains(",")) + { + QRecord folder = Db.first(ObjectStorageTables.FOLDER, "id", Integer.valueOf(folderIds.trim())); + if(folder != null) + { + alias = ActionSupport.aliasOf(folder); + prefix = StringUtils.hasContent(prefix) ? folder.getValueString("path") + prefix : folder.getValueString("path"); + } + } + if(alias == null) + { + return (new Destination(null, null, "Choose a bucket.")); + } + StorageBucketConfig config = ActionSupport.config(alias); + if(config == null || config.getRole() == BucketRole.BACKUP) + { + return (new Destination(alias, prefix, "Uploads are not offered for backup buckets.")); + } + String p = prefix == null ? "" : prefix.trim(); + if(!p.isEmpty() && !p.endsWith("/")) + { + p = p + "/"; + } + return (new Destination(alias, p, null)); + } + + + + /******************************************************************************* + ** Upload, review. + *******************************************************************************/ + public static class UploadTransform extends BaseTransform + { + private final ProcessSummaryLine will = EtlSupport.line(Status.OK, "file will be uploaded", "files will be uploaded", "file was uploaded", "files were uploaded"); + private final ProcessSummaryLine overwrites = EtlSupport.line(Status.WARNING, "file will replace an object that already exists", "files will replace objects that already exist", + "file replaced an object that already existed", "files replaced objects that already existed"); + private final ProcessSummaryLine exists = EtlSupport.line(Status.WARNING, "file is skipped: an object with that name exists (and overwrite is off)", "files are skipped: objects with those names exist (and overwrite is off)", + "file was skipped: the object already existed", "files were skipped: the objects already existed"); + private final ProcessSummaryLine invalid = EtlSupport.line(Status.ERROR, "file is skipped: its name makes an invalid key", "files are skipped: their names make invalid keys", + "file was skipped: invalid key", "files were skipped: invalid keys"); + private final ProcessSummaryLine protectedLine = EtlSupport.line(Status.WARNING, "file is skipped: the destination is protected", "files are skipped: the destination is protected", + "file was skipped: protected", "files were skipped: protected"); + private Destination destination; + private boolean described = false; + + + + /******************************************************************************* + ** Classify one file. + *******************************************************************************/ + @Override + protected void handle(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + if(destination == null) + { + destination = destination(input); + } + if(destination.error() != null) + { + return; + } + if(!ActionSupport.canSeeBucket(com.kingsrook.qbits.objectstorage.internal.ConfigSync.bucketId(destination.alias()), bucketAccess)) + { + destination = new Destination(destination.alias(), destination.prefix(), "You may not use that bucket."); + return; + } + Serializable id = record.getValue("id"); + String key; + try + { + key = StorageKeys.normalize(destination.prefix() + StorageKeys.safeFilename(record.getValueString("name"))); + } + catch(IllegalArgumentException e) + { + invalid.incrementCountAndAddPrimaryKey(id); + return; + } + if(ProtectionRules.check(destination.alias(), key, ProtectionRules.Op.WRITE, StorageCaller.user()).blocked()) + { + protectedLine.incrementCountAndAddPrimaryKey(id); + return; + } + boolean exists = ActionSupport.storage(destination.alias()).head(key).isPresent(); + if(exists) + { + if(!Boolean.TRUE.equals(input.getValueBoolean(FIELD_OVER))) + { + this.exists.incrementCountAndAddPrimaryKey(id); + return; + } + overwrites.incrementCountAndAddPrimaryKey(id); + } + will.incrementCountAndAddPrimaryKey(id); + record.setValue("_alias", destination.alias()); + record.setValue("_key", key); + output.getRecords().add(record); + described = true; + } + + + + /******************************************************************************* + ** Review lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + if(destination != null && destination.error() != null) + { + lines.add(EtlSupport.text(Status.ERROR, destination.error())); + return; + } + will.addSelfToListIfAnyCount(lines); + if(described && destination != null) + { + StorageBucketConfig config = ActionSupport.config(destination.alias()); + lines.add(EtlSupport.text(Status.INFO, "Destination: " + destination.alias() + "/" + destination.prefix() + ". Each file may be up to " + ActionSupport.bytes(config.getMaxUploadBytes()) + + ". The header policy sets the new objects' headers.")); + } + overwrites.addSelfToListIfAnyCount(lines); + exists.addSelfToListIfAnyCount(lines); + invalid.addSelfToListIfAnyCount(lines); + protectedLine.addSelfToListIfAnyCount(lines); + } + + + + /******************************************************************************* + ** The skips, as the Result screen shows them. + *******************************************************************************/ + @Override + protected List skipLines() + { + return (List.of(exists, invalid, protectedLine)); + } + } + + + + /******************************************************************************* + ** A stream that fails once it passes a byte limit. + *******************************************************************************/ + static class LimitedInputStream extends FilterInputStream + { + private final long limit; + private long read = 0; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + LimitedInputStream(InputStream in, long limit) + { + super(in); + this.limit = limit; + } + + + + /******************************************************************************* + ** Count one byte. + *******************************************************************************/ + @Override + public int read() throws IOException + { + int b = super.read(); + if(b >= 0) + { + count(1); + } + return (b); + } + + + + /******************************************************************************* + ** Count a block. + *******************************************************************************/ + @Override + public int read(byte[] buffer, int off, int len) throws IOException + { + int n = super.read(buffer, off, len); + if(n > 0) + { + count(n); + } + return (n); + } + + + + /******************************************************************************* + ** Fail past the limit. + *******************************************************************************/ + private void count(int n) throws IOException + { + read += n; + if(read > limit) + { + throw (new IOException("The file is larger than the upload limit of " + ActionSupport.bytes(limit))); + } + } + } + + + + /******************************************************************************* + ** Upload, execute: stream each file to the store. + *******************************************************************************/ + public static class UploadLoad extends BaseLoad + { + private final ProcessSummaryLine uploaded = EtlSupport.line(Status.OK, "file will be uploaded", "files will be uploaded", "file was uploaded", "files were uploaded"); + private long bytes = 0; + + + + /******************************************************************************* + ** Upload one file. + *******************************************************************************/ + @Override + protected void apply(QRecord record, RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + String alias = record.getValueString("_alias"); + String key = record.getValueString("_key"); + StorageBucketConfig config = ActionSupport.config(alias); + try(InputStream in = new LimitedInputStream(opener.apply(record.getValue("_file")), config.getMaxUploadBytes())) + { + long before = 0; + var result = ObjectStorage.bucket(alias).put(PutRequest.of(key, in)); + bytes += result.getBytes() == null ? before : result.getBytes(); + } + catch(IOException e) + { + throw (new StorageException(StorageException.Reason.PRECONDITION_FAILED, e.getMessage())); + } + uploaded.incrementCountAndAddPrimaryKey(record.getValue("id")); + output.getRecords().add(record); + } + + + + /******************************************************************************* + ** Result lines. + *******************************************************************************/ + @Override + protected void addLines(ArrayList lines) + { + uploaded.addSelfToListIfAnyCount(lines); + if(bytes > 0) + { + lines.add(EtlSupport.text(Status.INFO, ActionSupport.bytes(bytes) + " uploaded")); + } + if(getTransformStep() instanceof UploadTransform transform) + { + transform.overwrites.addSelfToListIfAnyCount(lines); + } + } + } + + + + /******************************************************************************* + ** The configured upload limit (documentation helper for tests). + *******************************************************************************/ + static long uploadLimit(String alias) + { + return (ObjectStorageRuntime.bucketConfig(alias).getMaxUploadBytes()); + } +} diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/ObjectStorageQBitProducerTest.java b/src/test/java/com/kingsrook/qbits/objectstorage/ObjectStorageQBitProducerTest.java index de03f2e..efbb394 100644 --- a/src/test/java/com/kingsrook/qbits/objectstorage/ObjectStorageQBitProducerTest.java +++ b/src/test/java/com/kingsrook/qbits/objectstorage/ObjectStorageQBitProducerTest.java @@ -1,11 +1,15 @@ /* - * Copyright 2026 Kingsrook, LLC + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/api/StorageKeysTest.java b/src/test/java/com/kingsrook/qbits/objectstorage/api/StorageKeysTest.java new file mode 100644 index 0000000..4b6987d --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/objectstorage/api/StorageKeysTest.java @@ -0,0 +1,100 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.api; + + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Unit test for StorageKeys. + *******************************************************************************/ +class StorageKeysTest +{ + /******************************************************************************* + ** Normalization collapses slashes, strips a leading slash, and rejects the unsafe. + *******************************************************************************/ + @Test + void testNormalize() + { + assertThat(StorageKeys.normalize("//a///b/c.txt")).isEqualTo("a/b/c.txt"); + assertThat(StorageKeys.normalize("name.")).isEqualTo("name."); + assertThatThrownBy(() -> StorageKeys.normalize("a/../b")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> StorageKeys.normalize("a\nb")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> StorageKeys.normalize("")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> StorageKeys.normalize(null)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> StorageKeys.normalize("///")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> StorageKeys.normalize("x".repeat(1025))).isInstanceOf(IllegalArgumentException.class); + assertThat(StorageKeys.normalize("x".repeat(1024))).hasSize(1024); + assertThat(StorageKeys.normalize("\u00e9".repeat(512))).hasSize(512); + assertThatThrownBy(() -> StorageKeys.normalize("\u00e9".repeat(513))).isInstanceOf(IllegalArgumentException.class); + } + + + + /******************************************************************************* + ** Prefixes keep a trailing slash and may be empty. + *******************************************************************************/ + @Test + void testNormalizePrefix() + { + assertThat(StorageKeys.normalizePrefix(null)).isEmpty(); + assertThat(StorageKeys.normalizePrefix("/")).isEmpty(); + assertThat(StorageKeys.normalizePrefix("//media//42/")).isEqualTo("media/42/"); + assertThat(StorageKeys.normalizePrefix("media")).isEqualTo("media"); + } + + + + /******************************************************************************* + ** Safe file names and the content-addressed layout. + *******************************************************************************/ + @Test + void testFilenamesAndContentAddressing() + { + assertThat(StorageKeys.safeFilename("../../etc/passwd")).isEqualTo("passwd"); + assertThat(StorageKeys.safeFilename("C:\\temp\\photo.jpg")).isEqualTo("photo.jpg"); + assertThat(StorageKeys.safeFilename("...")).isEqualTo("file"); + assertThat(StorageKeys.safeFilename(null)).isEqualTo("file"); + assertThat(StorageKeys.safeFilename(".hidden")).isEqualTo("hidden"); + + String key = StorageKeys.contentAddressed("media", "hero", "abc".getBytes(), "../Photo One.jpg"); + assertThat(key).isEqualTo("media/hero/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad/Photo One.jpg"); + } + + + + /******************************************************************************* + ** URL encoding handles the characters behind "works on MinIO, 403 on AWS". + *******************************************************************************/ + @Test + void testUrlEncoding() + { + String key = "a b/c+d/100%/caf\u00e9.txt"; + String encoded = StorageKeys.encodeForUrl(key); + assertThat(encoded).isEqualTo("a%20b/c%2Bd/100%25/caf%C3%A9.txt"); + assertThat(StorageKeys.decodeFromUrl(encoded)).isEqualTo(key); + assertThat(StorageKeys.decodeFromUrl("plain/path.txt")).isEqualTo("plain/path.txt"); + assertThat(StorageKeys.decodeFromUrl("trailing%")).isEqualTo("trailing%"); + } +} diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/conformance/ConformanceTarget.java b/src/test/java/com/kingsrook/qbits/objectstorage/conformance/ConformanceTarget.java new file mode 100644 index 0000000..53fe72c --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/objectstorage/conformance/ConformanceTarget.java @@ -0,0 +1,81 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.conformance; + + +import java.net.URI; +import java.util.EnumMap; +import java.util.Map; +import com.kingsrook.qbits.objectstorage.api.Capability; +import com.kingsrook.qbits.objectstorage.api.CapabilityStatus; +import com.kingsrook.qbits.objectstorage.config.ProviderType; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.checksums.RequestChecksumCalculation; +import software.amazon.awssdk.core.checksums.ResponseChecksumValidation; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + + +/******************************************************************************* + ** One S3-compatible store the conformance suite runs against: its connection + ** settings, the scratch buckets, and the capability set the probe is EXPECTED + ** to report. The suite asserts the probe equals this set. + *******************************************************************************/ +public class ConformanceTarget +{ + public String name; + public ProviderType providerType; + public String endpoint; + public String accessKey; + public String secretKey; + public String region; + + /** The scratch bucket for most tests. */ + public String bucket; + /** A versioned bucket, or null when the provider has no versioning. */ + public String versionedBucket; + /** A private bucket used as the soft-delete trash, when there is no versioning. */ + public String trashBucket; + /** A bucket used by the index tests (nothing else writes to it). */ + public String indexBucket; + /** A bucket with object lock enabled, or null. */ + public String lockBucket; + + /** What the probe must report for the scratch bucket. */ + public final Map expected = new EnumMap<>(Capability.class); + + /** Whether WHEN_SUPPORTED (SDK default checksums) is expected to work. */ + public boolean checksumsSupportedWorks = false; + + + + /******************************************************************************* + ** A raw admin client (always WHEN_REQUIRED), for creating buckets and + ** inspecting results independently of the code under test. + *******************************************************************************/ + public S3Client rawClient() + { + return (S3Client.builder().endpointOverride(URI.create(endpoint)).region(Region.of(region)).forcePathStyle(true) + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))) + .requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED).responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED).build()); + } +} diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/conformance/GarageConformanceTest.java b/src/test/java/com/kingsrook/qbits/objectstorage/conformance/GarageConformanceTest.java new file mode 100644 index 0000000..74d1432 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/objectstorage/conformance/GarageConformanceTest.java @@ -0,0 +1,38 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.conformance; + + +/******************************************************************************* + ** The conformance suite against Garage (digest-pinned): the most different + ** self-hosted store, with no versioning, tagging or bucket policy. + *******************************************************************************/ +class GarageConformanceTest extends ObjectStorageConformanceSuite +{ + /******************************************************************************* + ** Garage. + *******************************************************************************/ + @Override + protected ConformanceTarget createTarget() + { + return (Targets.garage()); + } +} diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/conformance/MinioConformanceTest.java b/src/test/java/com/kingsrook/qbits/objectstorage/conformance/MinioConformanceTest.java new file mode 100644 index 0000000..59e7014 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/objectstorage/conformance/MinioConformanceTest.java @@ -0,0 +1,37 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.conformance; + + +/******************************************************************************* + ** The conformance suite against MinIO (digest-pinned Chainguard build). + *******************************************************************************/ +class MinioConformanceTest extends ObjectStorageConformanceSuite +{ + /******************************************************************************* + ** MinIO. + *******************************************************************************/ + @Override + protected ConformanceTarget createTarget() + { + return (Targets.minio()); + } +} diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/conformance/MinioServiceTest.java b/src/test/java/com/kingsrook/qbits/objectstorage/conformance/MinioServiceTest.java new file mode 100644 index 0000000..e6e9903 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/objectstorage/conformance/MinioServiceTest.java @@ -0,0 +1,411 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.conformance; + + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import com.kingsrook.qbits.objectstorage.api.Capability; +import com.kingsrook.qbits.objectstorage.api.CapabilityStatus; +import com.kingsrook.qbits.objectstorage.api.ChangeOperation; +import com.kingsrook.qbits.objectstorage.api.CopyRequest; +import com.kingsrook.qbits.objectstorage.api.DeleteMode; +import com.kingsrook.qbits.objectstorage.api.DeleteRequest; +import com.kingsrook.qbits.objectstorage.api.ListRequest; +import com.kingsrook.qbits.objectstorage.api.LockMode; +import com.kingsrook.qbits.objectstorage.api.ObjectHeaders; +import com.kingsrook.qbits.objectstorage.api.ObjectStorage; +import com.kingsrook.qbits.objectstorage.api.PresignGet; +import com.kingsrook.qbits.objectstorage.api.PutRequest; +import com.kingsrook.qbits.objectstorage.api.StorageChange; +import com.kingsrook.qbits.objectstorage.api.StorageException; +import com.kingsrook.qbits.objectstorage.api.StoredObject; +import com.kingsrook.qbits.objectstorage.internal.CapabilityProbe; +import com.kingsrook.qbits.objectstorage.internal.ConfigSync; +import com.kingsrook.qbits.objectstorage.internal.DefaultObjectHeaderPolicy; +import com.kingsrook.qbits.objectstorage.internal.Db; +import com.kingsrook.qbits.objectstorage.internal.IndexHook; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qbits.objectstorage.processes.TestConnectionProcess; +import com.kingsrook.qbits.objectstorage.testsupport.ProcessRunner; +import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Service behaviour that is specific to this qbit (not provider conformance), + ** run against MinIO: the header policy on put, listeners, the index hook, + ** protection and system callers (SF1-SF5), object lock, and the Test + ** connection actions. + *******************************************************************************/ +class MinioServiceTest +{ + private ConformanceTarget target; + private String prefix; + + + + /******************************************************************************* + ** Install the qbit against MinIO, with a signed-in user. + *******************************************************************************/ + @BeforeEach + void setUp() throws Exception + { + Assumptions.assumeTrue(Targets.dockerAvailable(), "Docker is not available"); + target = Targets.minio(); + prefix = "svc/" + UUID.randomUUID() + "/"; + StorageTestSupport.setUp(target, c -> c.setMaxPresignsPerUserPerHour(3)); + QContext.setQSession(new QSession().withUser(new QUser().withIdReference("alice@example.com"))); + } + + + + /******************************************************************************* + ** Tear down. + *******************************************************************************/ + @AfterEach + void tearDown() + { + StorageTestSupport.tearDown(); + } + + + + @Test + void putAppliesTheHeaderPolicyUnlessHeadersAreGiven() throws Exception + { + StorageTestSupport.insert(ObjectStorageTables.HEADER_RULE, + new QRecord().withValue("name", "images").withValue("contentTypePattern", "image/*").withValue("cacheControl", "public, max-age=600").withValue("priority", 10).withValue("enabled", true)); + DefaultObjectHeaderPolicy.clearCache(); + + ObjectStorage.bucket("files").put(PutRequest.of(prefix + "a.png", new byte[] { 1, 2 })); + assertThat(ObjectStorage.bucket("files").head(prefix + "a.png").orElseThrow().getHeaders().getCacheControl()).isEqualTo("public, max-age=600"); + assertThat(ObjectStorage.bucket("files").head(prefix + "a.png").orElseThrow().getHeaders().getContentType()).isEqualTo("image/png"); + + ObjectStorage.bucket("files").put(PutRequest.of(prefix + "b.png", new byte[] { 1 }).withCacheControl("no-store")); + assertThat(ObjectStorage.bucket("files").head(prefix + "b.png").orElseThrow().getHeaders().getCacheControl()).isEqualTo("no-store"); + + // SF1: a BACKUP bucket never gets policy headers + ObjectStorage.asSystem("backups", "r1").bucket("locked").put(PutRequest.of(prefix + "c.png", new byte[] { 1 })); + assertThat(ObjectStorage.bucket("locked").head(prefix + "c.png").orElseThrow().getHeaders().getCacheControl()).isNull(); + } + + + + @Test + void changeListenersHearEveryWriteAndNeverFailIt() throws Exception + { + List heard = new ArrayList<>(); + ObjectStorage.addChangeListener(heard::add); + ObjectStorage.addChangeListener(change -> + { + throw new IllegalStateException("purge planner is down"); + }); + + String key = prefix + "listened.txt"; + ObjectStorage.bucket("files").put(PutRequest.of(key, "x".getBytes(StandardCharsets.UTF_8))); + ObjectStorage.bucket("files").copy(CopyRequest.of(key, key + ".copy")); + ObjectStorage.bucket("files").updateHeaders(key, ObjectHeaders.cacheControl("no-cache")); + ObjectStorage.bucket("files").delete(DeleteRequest.of(key)); + ObjectStorage.bucket("soft").put(PutRequest.of(key + ".soft", "y".getBytes(StandardCharsets.UTF_8))); + ObjectStorage.bucket("soft").delete(DeleteRequest.of(key + ".soft")); + ObjectStorage.bucket("soft").restore(key + ".soft"); + + assertThat(heard).extracting(StorageChange::getOperation).containsExactly(ChangeOperation.PUT, ChangeOperation.COPY, ChangeOperation.HEADERS, ChangeOperation.DELETE, + ChangeOperation.PUT, ChangeOperation.DELETE, ChangeOperation.RESTORE); + assertThat(heard.get(1).getKey()).isEqualTo(key); + assertThat(heard.get(1).getDestinationKey()).isEqualTo(key + ".copy"); + assertThat(heard.get(0).getActor()).isEqualTo("alice@example.com"); + + List rows = Db.query(ObjectStorageTables.OPERATION, "objectKey", key, "action", "PUT"); + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getValueString("detail")).contains("listener errors").contains("purge planner is down"); + assertThat(rows.get(0).getValueString("result")).isEqualTo("OK"); + } + + + + @Test + void theIndexHookIsToldAboutWritesAndDeletes() throws Exception + { + List events = new ArrayList<>(); + ObjectStorageRuntime.setIndexHook(new IndexHook() + { + /******************************************************************************* + ** Record. + *******************************************************************************/ + @Override + public void objectWritten(StorageBucketConfig bucket, StoredObject object) + { + events.add("written " + bucket.getAlias() + " " + object.getKey() + " " + object.getSize()); + } + + + + /******************************************************************************* + ** Record. + *******************************************************************************/ + @Override + public void objectRemoved(StorageBucketConfig bucket, String key, boolean soft) + { + events.add("removed " + bucket.getAlias() + " " + key + " " + soft); + } + }); + ObjectStorage.bucket("files").put(PutRequest.of(prefix + "i.txt", new byte[3])); + ObjectStorage.bucket("files").copy(CopyRequest.of(prefix + "i.txt", prefix + "j.txt")); + ObjectStorage.bucket("files").delete(DeleteRequest.of(prefix + "i.txt")); + assertThat(events).containsExactly("written files " + prefix + "i.txt 3", "written files " + prefix + "j.txt 3", "removed files " + prefix + "i.txt false"); + + ObjectStorageRuntime.setIndexHook(new IndexHook() + { + /******************************************************************************* + ** Fails. + *******************************************************************************/ + @Override + public void objectWritten(StorageBucketConfig bucket, StoredObject object) + { + throw new IllegalStateException("index is down"); + } + + + + /******************************************************************************* + ** Fails. + *******************************************************************************/ + @Override + public void objectRemoved(StorageBucketConfig bucket, String key, boolean soft) + { + throw new IllegalStateException("index is down"); + } + }); + ObjectStorage.bucket("files").put(PutRequest.of(prefix + "k.txt", new byte[1])); + assertThat(ObjectStorage.bucket("files").head(prefix + "k.txt")).as("an index failure never fails the write").isPresent(); + ObjectStorage.bucket("files").delete(DeleteRequest.of(prefix + "k.txt")); + } + + + + @Test + void systemCallersAreLoggedWithTheirRunAndTheUserWhoStartedIt() throws Exception + { + ObjectStorage.asSystem("backups", "run 1234").bucket("files").put(PutRequest.of(prefix + "sys.txt", new byte[1])); + List rows = Db.query(ObjectStorageTables.OPERATION, "objectKey", prefix + "sys.txt"); + assertThat(rows.get(0).getValueString("actor")).isEqualTo("system: backups run run 1234 (started by user alice@example.com)"); + assertThat(rows.get(0).getValueString("processRunId")).isEqualTo("run 1234"); + assertThat(rows.get(0).getValueString("bucketAlias")).isEqualTo("files"); + assertThat(rows.get(0).getValueInteger("bucketId")).isEqualTo(ConfigSync.bucketId("files")); + } + + + + @Test + void failedWritesAreLoggedAsFailedWithTheProviderRequestId() throws Exception + { + assertThatThrownBy(() -> ObjectStorage.bucket("files").put(PutRequest.of(prefix + "x", new byte[1]).withIfNoneMatch(true).withObjectLock(LockMode.GOVERNANCE, Instant.now().plusSeconds(60)))) + .isInstanceOf(StorageException.class); + List rows = Db.query(ObjectStorageTables.OPERATION, "objectKey", prefix + "x"); + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getValueString("result")).isEqualTo("FAILED"); + assertThat(rows.get(0).getValueString("errorMessage")).isNotBlank(); + } + + + + @Test + void protectionRulesBlockWritesAndDeletesButAllowTheNamedSystemCaller() throws Exception + { + Integer filesId = ConfigSync.bucketId("files"); + StorageTestSupport.insert(ObjectStorageTables.PROTECTION_RULE, + new QRecord().withValue("bucketId", filesId).withValue("prefixGlob", prefix + "ro/*").withValue("mode", "READ_ONLY").withValue("reason", "legal hold").withValue("enabled", true), + new QRecord().withValue("bucketId", filesId).withValue("prefixGlob", prefix + "nd/*").withValue("mode", "NO_DELETE").withValue("allowedSystemCallers", "backups").withValue("enabled", true)); + assertThatThrownBy(() -> ObjectStorage.bucket("files").put(PutRequest.of(prefix + "ro/a", new byte[1]))).isInstanceOfSatisfying(StorageException.class, + e -> assertThat(e.getReason()).isEqualTo(StorageException.Reason.ACCESS_DENIED)); + assertThat(ObjectStorage.bucket("files").head(prefix + "ro/a")).isEmpty(); + + ObjectStorage.bucket("files").put(PutRequest.of(prefix + "nd/a", new byte[1])); + assertThatThrownBy(() -> ObjectStorage.bucket("files").delete(DeleteRequest.of(prefix + "nd/a"))).hasMessageContaining("Protected by a rule"); + assertThatThrownBy(() -> ObjectStorage.asSystem("other", null).bucket("files").delete(DeleteRequest.of(prefix + "nd/a").permanent())).isInstanceOf(StorageException.class); + assertThat(ObjectStorage.asSystem("backups", "r").bucket("files").delete(DeleteRequest.of(prefix + "nd/a").permanent()).getMode()).isEqualTo(DeleteMode.PERMANENT); + assertThat(ObjectStorage.bucket("files").head(prefix + "nd/a")).isEmpty(); + } + + + + @Test + void objectLockOnABackupBucketIsOfferedOnlyWhereSupportedAndPermanentDeleteSkipsLockedObjects() throws Exception + { + String key = prefix + "locked.bin"; + ObjectStorage.asSystem("backups", "r").bucket("locked").put(PutRequest.of(key, new byte[] { 1, 2, 3 }).withObjectLock(LockMode.COMPLIANCE, Instant.now().plus(Duration.ofHours(1))) + .withStorageClass("STANDARD")); + StoredObject head = ObjectStorage.bucket("locked").head(key).orElseThrow(); + assertThat(head.getLockMode()).isEqualTo("COMPLIANCE"); + assertThat(head.getLockRetainUntil()).isNotNull(); + + var result = ObjectStorage.asSystem("backups", "r").bucket("locked").delete(DeleteRequest.of(key).permanent()); + assertThat(result.getMode()).isEqualTo(DeleteMode.SKIPPED_OBJECT_LOCK); + assertThat(ObjectStorage.bucket("locked").head(key)).as("the locked object is still there").isPresent(); + assertThat(Db.query(ObjectStorageTables.OPERATION, "objectKey", key, "action", "DELETE").get(0).getValueString("result")).isEqualTo("SKIPPED"); + + // a bucket without object lock: never silently dropped, either rejected by the store or by the capability + assertThatThrownBy(() -> ObjectStorage.bucket("files").put(PutRequest.of(prefix + "nolock", new byte[1]).withObjectLock(LockMode.GOVERNANCE, Instant.now().plusSeconds(60)))) + .isInstanceOf(StorageException.class); + CapabilityProbe.probeConnection("primary", false); + assertThat(ObjectStorage.bucket("files").capability(Capability.OBJECT_LOCK)).isEqualTo(CapabilityStatus.UNSUPPORTED); + assertThatThrownBy(() -> ObjectStorage.bucket("files").put(PutRequest.of(prefix + "nolock", new byte[1]).withObjectLock(LockMode.GOVERNANCE, Instant.now().plusSeconds(60)))) + .isInstanceOfSatisfying(StorageException.class, e -> assertThat(e.getReason()).isEqualTo(StorageException.Reason.UNSUPPORTED)); + } + + + + @Test + void writeProbesSkipABackupBucketWithObjectLock() throws Exception + { + CapabilityProbe.Report report = CapabilityProbe.probeConnection("primary", true); + assertThat(report.getNotes().get("locked")).containsExactly("write probes skipped: object lock"); + assertThat(report.getByBucket().get("locked")).doesNotContainKey(Capability.PUT_GET_DELETE); + assertThat(report.getByBucket().get("locked").get(Capability.OBJECT_LOCK)).isEqualTo(CapabilityStatus.SUPPORTED); + assertThat(ObjectStorage.asSystem("x", null).bucket("locked").list(ListRequest.prefix(CapabilityProbe.PROBE_PREFIX)).getItems()).isEmpty(); + assertThat(report.getClockSkewSeconds()).isNotNull().isLessThan(60); + assertThat(report.getPresignHostReachable()).isTrue(); + assertThat(report.summaryFor("files")).contains("supported"); + assertThat(report.summaryFor("nothing")).isEqualTo("nothing probed"); + } + + + + @Test + void presignsAreCappedPerUserPerHourAndLoggedWithoutTheirSignature() throws Exception + { + for(int i = 0; i < 3; i++) + { + ObjectStorage.bucket("files").presignGet(PresignGet.of(prefix + "p")); + } + assertThatThrownBy(() -> ObjectStorage.bucket("files").presignGet(PresignGet.of(prefix + "p"))).isInstanceOfSatisfying(StorageException.class, + e -> assertThat(e.getReason()).isEqualTo(StorageException.Reason.THROTTLED)); + assertThat(Db.query(ObjectStorageTables.OPERATION, "action", "PRESIGN_GET")).hasSize(3); + ObjectStorage.asSystem("backups", "r").bucket("files").presignGet(PresignGet.of(prefix + "p")); + } + + + + @Test + void testConnectionActionsReviewThenRecordTheResult() throws Exception + { + QRecord connection = Db.first(ObjectStorageTables.CONNECTION, "name", "primary"); + String id = String.valueOf(connection.getValue("id")); + + List review = ProcessRunner.review(TestConnectionProcess.READ_NAME, id); + assertThat(review).anyMatch(l -> l.contains("connection will be tested")).anyMatch(l -> l.contains("buckets will get read-only probes")); + assertThat(Db.first(ObjectStorageTables.CONNECTION, "name", "primary").getValue("lastTestedAt")).as("the review changes nothing").isNull(); + + ProcessRunner.Run run = ProcessRunner.run(TestConnectionProcess.READ_NAME, id, null); + assertThat(run.result()).anyMatch(l -> l.startsWith("files: ") && l.contains("supported")).anyMatch(l -> l.contains("Clock skew")); + QRecord after = Db.first(ObjectStorageTables.CONNECTION, "name", "primary"); + assertThat(after.getValueInstant("lastTestedAt")).isNotNull(); + assertThat(after.getValueString("lastTestStatus")).isEqualTo("OK"); + assertThat(after.getValueString("lastTestSummary")).contains("files:"); + assertThat(Db.query(ObjectStorageTables.CONNECTION_CAPABILITY, "connectionId", connection.getValue("id"), "capability", "LIST_V2").get(0).getValueString("status")).isEqualTo("SUPPORTED"); + assertThat(Db.query(ObjectStorageTables.BUCKET_CAPABILITY, "bucketId", ConfigSync.bucketId("files"), "capability", "PUT_GET_DELETE")).as("read-only probes make no write test").isEmpty(); + + List writeReview = ProcessRunner.review(TestConnectionProcess.WRITE_NAME, id); + assertThat(writeReview).anyMatch(l -> l.contains("will get write tests")).anyMatch(l -> l.contains("will not get write tests")); + ProcessRunner.run(TestConnectionProcess.WRITE_NAME, id, null); + assertThat(Db.query(ObjectStorageTables.BUCKET_CAPABILITY, "bucketId", ConfigSync.bucketId("files"), "capability", "PUT_GET_DELETE").get(0).getValueString("status")).isEqualTo("SUPPORTED"); + assertThat(Db.query(ObjectStorageTables.CONNECTION_CAPABILITY, "connectionId", connection.getValue("id"), "capability", "PRESIGNED_POST").get(0).getValueString("status")).isEqualTo("SUPPORTED"); + } + + + + @Test + void testConnectionRaisesAndResolvesTheConnectionAlert() throws Exception + { + List events = new ArrayList<>(); + ObjectStorageRuntime.setAlertSink(new com.kingsrook.qbits.objectstorage.api.StorageAlertSink() + { + /******************************************************************************* + ** Record. + *******************************************************************************/ + @Override + public void raise(String alertType, String dedupeKey, String severity, String message, Map details) + { + events.add("raise " + alertType + " " + dedupeKey + " " + severity); + } + + + + /******************************************************************************* + ** Record. + *******************************************************************************/ + @Override + public void resolve(String alertType, String dedupeKey) + { + events.add("resolve " + alertType + " " + dedupeKey); + } + }); + String id = String.valueOf(Db.first(ObjectStorageTables.CONNECTION, "name", "primary").getValue("id")); + ProcessRunner.run(TestConnectionProcess.READ_NAME, id, null); + assertThat(events).containsExactly("resolve storage.connection.failed connection:primary"); + + StorageTestSupport.setUp(target, c -> c.findConnection("primary").setEndpoint("http://127.0.0.1:1")); + ObjectStorageRuntime.setAlertSink(new com.kingsrook.qbits.objectstorage.api.StorageAlertSink() + { + /******************************************************************************* + ** Record. + *******************************************************************************/ + @Override + public void raise(String alertType, String dedupeKey, String severity, String message, Map details) + { + events.add("raise " + alertType + " " + dedupeKey + " " + severity); + } + + + + /******************************************************************************* + ** Record. + *******************************************************************************/ + @Override + public void resolve(String alertType, String dedupeKey) + { + events.add("resolve " + alertType + " " + dedupeKey); + } + }); + String id2 = String.valueOf(Db.first(ObjectStorageTables.CONNECTION, "name", "primary").getValue("id")); + ProcessRunner.run(TestConnectionProcess.READ_NAME, id2, null); + assertThat(events).contains("raise storage.connection.failed connection:primary error"); + assertThat(Db.first(ObjectStorageTables.CONNECTION, "name", "primary").getValueString("lastTestStatus")).isEqualTo("PROBLEMS"); + } +} diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/conformance/ObjectStorageConformanceSuite.java b/src/test/java/com/kingsrook/qbits/objectstorage/conformance/ObjectStorageConformanceSuite.java new file mode 100644 index 0000000..cc87818 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/objectstorage/conformance/ObjectStorageConformanceSuite.java @@ -0,0 +1,610 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.conformance; + + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; +import com.kingsrook.qbits.objectstorage.api.Capability; +import com.kingsrook.qbits.objectstorage.api.CapabilityStatus; +import com.kingsrook.qbits.objectstorage.api.CopyRequest; +import com.kingsrook.qbits.objectstorage.api.DeleteMode; +import com.kingsrook.qbits.objectstorage.api.DeleteRequest; +import com.kingsrook.qbits.objectstorage.api.ListRequest; +import com.kingsrook.qbits.objectstorage.api.MetadataDirective; +import com.kingsrook.qbits.objectstorage.api.ObjectHeaders; +import com.kingsrook.qbits.objectstorage.api.ObjectStorage; +import com.kingsrook.qbits.objectstorage.api.ObjectStream; +import com.kingsrook.qbits.objectstorage.api.Page; +import com.kingsrook.qbits.objectstorage.api.ObjectSummary; +import com.kingsrook.qbits.objectstorage.api.PresignGet; +import com.kingsrook.qbits.objectstorage.api.PresignPost; +import com.kingsrook.qbits.objectstorage.api.PresignPut; +import com.kingsrook.qbits.objectstorage.api.PresignedPost; +import com.kingsrook.qbits.objectstorage.api.PutRequest; +import com.kingsrook.qbits.objectstorage.api.PutResult; +import com.kingsrook.qbits.objectstorage.api.Range; +import com.kingsrook.qbits.objectstorage.api.StorageBucket; +import com.kingsrook.qbits.objectstorage.api.StorageException; +import com.kingsrook.qbits.objectstorage.api.StoredObject; +import com.kingsrook.qbits.objectstorage.config.ChecksumMode; +import com.kingsrook.qbits.objectstorage.internal.CapabilityProbe; +import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.core.sync.RequestBody; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** The conformance suite: runs the service API end to end against one + ** S3-compatible store (a ConformanceTarget). Subclasses supply the target + ** (MinIO and Garage in CI on every PR). Every test uses its own key prefix, so + ** tests share the scratch buckets safely. + *******************************************************************************/ +public abstract class ObjectStorageConformanceSuite +{ + protected ConformanceTarget target; + protected String prefix; + + private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + + + + /******************************************************************************* + ** The store under test. + *******************************************************************************/ + protected abstract ConformanceTarget createTarget(); + + + + /******************************************************************************* + ** Start (or reuse) the store and install the qbit against it. + *******************************************************************************/ + @BeforeEach + void setUpStore() throws Exception + { + if(!Targets.dockerAvailable()) + { + if(Targets.dockerRequired()) + { + org.junit.jupiter.api.Assertions.fail("Docker is required for the conformance suite in CI"); + } + Assumptions.assumeTrue(false, "Docker is not available"); + } + target = createTarget(); + prefix = "t/" + UUID.randomUUID() + "/"; + StorageTestSupport.setUp(target, null); + } + + + + /******************************************************************************* + ** Tear down. + *******************************************************************************/ + @AfterEach + void tearDownStore() + { + StorageTestSupport.tearDown(); + } + + + + /******************************************************************************* + ** The default bucket. + *******************************************************************************/ + protected StorageBucket files() + { + return (ObjectStorage.bucket("files")); + } + + + + /******************************************************************************* + ** SHA-256 hex of bytes. + *******************************************************************************/ + private static String sha(byte[] bytes) throws Exception + { + return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes))); + } + + + + /******************************************************************************* + ** Deterministic pseudo-random content. + *******************************************************************************/ + private static byte[] content(int size) + { + byte[] b = new byte[size]; + new Random(size).nextBytes(b); + return (b); + } + + + + @Test + void putHeadGetDeleteAcrossSizes() throws Exception + { + for(int size : new int[] { 0, 1234, 5 * 1024 * 1024 }) + { + String key = prefix + "size-" + size + ".bin"; + byte[] bytes = content(size); + PutResult put = files().put(PutRequest.of(key, bytes).withContentType("application/octet-stream")); + assertThat(put.getBytes()).isEqualTo(size); + + Optional head = files().head(key); + assertThat(head).isPresent(); + assertThat(head.get().getSize()).isEqualTo(size); + assertThat(head.get().getHeaders().getContentType()).isEqualTo("application/octet-stream"); + + try(ObjectStream in = files().get(key, Range.all())) + { + assertThat(sha(in.readAllBytes())).isEqualTo(sha(bytes)); + } + if(size > 100) + { + try(ObjectStream in = files().get(key, Range.bytes(10, 19))) + { + byte[] part = in.readAllBytes(); + assertThat(part).hasSize(10).isEqualTo(java.util.Arrays.copyOfRange(bytes, 10, 20)); + } + } + files().delete(DeleteRequest.of(key)); + assertThat(files().head(key)).isEmpty(); + } + } + + + + @Test + void streamedUploadAboveThePartSizeIsMultipart() throws Exception + { + byte[] bytes = content(20 * 1024 * 1024); + String key = prefix + "big-20mib.bin"; + PutResult result = files().put(PutRequest.of(key, (InputStream) new ByteArrayInputStream(bytes)).withContentType("application/octet-stream")); + assertThat(result.getMultipart()).isTrue(); + assertThat(result.getBytes()).isEqualTo(bytes.length); + try(ObjectStream in = files().get(key, Range.all())) + { + assertThat(sha(in.readAllBytes())).isEqualTo(sha(bytes)); + } + } + + + + @Test + void awkwardKeysRoundTrip() throws Exception + { + List keys = List.of(prefix + "with space/a b.txt", prefix + "plus+sign/x+y.txt", prefix + "percent%41/p%25.txt", prefix + "unicode/café-日本.txt", + prefix + "trailing.dot.", prefix + "slashes//collapsed.txt"); + for(String key : keys) + { + PutResult result = files().put(PutRequest.of(key, "x".getBytes(StandardCharsets.UTF_8))); + String normalized = result.getKey(); + assertThat(files().head(normalized)).as(normalized).isPresent(); + try(ObjectStream in = files().get(normalized, Range.all())) + { + assertThat(new String(in.readAllBytes(), StandardCharsets.UTF_8)).isEqualTo("x"); + } + } + Page page = files().list(ListRequest.prefix(prefix)); + assertThat(page.getItems()).extracting(ObjectSummary::getKey).contains(prefix + "slashes/collapsed.txt", prefix + "with space/a b.txt", prefix + "unicode/café-日本.txt"); + } + + + + @Test + void listPagesOverTwoThousandFiveHundredKeys() throws Exception + { + try(S3Client raw = target.rawClient()) + { + IntStream.range(0, 2500).parallel().forEach(i -> raw.putObject(PutObjectRequest.builder().bucket(target.bucket).key(prefix + "many/" + String.format("%05d", i)).build(), RequestBody.empty())); + } + int pages = 0; + int total = 0; + String token = null; + String last = ""; + do + { + Page page = files().list(ListRequest.prefix(prefix + "many/").pageSize(1000).withAfter(token)); + pages++; + total += page.getItems().size(); + for(ObjectSummary o : page.getItems()) + { + assertThat(o.getKey()).isGreaterThan(last); + last = o.getKey(); + } + token = page.getNextToken(); + } + while(token != null); + assertThat(total).isEqualTo(2500); + assertThat(pages).isEqualTo(3); + } + + + + @Test + void delimiterListingAndStartAfter() throws Exception + { + for(String name : new String[] { "a/1.txt", "a/2.txt", "b/1.txt", "top.txt" }) + { + files().put(PutRequest.of(prefix + "tree/" + name, "x".getBytes(StandardCharsets.UTF_8))); + } + Page page = files().list(ListRequest.prefix(prefix + "tree/").withDelimiter("/")); + assertThat(page.getPrefixes()).containsExactly(prefix + "tree/a/", prefix + "tree/b/"); + assertThat(page.getItems()).extracting(ObjectSummary::getKey).containsExactly(prefix + "tree/top.txt"); + + Page after = files().list(ListRequest.prefix(prefix + "tree/").withStartAfter(prefix + "tree/a/2.txt")); + assertThat(after.getItems()).extracting(ObjectSummary::getKey).containsExactly(prefix + "tree/b/1.txt", prefix + "tree/top.txt"); + } + + + + @Test + void copyAndCopyInPlaceWithReplaceMetadata() throws Exception + { + String src = prefix + "copy-src.txt"; + files().put(PutRequest.of(src, "hello".getBytes(StandardCharsets.UTF_8)).withContentType("text/plain").withCacheControl("max-age=10").withMetadata(Map.of("alt", "one"))); + + String copy = prefix + "copy-dst.txt"; + files().copy(CopyRequest.of(src, copy)); + StoredObject copied = files().head(copy).orElseThrow(); + assertThat(copied.getHeaders().getCacheControl()).isEqualTo("max-age=10"); + assertThat(copied.getHeaders().getUserMetadata()).containsEntry("alt", "one"); + + files().copy(CopyRequest.of(src, src).withMetadata(MetadataDirective.REPLACE).withHeaders(new ObjectHeaders().withCacheControl("public, max-age=31536000, immutable").withContentType("text/plain"))); + StoredObject replaced = files().head(src).orElseThrow(); + assertThat(replaced.getHeaders().getCacheControl()).isEqualTo("public, max-age=31536000, immutable"); + try(ObjectStream in = files().get(src, Range.all())) + { + assertThat(new String(in.readAllBytes(), StandardCharsets.UTF_8)).isEqualTo("hello"); + } + } + + + + @Test + void updateHeadersKeepsTheRestAndRoundTripsCacheControl() throws Exception + { + String key = prefix + "headers.json"; + files().put(PutRequest.of(key, "{}".getBytes(StandardCharsets.UTF_8)).withContentType("application/json").withMetadata(Map.of("owner", "me"))); + files().updateHeaders(key, ObjectHeaders.cacheControl("no-cache")); + StoredObject o = files().head(key).orElseThrow(); + assertThat(o.getHeaders().getCacheControl()).isEqualTo("no-cache"); + assertThat(o.getHeaders().getContentType()).isEqualTo("application/json"); + assertThat(o.getHeaders().getUserMetadata()).containsEntry("owner", "me"); + } + + + + @Test + void multipartCopyOfAnObjectOverTheThreshold() throws Exception + { + // reinstall with a low threshold so the 12 MiB copy is made in parts + StorageTestSupport.setUp(target, c -> c.findConnection("primary").setMultipartCopyThresholdBytes(6L * 1024 * 1024)); + byte[] bytes = content(12 * 1024 * 1024); + String src = prefix + "mp-src.bin"; + files().put(PutRequest.of(src, bytes)); + String dst = prefix + "mp-dst.bin"; + files().copy(CopyRequest.of(src, dst)); + try(ObjectStream in = files().get(dst, Range.all())) + { + assertThat(sha(in.readAllBytes())).isEqualTo(sha(bytes)); + } + } + + + + @Test + void purgeKeysUsesBatchDeleteWithSingleDeleteFallback() throws Exception + { + List keys = new ArrayList<>(); + for(int i = 0; i < 5; i++) + { + String key = prefix + "purge/" + i; + files().put(PutRequest.of(key, new byte[] { 1 })); + keys.add(key); + } + int deleted = ObjectStorage.asSystem("conformance", "run-1").bucket("files").purgeKeys(keys); + assertThat(deleted).isEqualTo(5); + assertThat(files().list(ListRequest.prefix(prefix + "purge/")).getItems()).isEmpty(); + } + + + + @Test + void presignedGetAndPutWorkWithAPlainHttpClient() throws Exception + { + String key = prefix + "presigned.txt"; + URL put = files().presignPut(PresignPut.of(key).contentType("text/plain").withTtl(Duration.ofMinutes(5))); + HttpResponse uploaded = http.send(HttpRequest.newBuilder(put.toURI()).header("Content-Type", "text/plain").PUT(HttpRequest.BodyPublishers.ofString("via presigned put")).build(), + HttpResponse.BodyHandlers.ofString()); + assertThat(uploaded.statusCode()).as(uploaded.body()).isBetween(200, 299); + + URL get = files().presignGet(PresignGet.of(key).withTtl(Duration.ofMinutes(5)).withDownloadAs("photo.txt")); + HttpResponse downloaded = http.send(HttpRequest.newBuilder(get.toURI()).GET().build(), HttpResponse.BodyHandlers.ofString()); + assertThat(downloaded.statusCode()).isEqualTo(200); + assertThat(downloaded.body()).isEqualTo("via presigned put"); + assertThat(downloaded.headers().firstValue("Content-Disposition")).hasValueSatisfying(v -> assertThat(v).contains("photo.txt")); + } + + + + @Test + void presignedPutPinsContentTypeAndLength() throws Exception + { + String key = prefix + "pinned.txt"; + URL put = files().presignPut(PresignPut.of(key).contentType("text/plain").withContentLength(5L)); + HttpResponse wrongType = http.send(HttpRequest.newBuilder(put.toURI()).header("Content-Type", "image/png").PUT(HttpRequest.BodyPublishers.ofString("12345")).build(), + HttpResponse.BodyHandlers.ofString()); + assertThat(wrongType.statusCode()).isGreaterThanOrEqualTo(400); + + HttpResponse right = http.send(HttpRequest.newBuilder(put.toURI()).header("Content-Type", "text/plain").PUT(HttpRequest.BodyPublishers.ofString("12345")).build(), + HttpResponse.BodyHandlers.ofString()); + assertThat(right.statusCode()).as(right.body()).isBetween(200, 299); + } + + + + @Test + void presignedUrlsExpire() throws Exception + { + String key = prefix + "expiring.txt"; + files().put(PutRequest.of(key, "x".getBytes(StandardCharsets.UTF_8))); + URL get = files().presignGet(PresignGet.of(key).withTtl(Duration.ofSeconds(1))); + Thread.sleep(3000); + HttpResponse r = http.send(HttpRequest.newBuilder(get.toURI()).GET().build(), HttpResponse.BodyHandlers.ofString()); + assertThat(r.statusCode()).isGreaterThanOrEqualTo(400); + } + + + + @Test + void presignedTtlIsCappedByTheBucketMaximum() throws Exception + { + String key = prefix + "capped.txt"; + URL get = files().presignGet(PresignGet.of(key).withTtl(Duration.ofDays(5))); + assertThat(get.getQuery()).contains("X-Amz-Expires=3600"); + } + + + + @Test + void presignedPostEnforcesTheSizeRange() throws Exception + { + Assumptions.assumeTrue(target.expected.get(Capability.PRESIGNED_POST) == CapabilityStatus.SUPPORTED); + String key = prefix + "posted.txt"; + PresignedPost post = files().presignPost(new PresignPost().withKey(key).withTtl(Duration.ofMinutes(5)).withMinBytes(1L).withMaxBytes(10L)); + HttpResponse posted = postForm(post, "12345"); + assertThat(posted.statusCode()).as(posted.body() + " url=" + post.getUrl()).isBetween(200, 299); + assertThat(files().head(key)).isPresent(); + PresignedPost second = files().presignPost(new PresignPost().withKey(prefix + "toobig.txt").withTtl(Duration.ofMinutes(5)).withMinBytes(1L).withMaxBytes(10L)); + assertThat(postForm(second, "this is more than ten bytes").statusCode()).isGreaterThanOrEqualTo(400); + assertThat(files().head(prefix + "toobig.txt")).isEmpty(); + } + + + + /******************************************************************************* + ** POST a multipart/form-data upload built from a presigned POST. + *******************************************************************************/ + private HttpResponse postForm(PresignedPost post, String content) throws Exception + { + String boundary = "----qqq" + UUID.randomUUID(); + StringBuilder body = new StringBuilder(); + for(Map.Entry field : post.getFields().entrySet()) + { + body.append("--").append(boundary).append("\r\nContent-Disposition: form-data; name=\"").append(field.getKey()).append("\"\r\n\r\n").append(field.getValue()).append("\r\n"); + } + body.append("--").append(boundary).append("\r\nContent-Disposition: form-data; name=\"file\"; filename=\"f.txt\"\r\nContent-Type: text/plain\r\n\r\n").append(content).append("\r\n--").append(boundary).append("--\r\n"); + return (http.send(HttpRequest.newBuilder(URI.create(post.getUrl())).header("Content-Type", "multipart/form-data; boundary=" + boundary) + .POST(HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8)).build(), HttpResponse.BodyHandlers.ofString())); + } + + + + @Test + void conditionalWriteWhereExpected() throws Exception + { + Assumptions.assumeTrue(target.expected.get(Capability.CONDITIONAL_WRITE) == CapabilityStatus.SUPPORTED); + String key = prefix + "once.txt"; + files().put(PutRequest.of(key, "1".getBytes(StandardCharsets.UTF_8)).withIfNoneMatch(true)); + assertThatThrownBy(() -> files().put(PutRequest.of(key, "2".getBytes(StandardCharsets.UTF_8)).withIfNoneMatch(true))) + .isInstanceOfSatisfying(StorageException.class, e -> assertThat(e.getReason()).isEqualTo(StorageException.Reason.PRECONDITION_FAILED)); + } + + + + @Test + void bothChecksumModesWork() throws Exception + { + for(ChecksumMode mode : ChecksumMode.values()) + { + StorageTestSupport.setUp(StorageTestSupport.config(target, mode)); + String key = prefix + "checksum-" + mode + ".bin"; + try + { + byte[] bytes = content(6 * 1024 * 1024); + files().put(PutRequest.of(key, bytes)); + try(ObjectStream in = files().get(key, Range.all())) + { + assertThat(sha(in.readAllBytes())).isEqualTo(sha(bytes)); + } + files().put(PutRequest.of(key + ".mp", (InputStream) new ByteArrayInputStream(content(20 * 1024 * 1024)))); + } + catch(RuntimeException e) + { + // WHEN_REQUIRED must always work; WHEN_SUPPORTED only where the store takes SDK default checksums + if(mode == ChecksumMode.WHEN_REQUIRED || target.checksumsSupportedWorks) + { + throw (e); + } + } + } + } + + + + @Test + void capabilityProbeMatchesTheExpectedSetAndChangesNothing() throws Exception + { + String versioningBefore; + try(S3Client raw = target.rawClient()) + { + versioningBefore = String.valueOf(raw.getBucketVersioning(b -> b.bucket(target.bucket)).statusAsString()); + } + CapabilityProbe.Report report = CapabilityProbe.probeConnection("primary", true); + + Map got = report.getByBucket().get("files"); + for(Map.Entry expected : target.expected.entrySet()) + { + assertThat(got.get(expected.getKey())).as(expected.getKey().name()).isEqualTo(expected.getValue()); + } + try(S3Client raw = target.rawClient()) + { + assertThat(String.valueOf(raw.getBucketVersioning(b -> b.bucket(target.bucket)).statusAsString())).isEqualTo(versioningBefore); + ListObjectsV2Response left = raw.listObjectsV2(b -> b.bucket(target.bucket).prefix(CapabilityProbe.PROBE_PREFIX)); + assertThat(left.contents()).as("probe objects are always deleted").isEmpty(); + } + if(target.lockBucket != null) + { + assertThat(report.getNotes().get("locked")).anyMatch(n -> n.contains("object lock")); + } + } + + + + @Test + void softDeleteWithATrashBucketAndRestore() throws Exception + { + StorageBucket soft = ObjectStorage.bucket("soft"); + String key = prefix + "trashed.txt"; + soft.put(PutRequest.of(key, "keep me".getBytes(StandardCharsets.UTF_8))); + + assertThat(soft.delete(DeleteRequest.of(key)).getMode()).isEqualTo(DeleteMode.SOFT_TRASH); + assertThat(soft.head(key)).isEmpty(); + assertThat(ObjectStorage.bucket("trashbin").head(".trash/" + key)).isPresent(); + + assertThat(soft.restore(key)).isTrue(); + try(ObjectStream in = soft.get(key, Range.all())) + { + assertThat(new String(in.readAllBytes(), StandardCharsets.UTF_8)).isEqualTo("keep me"); + } + assertThat(ObjectStorage.bucket("trashbin").head(".trash/" + key)).isEmpty(); + } + + + + @Test + void softDeleteWithVersioningAndRestore() throws Exception + { + Assumptions.assumeTrue(target.versionedBucket != null, "the store has no versioning"); + StorageBucket versioned = ObjectStorage.bucket("versioned"); + String key = prefix + "versioned.txt"; + versioned.put(PutRequest.of(key, "v1".getBytes(StandardCharsets.UTF_8))); + + assertThat(versioned.delete(DeleteRequest.of(key)).getMode()).isEqualTo(DeleteMode.SOFT_DELETE_MARKER); + assertThat(versioned.head(key)).isEmpty(); + + assertThat(versioned.restore(key)).isTrue(); + assertThat(versioned.head(key)).isPresent(); + + // a system caller can remove every version permanently + DeleteMode mode = ObjectStorage.asSystem("conformance", "r1").bucket("versioned").delete(DeleteRequest.of(key).permanent()).getMode(); + assertThat(mode).isEqualTo(DeleteMode.PERMANENT); + assertThat(versioned.head(key)).isEmpty(); + assertThat(versioned.restore(key)).isFalse(); + } + + + + @Test + void permanentDeleteIsForSystemCallersOnly() throws Exception + { + String key = prefix + "perm.txt"; + files().put(PutRequest.of(key, "x".getBytes(StandardCharsets.UTF_8))); + assertThatThrownBy(() -> files().delete(DeleteRequest.of(key).permanent())) + .isInstanceOfSatisfying(StorageException.class, e -> assertThat(e.getReason()).isEqualTo(StorageException.Reason.ACCESS_DENIED)); + assertThat(files().head(key)).isPresent(); + } + + + + @Test + void missingObjectsAreNotFoundNotCrashes() throws Exception + { + assertThat(files().head(prefix + "nope")).isEmpty(); + assertThatThrownBy(() -> files().get(prefix + "nope", Range.all())) + .isInstanceOfSatisfying(StorageException.class, e -> assertThat(e.getReason()).isEqualTo(StorageException.Reason.NOT_FOUND)); + } + + + + @Test + void everyWriteIsLoggedWithoutPresignedSignatures() throws Exception + { + String key = prefix + "logged.txt"; + files().put(PutRequest.of(key, "x".getBytes(StandardCharsets.UTF_8))); + URL url = files().presignGet(PresignGet.of(key)); + files().delete(DeleteRequest.of(key)); + List rows = com.kingsrook.qbits.objectstorage.internal.Db.query("object_storage_operation", "objectKey", key); + assertThat(rows).extracting(r -> r.getValueString("action")).contains("PUT", "PRESIGN_GET", "DELETE"); + String signature = url.getQuery().replaceAll(".*X-Amz-Signature=([^&]+).*", "$1"); + for(com.kingsrook.qqq.backend.core.model.data.QRecord row : rows) + { + assertThat(String.valueOf(row.getValues())).doesNotContain(signature); + } + } + + + + /******************************************************************************* + ** Count helper kept for subclasses. + *******************************************************************************/ + protected static int countOf(AtomicInteger counter) + { + return (counter.get()); + } +} diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/conformance/Targets.java b/src/test/java/com/kingsrook/qbits/objectstorage/conformance/Targets.java new file mode 100644 index 0000000..cdb1c03 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/objectstorage/conformance/Targets.java @@ -0,0 +1,289 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.conformance; + + +import java.io.IOException; +import java.time.Duration; +import java.util.UUID; +import com.kingsrook.qbits.objectstorage.api.Capability; +import com.kingsrook.qbits.objectstorage.api.CapabilityStatus; +import com.kingsrook.qbits.objectstorage.config.ProviderType; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.BucketVersioningStatus; +import software.amazon.awssdk.services.s3.model.VersioningConfiguration; + + +/******************************************************************************* + ** The pinned stores: MinIO (the digest-pinned Chainguard build the qqq tests + ** use, because Docker Hub's minio/minio is gone) and Garage (digest-pinned), + ** each started once per JVM with Testcontainers. + *******************************************************************************/ +public final class Targets +{ + public static final String MINIO_IMAGE = System.getProperty("qqq.test.minioImage", "cgr.dev/chainguard/minio@sha256:dc455cdd4db6e390b1b26514f15ff37681a777bf8dab41aac445a0259f751e56"); + public static final String GARAGE_IMAGE = System.getProperty("qqq.test.garageImage", "dxflrs/garage@sha256:a5706cf1f3d7b349ac5133ec59ad8181b709270b75e5f2fa7b3e1a5c07d67137"); + + private static ConformanceTarget minio; + private static ConformanceTarget garage; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private Targets() + { + } + + + + /******************************************************************************* + ** Whether Docker is available. In CI (the CI env var is set) a missing Docker + ** is a failure, not a skip. + *******************************************************************************/ + public static boolean dockerAvailable() + { + try + { + return (DockerClientFactory.instance().isDockerAvailable()); + } + catch(RuntimeException e) + { + return (false); + } + } + + + + /******************************************************************************* + ** Whether a missing Docker must fail the build. + *******************************************************************************/ + public static boolean dockerRequired() + { + return (System.getenv("CI") != null); + } + + + + /******************************************************************************* + ** MinIO, started once. + *******************************************************************************/ + public static synchronized ConformanceTarget minio() + { + if(minio == null) + { + GenericContainer container = new GenericContainer<>(MINIO_IMAGE) + .withEnv("MINIO_ROOT_USER", "qqq-test-access-key") + .withEnv("MINIO_ROOT_PASSWORD", "qqq-test-secret-key") + .withCommand("server", "/tmp/minio-data") + .withExposedPorts(9000) + .waitingFor(Wait.forHttp("/minio/health/live").forPort(9000).withStartupTimeout(Duration.ofMinutes(2))); + container.start(); + + ConformanceTarget t = new ConformanceTarget(); + t.name = "minio"; + t.providerType = ProviderType.MINIO; + t.endpoint = "http://" + container.getHost() + ":" + container.getMappedPort(9000); + t.accessKey = "qqq-test-access-key"; + t.secretKey = "qqq-test-secret-key"; + t.region = "us-east-1"; + String suffix = UUID.randomUUID().toString().substring(0, 8); + t.bucket = "scratch-" + suffix; + t.versionedBucket = "versioned-" + suffix; + t.trashBucket = "trash-" + suffix; + t.lockBucket = "locked-" + suffix; + t.indexBucket = "index-" + suffix; + try(S3Client s3 = t.rawClient()) + { + s3.createBucket(CreateBucketRequest.builder().bucket(t.bucket).build()); + s3.createBucket(CreateBucketRequest.builder().bucket(t.versionedBucket).build()); + s3.putBucketVersioning(b -> b.bucket(t.versionedBucket).versioningConfiguration(VersioningConfiguration.builder().status(BucketVersioningStatus.ENABLED).build())); + s3.createBucket(CreateBucketRequest.builder().bucket(t.trashBucket).build()); + s3.createBucket(CreateBucketRequest.builder().bucket(t.indexBucket).build()); + s3.createBucket(CreateBucketRequest.builder().bucket(t.lockBucket).objectLockEnabledForBucket(true).build()); + } + t.checksumsSupportedWorks = true; + // what the probe must report for the plain scratch bucket + t.expected.put(Capability.LIST_V2, CapabilityStatus.SUPPORTED); + t.expected.put(Capability.PUT_GET_DELETE, CapabilityStatus.SUPPORTED); + t.expected.put(Capability.COPY_REPLACE_METADATA, CapabilityStatus.SUPPORTED); + t.expected.put(Capability.BATCH_DELETE, CapabilityStatus.SUPPORTED); + t.expected.put(Capability.MULTIPART, CapabilityStatus.SUPPORTED); + t.expected.put(Capability.PRESIGNED_GET, CapabilityStatus.SUPPORTED); + t.expected.put(Capability.PRESIGNED_PUT, CapabilityStatus.SUPPORTED); + t.expected.put(Capability.PRESIGNED_POST, CapabilityStatus.SUPPORTED); + t.expected.put(Capability.CONDITIONAL_WRITE, CapabilityStatus.SUPPORTED); + t.expected.put(Capability.VERSIONING, CapabilityStatus.SUPPORTED); + t.expected.put(Capability.OBJECT_LOCK, CapabilityStatus.UNSUPPORTED); + minio = t; + } + return (minio); + } + + + + /******************************************************************************* + ** Garage, started once, with a single-node layout, one key and the buckets. + *******************************************************************************/ + public static synchronized ConformanceTarget garage() + { + if(garage == null) + { + String toml = """ + metadata_dir = "/tmp/meta" + data_dir = "/tmp/data" + db_engine = "sqlite" + replication_factor = 1 + rpc_bind_addr = "[::]:3901" + rpc_public_addr = "127.0.0.1:3901" + rpc_secret = "1799bccfd7411eddcf9ebd316bc1f5287ad12a68094e1c6ac6abde7e6feae1ec" + + [s3_api] + s3_region = "garage" + api_bind_addr = "[::]:3900" + root_domain = ".s3.garage.localhost" + + [admin] + api_bind_addr = "[::]:3903" + admin_token = "qqq-test-admin-token" + """; + GenericContainer container = new GenericContainer<>(GARAGE_IMAGE) + .withCopyToContainer(Transferable.of(toml), "/etc/garage.toml") + .withExposedPorts(3900, 3903) + .waitingFor(Wait.forHttp("/health").forPort(3903).forStatusCodeMatching(c -> c == 200 || c == 503).withStartupTimeout(Duration.ofMinutes(2))); + container.start(); + + String accessKey = "GK" + "0123456789abcdef01234567"; + String secretKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + ConformanceTarget t = new ConformanceTarget(); + t.name = "garage"; + t.providerType = ProviderType.GARAGE; + t.endpoint = "http://" + container.getHost() + ":" + container.getMappedPort(3900); + t.accessKey = accessKey; + t.secretKey = secretKey; + t.region = "garage"; + t.bucket = "scratch"; + t.trashBucket = "trash"; + t.indexBucket = "indexed"; + try + { + String status = exec(container, "/garage", "status"); + String nodeId = null; + for(String line : status.split("\n")) + { + String trimmed = line.trim(); + if(trimmed.matches("^[0-9a-f]{16}\\b.*")) + { + nodeId = trimmed.substring(0, 16); + break; + } + } + if(nodeId == null) + { + throw (new IllegalStateException("No Garage node id in: " + status)); + } + exec(container, "/garage", "layout", "assign", "-z", "dc1", "-c", "1G", nodeId); + exec(container, "/garage", "layout", "apply", "--version", "1"); + exec(container, "/garage", "key", "import", "--yes", "-n", "qqq-test", accessKey, secretKey); + for(String b : new String[] { t.bucket, t.trashBucket, t.indexBucket }) + { + exec(container, "/garage", "bucket", "create", b); + exec(container, "/garage", "bucket", "allow", "--read", "--write", "--owner", b, "--key", "qqq-test"); + } + } + catch(IOException | InterruptedException e) + { + throw (new IllegalStateException("Could not set up Garage", e)); + } + waitForS3(t); + t.checksumsSupportedWorks = false; + t.expected.put(Capability.LIST_V2, CapabilityStatus.SUPPORTED); + t.expected.put(Capability.PUT_GET_DELETE, CapabilityStatus.SUPPORTED); + t.expected.put(Capability.COPY_REPLACE_METADATA, CapabilityStatus.SUPPORTED); + t.expected.put(Capability.BATCH_DELETE, CapabilityStatus.SUPPORTED); + t.expected.put(Capability.MULTIPART, CapabilityStatus.SUPPORTED); + t.expected.put(Capability.PRESIGNED_GET, CapabilityStatus.SUPPORTED); + t.expected.put(Capability.PRESIGNED_PUT, CapabilityStatus.SUPPORTED); + t.expected.put(Capability.PRESIGNED_POST, CapabilityStatus.SUPPORTED); + t.expected.put(Capability.VERSIONING, CapabilityStatus.UNSUPPORTED); + t.expected.put(Capability.OBJECT_LOCK, CapabilityStatus.UNSUPPORTED); + t.expected.put(Capability.OBJECT_TAGGING, CapabilityStatus.UNSUPPORTED); + t.expected.put(Capability.BUCKET_POLICY, CapabilityStatus.UNSUPPORTED); + garage = t; + } + return (garage); + } + + + + /******************************************************************************* + ** Run a command in a container, failing on a non-zero exit. + *******************************************************************************/ + private static String exec(GenericContainer container, String... command) throws IOException, InterruptedException + { + Container.ExecResult result = container.execInContainer(command); + if(result.getExitCode() != 0) + { + throw (new IllegalStateException("Command failed (" + String.join(" ", command) + "): " + result.getStderr() + result.getStdout())); + } + return (result.getStdout()); + } + + + + /******************************************************************************* + ** Wait until the S3 API answers (the layout takes a moment to apply). + *******************************************************************************/ + private static void waitForS3(ConformanceTarget t) + { + long deadline = System.currentTimeMillis() + 60_000; + RuntimeException last = null; + while(System.currentTimeMillis() < deadline) + { + try(S3Client s3 = t.rawClient()) + { + s3.listObjectsV2(b -> b.bucket(t.bucket).maxKeys(1)); + return; + } + catch(RuntimeException e) + { + last = e; + try + { + Thread.sleep(500); + } + catch(InterruptedException ie) + { + Thread.currentThread().interrupt(); + break; + } + } + } + throw (new IllegalStateException("Garage S3 never became ready", last)); + } +} diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/devserver/StorageDevServer.java b/src/test/java/com/kingsrook/qbits/objectstorage/devserver/StorageDevServer.java new file mode 100644 index 0000000..abcea90 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/objectstorage/devserver/StorageDevServer.java @@ -0,0 +1,318 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.devserver; + + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.Font; +import java.awt.GradientPaint; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig; +import com.kingsrook.qbits.objectstorage.ObjectStorageQBitProducer; +import com.kingsrook.qbits.objectstorage.api.CopyRequest; +import com.kingsrook.qbits.objectstorage.api.DeleteRequest; +import com.kingsrook.qbits.objectstorage.api.FieldReferenceProvider; +import com.kingsrook.qbits.objectstorage.api.ObjectHeaders; +import com.kingsrook.qbits.objectstorage.api.ObjectStorage; +import com.kingsrook.qbits.objectstorage.api.PresignGet; +import com.kingsrook.qbits.objectstorage.api.PutRequest; +import com.kingsrook.qbits.objectstorage.config.AddressingStyle; +import com.kingsrook.qbits.objectstorage.config.BucketRole; +import com.kingsrook.qbits.objectstorage.config.ProviderType; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig; +import com.kingsrook.qbits.objectstorage.index.ObjectIndex; +import com.kingsrook.qbits.objectstorage.index.ReferenceScan; +import com.kingsrook.qbits.objectstorage.index.StorageJobs; +import com.kingsrook.qbits.objectstorage.internal.CapabilityProbe; +import com.kingsrook.qbits.objectstorage.internal.ConfigSync; +import com.kingsrook.qbits.objectstorage.internal.Db; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.checksums.RequestChecksumCalculation; +import software.amazon.awssdk.core.checksums.ResponseChecksumValidation; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.BucketVersioningStatus; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.VersioningConfiguration; + + +/******************************************************************************* + ** A demo server for the Storage app, used to look at it in a browser (and for + ** the screenshots): an in-memory QQQ instance with the qbit installed against a + ** throwaway MinIO (Testcontainers), a few buckets, generated images, header + ** rules, references, an index and some history. Not shipped; lives in src/test. + ** + ** Run (from the repository root, after building the test classes): + ** java -Dqqq.javalin.frontend=next -Dos.dev.port=8099 -cp "target/test-classes;target/classes;DEPS;EXPORT" ...StorageDevServer + ** where EXPORT is a classpath folder that holds a Next export as next-dashboard/. + *******************************************************************************/ +public class StorageDevServer extends AbstractQQQApplication +{ + private static QInstance instance; + + private static String minioEndpoint; + + + + /******************************************************************************* + ** The demo instance (built once in main). + *******************************************************************************/ + @Override + public QInstance defineQInstance() + { + return (instance); + } + + + + /******************************************************************************* + ** Start MinIO, build and seed the instance, start the server. + *******************************************************************************/ + public static void main(String[] args) throws Exception + { + int port = Integer.parseInt(System.getProperty("os.dev.port", "8099")); + + String endpoint = System.getProperty("os.dev.minio"); + if(endpoint == null) + { + GenericContainer minio = new GenericContainer<>(System.getProperty("qqq.test.minioImage", "cgr.dev/chainguard/minio@sha256:dc455cdd4db6e390b1b26514f15ff37681a777bf8dab41aac445a0259f751e56")) + .withEnv("MINIO_ROOT_USER", "devaccess").withEnv("MINIO_ROOT_PASSWORD", "devsecretkey").withCommand("server", "/tmp/minio-data").withExposedPorts(9000) + .waitingFor(Wait.forHttp("/minio/health/live").forPort(9000).withStartupTimeout(Duration.ofMinutes(2))); + minio.start(); + endpoint = "http://localhost:" + minio.getMappedPort(9000); + } + minioEndpoint = endpoint; + createBuckets(); + + System.setProperty("os.dev.endpoint", endpoint); + System.setProperty("os.dev.ak", "devaccess"); + System.setProperty("os.dev.sk", "devsecretkey"); + instance = buildInstance(); + + QContext.init(instance, new QSession()); + ConfigSync.sync(); + seed(); + + String imageSource = endpoint; + QApplicationJavalinServer server = new QApplicationJavalinServer(new StorageDevServer()).withPort(port).withServeFrontendNext(true) + .withNextDashboardSecurityHeadersCustomizer(headers -> headers.withSources("img-src", imageSource)); + server.start(); + System.out.println("STORAGE DEV SERVER READY http://localhost:" + port + "/ (MinIO at " + endpoint + ")"); + Thread.currentThread().join(); + } + + + + /******************************************************************************* + ** The QQQ instance: an in-memory backend, a small "media" table that points at + ** objects, and the Storage qbit. + *******************************************************************************/ + static QInstance buildInstance() throws Exception + { + QInstance qInstance = new QInstance(); + qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS)); + qInstance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); + qInstance.addTable(new QTableMetaData().withName("media").withLabel("Media").withBackendName("memory").withPrimaryKeyField("id").withRecordLabelFormat("%s").withRecordLabelFields("title") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("title", QFieldType.STRING)).withField(new QFieldMetaData("storageKey", QFieldType.STRING)).withField(new QFieldMetaData("thumbKey", QFieldType.STRING))); + qInstance.addApp(new QAppMetaData().withName("admin").withLabel("Admin") + .withSection(new QAppSection().withName("data").withLabel("Data").withTables(List.of("media")))); + + ObjectStorageQBitConfig config = new ObjectStorageQBitConfig() + .withBackendName("memory").withBucketScoping(false).withOpenWhenAnonymous(true).withAdminApp(true).withParentAppName("admin") + .withAllowedSecretRefPatterns(List.of("prop.os.dev.*")) + .withConnection(new StorageConnectionConfig("primary").withProviderType(ProviderType.MINIO).withEndpoint("${prop.os.dev.endpoint}").withRegion("us-east-1") + .withAddressingStyle(AddressingStyle.PATH).withAccessKeyRef("${prop.os.dev.ak}").withSecretKeyRef("${prop.os.dev.sk}") + .withPresignEndpoint(minioEndpoint).withMaxOpsPerSecond(500).withMaxConcurrency(8)) + .withBucket(new StorageBucketConfig("media").withConnection("primary").withBucketName("dev-media").withRole(BucketRole.PUBLIC).withPublicBaseUrl(minioEndpoint + "/dev-media") + .withIndexed(true).withReferenceTracked(true).withExpectVersioning(true).withExemptPrefixes(new ArrayList<>(List.of("tools/")))) + .withBucket(new StorageBucketConfig("incoming").withConnection("primary").withBucketName("dev-incoming").withRole(BucketRole.INCOMING).withIndexed(false)) + .withBucket(new StorageBucketConfig("documents").withConnection("primary").withBucketName("dev-documents").withRole(BucketRole.PRIVATE).withIndexed(true) + .withTrashEnabled(true).withTrashBucket("trash")) + .withBucket(new StorageBucketConfig("trash").withConnection("primary").withBucketName("dev-trash").withRole(BucketRole.PRIVATE).withIndexed(false)) + .withBucket(new StorageBucketConfig("backups").withConnection("primary").withBucketName("dev-backups").withRole(BucketRole.BACKUP).withIndexed(false)) + .withReferenceProvider(FieldReferenceProvider.key("media", "media", "storageKey")).withReferenceProvider(FieldReferenceProvider.key("media", "media", "thumbKey")); + new ObjectStorageQBitProducer().withQBitConfig(config).produce(qInstance); + return (qInstance); + } + + + + /******************************************************************************* + ** Create the buckets with the raw client (the app never creates buckets). + *******************************************************************************/ + private static void createBuckets() + { + try(S3Client s3 = S3Client.builder().endpointOverride(java.net.URI.create(minioEndpoint)).region(Region.US_EAST_1).forcePathStyle(true) + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create("devaccess", "devsecretkey"))) + .requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED).responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED).build()) + { + for(String bucket : new String[] { "dev-media", "dev-incoming", "dev-documents", "dev-trash" }) + { + s3.createBucket(CreateBucketRequest.builder().bucket(bucket).build()); + } + s3.putBucketVersioning(b -> b.bucket("dev-media").versioningConfiguration(VersioningConfiguration.builder().status(BucketVersioningStatus.ENABLED).build())); + s3.createBucket(CreateBucketRequest.builder().bucket("dev-backups").objectLockEnabledForBucket(true).build()); + } + } + + + + /******************************************************************************* + ** A generated picture (so the thumbnails are real images). + *******************************************************************************/ + private static byte[] picture(int w, int h, Color a, Color b, String label, String format) throws Exception + { + BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); + Graphics2D g = image.createGraphics(); + g.setPaint(new GradientPaint(0, 0, a, w, h, b)); + g.fillRect(0, 0, w, h); + g.setColor(new Color(255, 255, 255, 220)); + g.setFont(new Font("SansSerif", Font.BOLD, Math.max(14, w / 12))); + g.drawString(label, w / 12, h / 2); + g.dispose(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(image, format, out); + return (out.toByteArray()); + } + + + + /******************************************************************************* + ** Demo data: media, documents, header rules, references, history, an index. + *******************************************************************************/ + private static void seed() throws Exception + { + QContext.setQSession(new QSession().withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference("james@kof22.com"))); + + Db.insert(ObjectStorageTables.HEADER_RULE, new QRecord().withValue("name", "Content-addressed media").withValue("keyRegex", ".*/[0-9a-f]{8}-.*") + .withValue("cacheControl", "public, max-age=31536000, immutable").withValue("priority", 10).withValue("enabled", true)); + Db.insert(ObjectStorageTables.HEADER_RULE, new QRecord().withValue("name", "Images").withValue("contentTypePattern", "image/*").withValue("cacheControl", "public, max-age=86400").withValue("priority", 50).withValue("enabled", true)); + Db.insert(ObjectStorageTables.PROTECTION_RULE, new QRecord().withValue("bucketId", ConfigSync.bucketId("media")).withValue("prefixGlob", "legal/*").withValue("mode", "NO_DELETE") + .withValue("reason", "Retained for legal review").withValue("enabled", true)); + + // objects written outside QQQ first (so the scan has something to find), then through the service + try(S3Client raw = S3Client.builder().endpointOverride(java.net.URI.create(minioEndpoint)).region(Region.US_EAST_1).forcePathStyle(true) + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create("devaccess", "devsecretkey"))) + .requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED).responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED).build()) + { + Color[][] palette = { { new Color(0x2563eb), new Color(0x7c3aed) }, { new Color(0x059669), new Color(0x84cc16) }, { new Color(0xea580c), new Color(0xdb2777) }, + { new Color(0x0891b2), new Color(0x1d4ed8) }, { new Color(0x4f46e5), new Color(0x06b6d4) }, { new Color(0xb91c1c), new Color(0xf59e0b) } }; + String[] names = { "harbor-sunrise", "forest-trail", "city-lights", "alpine-lake", "desert-dunes", "coast-storm" }; + for(int idx = 0; idx < names.length; idx++) + { + final int i = idx; + String hash = String.format("%08x", names[i].hashCode()); + byte[] original = picture(1600, 900, palette[i][0], palette[i][1], names[i], "jpg"); + byte[] small = picture(480, 270, palette[i][0], palette[i][1], names[i], "png"); + raw.putObject(b -> b.bucket("dev-media").key("photos/2026/" + hash + "-" + names[i] + ".jpg").contentType("image/jpeg"), software.amazon.awssdk.core.sync.RequestBody.fromBytes(original)); + raw.putObject(b -> b.bucket("dev-media").key("photos/2026/thumbs/" + hash + "-" + names[i] + ".png").contentType("image/png"), software.amazon.awssdk.core.sync.RequestBody.fromBytes(small)); + } + raw.putObject(b -> b.bucket("dev-media").key("photos/legacy/IMG_0042.jpg").contentType("image/jpeg"), software.amazon.awssdk.core.sync.RequestBody.fromBytes(picture(1200, 800, Color.DARK_GRAY, Color.GRAY, "legacy", "jpg"))); + raw.putObject(b -> b.bucket("dev-media").key("tools/import-notes.txt").contentType("text/plain"), software.amazon.awssdk.core.sync.RequestBody.fromString("Internal import notes\n")); + raw.putObject(b -> b.bucket("dev-media").key("legal/consent-template.txt").contentType("text/plain"), software.amazon.awssdk.core.sync.RequestBody.fromString("Consent text\n")); + raw.putObject(b -> b.bucket("dev-documents").key("reports/2026-09-summary.txt").contentType("text/plain"), software.amazon.awssdk.core.sync.RequestBody.fromString("September summary\n".repeat(40))); + raw.putObject(b -> b.bucket("dev-documents").key("reports/2026-10-summary.txt").contentType("text/plain"), software.amazon.awssdk.core.sync.RequestBody.fromString("October summary\n".repeat(60))); + raw.putObject(b -> b.bucket("dev-documents").key("contracts/vendor-agreement.txt").contentType("text/plain"), software.amazon.awssdk.core.sync.RequestBody.fromString("Agreement\n".repeat(200))); + } + + // media records point at the photos and their thumbnails; one record points at an object that is not there + String[] titles = { "Harbor at sunrise", "Forest trail", "City lights", "Alpine lake", "Desert dunes", "Coast storm" }; + String[] slugs = { "harbor-sunrise", "forest-trail", "city-lights", "alpine-lake", "desert-dunes", "coast-storm" }; + List mediaRows = new ArrayList<>(); + for(int i = 0; i < slugs.length; i++) + { + String hash = String.format("%08x", slugs[i].hashCode()); + mediaRows.add(new QRecord().withValue("title", titles[i]).withValue("storageKey", "photos/2026/" + hash + "-" + slugs[i] + ".jpg") + .withValue("thumbKey", "photos/2026/thumbs/" + hash + "-" + slugs[i] + ".png")); + } + mediaRows.add(new QRecord().withValue("title", "Old launch banner").withValue("storageKey", "photos/2025/launch-banner.jpg")); + Db.insert("media", mediaRows); + + // yesterday's usage, so the trend has two points + ObjectIndex.scan("media"); + ObjectIndex.scan("documents"); + Db.update(ObjectStorageTables.BUCKET, List.of(new QRecord().withValue("id", ConfigSync.bucketId("media")).withValue("orphanCountAlertThreshold", 2))); + + // some history through the service: uploads, a copy, a soft delete, a presign + ObjectStorage.bucket("media").put(PutRequest.of("photos/2026/" + String.format("%08x", "new-banner".hashCode()) + "-new-banner.jpg", picture(1600, 900, new Color(0x0f766e), new Color(0x1e3a8a), "new banner", "jpg")) + .withContentType("image/jpeg")); + ObjectStorage.bucket("media").copy(CopyRequest.of("photos/legacy/IMG_0042.jpg", "photos/legacy/IMG_0042-copy.jpg")); + ObjectStorage.bucket("media").updateHeaders("photos/legacy/IMG_0042.jpg", ObjectHeaders.cacheControl("public, max-age=60")); + ObjectStorage.bucket("documents").put(PutRequest.of("reports/draft-notes.txt", "Draft\n".getBytes(StandardCharsets.UTF_8))); + ObjectStorage.bucket("documents").delete(DeleteRequest.of("reports/draft-notes.txt")); + ObjectStorage.bucket("media").presignGet(PresignGet.of("photos/legacy/IMG_0042.jpg")); + ObjectStorage.asSystem("backups", "run 41").bucket("backups").put(PutRequest.of("2026-10-11/db.dump", new byte[2048])); + + // the index: headers, references, usage (two days), connection test + ObjectIndex.headBacklog("media", 1000); + ObjectIndex.headBacklog("documents", 1000); + ReferenceScan.run("media"); + StorageJobs.usageSnapshot("media"); + StorageJobs.usageSnapshot("documents"); + for(String alias : new String[] { "media", "documents" }) + { + QRecord today = Db.first(ObjectStorageTables.USAGE_DAILY, "bucketId", ConfigSync.bucketId(alias)); + Db.insert(ObjectStorageTables.USAGE_DAILY, new QRecord().withValue("bucketId", ConfigSync.bucketId(alias)).withValue("snapshotDate", LocalDate.now(ZoneOffset.UTC).minusDays(29)) + .withValue("objectCount", Math.max(0, today.getValueLong("objectCount") - 5)).withValue("totalBytes", Math.max(0, today.getValueLong("totalBytes") - 600_000)).withValue("byPrefix", "{}")); + } + CapabilityProbe.Report report = CapabilityProbe.probeConnection("primary", true); + QRecord connection = Db.first(ObjectStorageTables.CONNECTION, "name", "primary"); + Db.update(ObjectStorageTables.CONNECTION, List.of(new QRecord().withValue("id", connection.getValue("id")).withValue("lastTestedAt", Instant.now()).withValue("lastTestStatus", "OK") + .withValue("lastTestSummary", report.getByBucket().keySet().stream().map(a -> a + ": " + report.summaryFor(a)).reduce((x, y) -> x + "; " + y).orElse("")) + .withValue("clockSkewSeconds", report.getClockSkewSeconds() == null ? null : report.getClockSkewSeconds().intValue()))); + ObjectStorageRuntime.setClock(Clock.systemUTC()); + QContext.setQSession(new QSession()); + System.out.println("Seeded: " + Db.query(ObjectStorageTables.OBJECT).size() + " objects, " + Db.query(ObjectStorageTables.OPERATION).size() + " operations; " + Map.of("ok", true)); + } +} diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/index/MinioIndexTest.java b/src/test/java/com/kingsrook/qbits/objectstorage/index/MinioIndexTest.java new file mode 100644 index 0000000..670a9c6 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/objectstorage/index/MinioIndexTest.java @@ -0,0 +1,555 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.index; + + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.IntStream; +import com.kingsrook.qbits.objectstorage.api.CopyRequest; +import com.kingsrook.qbits.objectstorage.api.DeleteRequest; +import com.kingsrook.qbits.objectstorage.api.FieldReferenceProvider; +import com.kingsrook.qbits.objectstorage.api.ObjectHeaders; +import com.kingsrook.qbits.objectstorage.api.ObjectStorage; +import com.kingsrook.qbits.objectstorage.api.PutRequest; +import com.kingsrook.qbits.objectstorage.api.StorageAlertSink; +import com.kingsrook.qbits.objectstorage.conformance.ConformanceTarget; +import com.kingsrook.qbits.objectstorage.conformance.Targets; +import com.kingsrook.qbits.objectstorage.internal.ConfigSync; +import com.kingsrook.qbits.objectstorage.internal.DefaultObjectHeaderPolicy; +import com.kingsrook.qbits.objectstorage.internal.Db; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The object index against MinIO: scan, folders and rollups, HEAD backlog, + ** write-through, soft-delete rows, resume, the mass-removal safety stop, the + ** reference scan (orphaned and missing), usage snapshots and the nightly job. + *******************************************************************************/ +class MinioIndexTest +{ + private ConformanceTarget target; + private List alerts = new ArrayList<>(); + + + + /******************************************************************************* + ** Install the qbit against MinIO with an empty index bucket and a media table. + *******************************************************************************/ + @BeforeEach + void setUp() throws Exception + { + Assumptions.assumeTrue(Targets.dockerAvailable(), "Docker is not available"); + target = Targets.minio(); + emptyBucket(target.indexBucket); + emptyBucket(target.versionedBucket); + emptyBucket(target.trashBucket); + alerts.clear(); + StorageTestSupport.setUp(StorageTestSupport.config(target, com.kingsrook.qbits.objectstorage.config.ChecksumMode.WHEN_REQUIRED) + .withReferenceProvider(FieldReferenceProvider.keyPrefix("indexed", "media", "storageKey")) + .withReferenceProvider(FieldReferenceProvider.url("indexed", "media", "thumbUrl")), instance -> + instance.addTable(new QTableMetaData().withName("media").withBackendName(StorageTestSupport.MEMORY).withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("storageKey", QFieldType.STRING)).withField(new QFieldMetaData("thumbUrl", QFieldType.STRING)))); + ObjectStorageRuntime.setAlertSink(new StorageAlertSink() + { + /******************************************************************************* + ** Record. + *******************************************************************************/ + @Override + public void raise(String alertType, String dedupeKey, String severity, String message, Map details) + { + alerts.add("raise " + alertType + " " + dedupeKey + " " + severity); + } + + + + /******************************************************************************* + ** Record. + *******************************************************************************/ + @Override + public void resolve(String alertType, String dedupeKey) + { + alerts.add("resolve " + alertType + " " + dedupeKey); + } + }); + } + + + + /******************************************************************************* + ** Tear down. + *******************************************************************************/ + @AfterEach + void tearDown() + { + ObjectIndex.failAfterPages = null; + StorageTestSupport.tearDown(); + } + + + + /******************************************************************************* + ** Delete everything in a bucket (raw client, independent of the code under test). + *******************************************************************************/ + private void emptyBucket(String bucket) + { + if(bucket == null) + { + return; + } + try(S3Client raw = target.rawClient()) + { + raw.listObjectVersionsPaginator(b -> b.bucket(bucket)).forEach(page -> + { + page.versions().forEach(v -> raw.deleteObject(DeleteObjectRequest.builder().bucket(bucket).key(v.key()).versionId(v.versionId()).build())); + page.deleteMarkers().forEach(m -> raw.deleteObject(DeleteObjectRequest.builder().bucket(bucket).key(m.key()).versionId(m.versionId()).build())); + }); + raw.listObjectsV2Paginator(b -> b.bucket(bucket)).forEach(page -> page.contents().forEach(o -> raw.deleteObject(DeleteObjectRequest.builder().bucket(bucket).key(o.key()).build()))); + } + } + + + + /******************************************************************************* + ** Put an object with the raw client (a write made outside QQQ). + *******************************************************************************/ + private void rawPut(String bucket, String key, int size) + { + try(S3Client raw = target.rawClient()) + { + raw.putObject(PutObjectRequest.builder().bucket(bucket).key(key).contentType(key.endsWith(".png") ? "image/png" : "text/plain").build(), RequestBody.fromBytes(new byte[size])); + } + } + + + + /******************************************************************************* + ** The object rows of the index bucket by key. + *******************************************************************************/ + private Map rows(String alias) throws Exception + { + Map map = new java.util.TreeMap<>(); + for(QRecord r : Db.query(ObjectStorageTables.OBJECT, "bucketId", ConfigSync.bucketId(alias))) + { + map.put(r.getValueString("objectKey"), r); + } + return (map); + } + + + + /******************************************************************************* + ** The folder rows by path. + *******************************************************************************/ + private Map folders(String alias) throws Exception + { + Map map = new java.util.TreeMap<>(); + for(QRecord r : Db.query(ObjectStorageTables.FOLDER, "bucketId", ConfigSync.bucketId(alias))) + { + map.put(r.getValueString("path"), r); + } + return (map); + } + + + + @Test + void scanBuildsRowsFoldersAndRollupsAndSecondScanRepairsDrift() throws Exception + { + rawPut(target.indexBucket, "a/1.txt", 10); + rawPut(target.indexBucket, "a/b/2.png", 20); + rawPut(target.indexBucket, "a/b/c/3.bin", 30); + rawPut(target.indexBucket, "top.txt", 5); + + ObjectIndex.ScanResult first = ObjectIndex.scan("indexed"); + assertThat(first.status()).isEqualTo("OK"); + assertThat(first.seen()).isEqualTo(4); + assertThat(first.added()).isEqualTo(4); + + Map rows = rows("indexed"); + assertThat(rows.keySet()).containsExactly("a/1.txt", "a/b/2.png", "a/b/c/3.bin", "top.txt"); + QRecord png = rows.get("a/b/2.png"); + assertThat(png.getValueString("name")).isEqualTo("2.png"); + assertThat(png.getValueString("extension")).isEqualTo("png"); + assertThat(png.getValueLong("size")).isEqualTo(20); + assertThat(png.getValueString("etag")).doesNotContain("\"").hasSize(32); + assertThat(png.getValueBoolean("headersKnown")).isFalse(); + assertThat(rows.get("top.txt").getValueInteger("folderId")).isNull(); + + Map folders = folders("indexed"); + assertThat(folders.keySet()).containsExactly("a/", "a/b/", "a/b/c/"); + assertThat(folders.get("a/").getValueLong("objectCount")).isEqualTo(3); + assertThat(folders.get("a/").getValueLong("totalBytes")).isEqualTo(60); + assertThat(folders.get("a/b/").getValueLong("objectCount")).isEqualTo(2); + assertThat(folders.get("a/b/c/").getValueLong("totalBytes")).isEqualTo(30); + assertThat(folders.get("a/b/").getValueInteger("parentFolderId")).isEqualTo(folders.get("a/").getValueInteger("id")); + assertThat(folders.get("a/").getValueInteger("parentFolderId")).isNull(); + assertThat(folders.get("a/").getValueInstant("newestChange")).isNotNull(); + + QRecord bucket = Db.first(ObjectStorageTables.BUCKET, "alias", "indexed"); + assertThat(bucket.getValueLong("objectCount")).isEqualTo(4); + assertThat(bucket.getValueLong("totalBytes")).isEqualTo(65); + assertThat(bucket.getValueString("lastScanStatus")).isEqualTo("OK"); + assertThat(bucket.getValueInstant("lastScanDate")).isNotNull(); + assertThat(bucket.getValueLong("scanGeneration")).isEqualTo(1); + + // drift: change, remove and add outside QQQ + rawPut(target.indexBucket, "a/1.txt", 11); + try(S3Client raw = target.rawClient()) + { + raw.deleteObject(DeleteObjectRequest.builder().bucket(target.indexBucket).key("top.txt").build()); + } + rawPut(target.indexBucket, "new/4.txt", 7); + ObjectIndex.ScanResult second = ObjectIndex.scan("indexed"); + assertThat(second.added()).isEqualTo(1); + assertThat(second.updated()).isEqualTo(3); + assertThat(second.removed()).isEqualTo(1); + assertThat(rows("indexed").keySet()).containsExactly("a/1.txt", "a/b/2.png", "a/b/c/3.bin", "new/4.txt"); + assertThat(rows("indexed").get("a/1.txt").getValueLong("size")).isEqualTo(11); + assertThat(rows("indexed").get("a/1.txt").getValueBoolean("headersKnown")).as("a changed object needs its headers again").isFalse(); + assertThat(folders("indexed").get("a/").getValueLong("totalBytes")).isEqualTo(61); + assertThat(folders("indexed").get("new/").getValueLong("objectCount")).isEqualTo(1); + assertThat(Db.first(ObjectStorageTables.BUCKET, "alias", "indexed").getValueLong("scanGeneration")).isEqualTo(2); + assertThat(alerts).contains("resolve storage.scan.stale bucket:indexed"); + } + + + + @Test + void headBacklogFillsHeadersAndTracksPolicyCoverage() throws Exception + { + StorageTestSupport.insert(ObjectStorageTables.HEADER_RULE, + new QRecord().withValue("name", "png").withValue("contentTypePattern", "image/png").withValue("cacheControl", "public, max-age=60").withValue("priority", 1).withValue("enabled", true)); + DefaultObjectHeaderPolicy.clearCache(); + rawPut(target.indexBucket, "img/a.png", 3); + rawPut(target.indexBucket, "img/b.png", 3); + rawPut(target.indexBucket, "doc/c.txt", 3); + ObjectIndex.scan("indexed"); + + assertThat(ObjectIndex.headBacklog("indexed", 2)).isEqualTo(2); + assertThat(ObjectIndex.headBacklog("indexed", 100)).isEqualTo(1); + assertThat(ObjectIndex.headBacklog("indexed", 100)).isZero(); + + Map rows = rows("indexed"); + assertThat(rows.get("img/a.png").getValueString("contentType")).isEqualTo("image/png"); + assertThat(rows.get("img/a.png").getValueBoolean("headersKnown")).isTrue(); + assertThat(rows.get("img/a.png").getValueBoolean("matchesHeaderPolicy")).as("the object has no Cache-Control but the policy wants one").isFalse(); + assertThat(rows.get("doc/c.txt").getValueBoolean("matchesHeaderPolicy")).as("no rule, no opinion").isTrue(); + QRecord bucket = Db.first(ObjectStorageTables.BUCKET, "alias", "indexed"); + assertThat(bucket.getValueLong("headersKnownCount")).isEqualTo(3); + assertThat(bucket.getValueLong("headersMatchingPolicyCount")).isEqualTo(1); + + // fix one object through the service: write-through marks it matching + ObjectStorage.bucket("indexed").updateHeaders("img/a.png", ObjectHeaders.cacheControl("public, max-age=60")); + assertThat(rows("indexed").get("img/a.png").getValueBoolean("matchesHeaderPolicy")).isTrue(); + assertThat(rows("indexed").get("img/a.png").getValueString("cacheControl")).isEqualTo("public, max-age=60"); + } + + + + @Test + void writesThroughTheServiceUpdateTheIndexAtOnce() throws Exception + { + ObjectStorage.bucket("indexed").put(PutRequest.of("w/one.txt", "12345".getBytes(StandardCharsets.UTF_8)).withContentType("text/plain").withMetadata(Map.of("k", "v"))); + Map rows = rows("indexed"); + assertThat(rows.keySet()).containsExactly("w/one.txt"); + QRecord one = rows.get("w/one.txt"); + assertThat(one.getValueLong("size")).isEqualTo(5); + assertThat(one.getValueBoolean("headersKnown")).isTrue(); + assertThat(one.getValueString("contentType")).isEqualTo("text/plain"); + assertThat(one.getValueString("userMetadata")).contains("\"k\"").contains("\"v\""); + assertThat(folders("indexed").get("w/").getValueLong("objectCount")).isEqualTo(1); + assertThat(folders("indexed").get("w/").getValueLong("totalBytes")).isEqualTo(5); + + ObjectStorage.bucket("indexed").copy(CopyRequest.of("w/one.txt", "w/deeper/two.txt")); + assertThat(rows("indexed").keySet()).containsExactly("w/deeper/two.txt", "w/one.txt"); + assertThat(folders("indexed").get("w/").getValueLong("objectCount")).isEqualTo(2); + assertThat(folders("indexed").get("w/deeper/").getValueLong("totalBytes")).isEqualTo(5); + + ObjectStorage.bucket("indexed").put(PutRequest.of("w/one.txt", "123456789".getBytes(StandardCharsets.UTF_8))); + assertThat(folders("indexed").get("w/").getValueLong("totalBytes")).isEqualTo(14); + assertThat(folders("indexed").get("w/").getValueLong("objectCount")).isEqualTo(2); + + ObjectStorage.asSystem("test", "r").bucket("indexed").delete(DeleteRequest.of("w/deeper/two.txt").permanent()); + ObjectStorage.bucket("indexed").delete(DeleteRequest.of("w/one.txt")); + assertThat(rows("indexed")).as("a permanent delete removes the row").doesNotContainKey("w/deeper/two.txt").doesNotContainKey("w/one.txt"); + Map bucketRow = Map.of("b", Db.first(ObjectStorageTables.BUCKET, "alias", "indexed")); + assertThat(bucketRow.get("b").getValueLong("objectCount")).isZero(); + assertThat(bucketRow.get("b").getValueLong("totalBytes")).isZero(); + } + + + + @Test + void deletedObjectsOfAVersionedBucketStayAsDeletedRowsUntilTheRetentionPasses() throws Exception + { + ObjectStorage.bucket("versioned").put(PutRequest.of("v/keep.txt", "x".getBytes(StandardCharsets.UTF_8))); + ObjectStorage.bucket("versioned").put(PutRequest.of("v/gone.txt", "yy".getBytes(StandardCharsets.UTF_8))); + ObjectStorage.bucket("versioned").delete(DeleteRequest.of("v/gone.txt")); + QRecord gone = rows("versioned").get("v/gone.txt"); + assertThat(gone.getValueBoolean("deleted")).isTrue(); + assertThat(folders("versioned").get("v/").getValueLong("objectCount")).isEqualTo(1); + + ObjectStorage.bucket("versioned").restore("v/gone.txt"); + assertThat(rows("versioned").get("v/gone.txt").getValueBoolean("deleted")).isFalse(); + assertThat(folders("versioned").get("v/").getValueLong("objectCount")).isEqualTo(2); + + // a delete made outside QQQ is found by the scan and kept as a deleted row, then purged + try(S3Client raw = target.rawClient()) + { + raw.deleteObject(DeleteObjectRequest.builder().bucket(target.versionedBucket).key("v/keep.txt").build()); + } + ObjectIndex.ScanResult scan = ObjectIndex.scan("versioned"); + assertThat(scan.markedDeleted()).isEqualTo(1); + assertThat(rows("versioned").get("v/keep.txt").getValueBoolean("deleted")).isTrue(); + + ObjectStorageRuntime.setClock(Clock.fixed(Instant.now().plus(Duration.ofDays(31)), ZoneOffset.UTC)); + ObjectIndex.scan("versioned"); + assertThat(rows("versioned")).doesNotContainKey("v/keep.txt"); + } + + + + @Test + void aCrashedScanResumesFromItsSavedCursor() throws Exception + { + try(S3Client raw = target.rawClient()) + { + IntStream.range(0, 1500).parallel().forEach(i -> raw.putObject(PutObjectRequest.builder().bucket(target.indexBucket).key("many/" + String.format("%05d", i)).build(), RequestBody.empty())); + } + ObjectIndex.failAfterPages = 1; + org.assertj.core.api.Assertions.assertThatThrownBy(() -> ObjectIndex.scan("indexed")).hasMessageContaining("Simulated crash"); + QRecord crashed = Db.first(ObjectStorageTables.BUCKET, "alias", "indexed"); + assertThat(crashed.getValueString("lastScanStatus")).isEqualTo("FAILED"); + assertThat(crashed.getValueString("scanCursor")).isNotBlank(); + assertThat(rows("indexed")).hasSize(1000); + + ObjectIndex.ScanResult resumed = ObjectIndex.scan("indexed"); + assertThat(resumed.status()).isEqualTo("OK"); + assertThat(resumed.pages()).as("only the remaining page is listed").isEqualTo(1); + assertThat(rows("indexed")).hasSize(1500); + assertThat(Db.first(ObjectStorageTables.BUCKET, "alias", "indexed").getValueLong("scanGeneration")).as("a resume keeps the generation").isEqualTo(1); + assertThat(Db.first(ObjectStorageTables.BUCKET, "alias", "indexed").getValueLong("objectCount")).isEqualTo(1500); + } + + + + @Test + void theMassRemovalSafetyStopLeavesTheIndexAloneAndRaisesTheStaleAlert() throws Exception + { + for(int i = 0; i < 30; i++) + { + rawPut(target.indexBucket, "s/" + i, 1); + } + ObjectIndex.scan("indexed"); + try(S3Client raw = target.rawClient()) + { + for(int i = 0; i < 25; i++) + { + raw.deleteObject(DeleteObjectRequest.builder().bucket(target.indexBucket).key("s/" + i).build()); + } + } + alerts.clear(); + ObjectIndex.ScanResult stopped = ObjectIndex.scan("indexed"); + assertThat(stopped.status()).isEqualTo("STOPPED_SAFETY"); + assertThat(stopped.note()).contains("25 of 30").contains("wrong prefix"); + assertThat(rows("indexed")).hasSize(30); + assertThat(Db.first(ObjectStorageTables.BUCKET, "alias", "indexed").getValueString("lastScanStatus")).isEqualTo("STOPPED_SAFETY"); + assertThat(alerts).contains("raise storage.scan.stale bucket:indexed warning"); + } + + + + @Test + void aScanOfANonIndexedBucketOrAConcurrentScanIsRefused() throws Exception + { + assertThat(ObjectIndex.scan("files").status()).isEqualTo("NOT_INDEXED"); + assertThat(ObjectIndex.isScanning("indexed")).isFalse(); + } + + + + @Test + void theReferenceScanFindsOrphanedAndMissingObjectsAndProtectsReferencedOnes() throws Exception + { + rawPut(target.indexBucket, "media/1/a.jpg", 10); + rawPut(target.indexBucket, "media/1/b.jpg", 20); + rawPut(target.indexBucket, "media/9/orphan.jpg", 30); + rawPut(target.indexBucket, "exempt/tool.bin", 40); + rawPut(target.indexBucket, "single/direct.png", 50); + rawPut(target.indexBucket, "tombstone/other.txt", 1); + ObjectIndex.scan("indexed"); + + StorageTestSupport.insert("media", + new QRecord().withValue("storageKey", "media/1/"), + new QRecord().withValue("storageKey", "media/2/gone/"), + new QRecord().withValue("thumbUrl", "https://cdn.example.com/idx/single/direct.png?v=3"), + new QRecord().withValue("thumbUrl", "https://cdn.example.com/idx/single/not-there.png"), + new QRecord().withValue("thumbUrl", "https://elsewhere.example.com/x.png"), + new QRecord().withValue("storageKey", null)); + ReferenceScan.Result result = ReferenceScan.run("indexed"); + + assertThat(result.references()).isEqualTo(4); + assertThat(result.missing()).isEqualTo(2); + assertThat(result.orphaned()).as("orphan.jpg and other.txt; exempt/ is never reported").isEqualTo(2); + assertThat(result.orphanedBytes()).isEqualTo(31); + + Map rows = rows("indexed"); + assertThat(rows.get("media/1/a.jpg").getValueInteger("referenceCount")).isEqualTo(1); + assertThat(rows.get("media/1/b.jpg").getValueInteger("referenceCount")).isEqualTo(1); + assertThat(rows.get("single/direct.png").getValueInteger("referenceCount")).isEqualTo(1); + assertThat(rows.get("media/9/orphan.jpg").getValueInteger("referenceCount")).isZero(); + + List refs = Db.query(ObjectStorageTables.OBJECT_REFERENCE, "bucketId", ConfigSync.bucketId("indexed")); + assertThat(refs).hasSize(4); + assertThat(refs.stream().filter(r -> Boolean.TRUE.equals(r.getValueBoolean("missing"))).map(r -> r.getValueString("keyOrPrefix")).toList()) + .containsExactlyInAnyOrder("media/2/gone/", "single/not-there.png"); + QRecord direct = refs.stream().filter(r -> "single/direct.png".equals(r.getValueString("keyOrPrefix"))).findFirst().orElseThrow(); + assertThat(direct.getValueString("sourceTable")).isEqualTo("media"); + assertThat(direct.getValueString("sourceField")).isEqualTo("thumbUrl"); + assertThat(direct.getValueInteger("objectId")).isEqualTo(rows.get("single/direct.png").getValueInteger("id")); + + QRecord bucket = Db.first(ObjectStorageTables.BUCKET, "alias", "indexed"); + assertThat(bucket.getValueLong("orphanedCount")).isEqualTo(2); + assertThat(bucket.getValueLong("missingCount")).isEqualTo(2); + assertThat(bucket.getValueInstant("lastReferenceScanDate")).isNotNull(); + assertThat(alerts).contains("raise storage.objects.missing bucket:indexed error"); + + // a second scan after the data is fixed: counts go back down and the alert resolves + for(QRecord m : Db.query("media")) + { + if("media/2/gone/".equals(m.getValueString("storageKey")) || "https://cdn.example.com/idx/single/not-there.png".equals(m.getValueString("thumbUrl"))) + { + com.kingsrook.qqq.backend.core.actions.tables.DeleteAction da = new com.kingsrook.qqq.backend.core.actions.tables.DeleteAction(); + da.execute(new com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput("media").withPrimaryKeys(List.of(m.getValueInteger("id")))); + } + } + alerts.clear(); + ReferenceScan.run("indexed"); + assertThat(Db.first(ObjectStorageTables.BUCKET, "alias", "indexed").getValueLong("missingCount")).isZero(); + assertThat(alerts).contains("resolve storage.objects.missing bucket:indexed"); + } + + + + @Test + void backupBucketsAreNeverReferenceScanned() throws Exception + { + assertThat(ReferenceScan.applies(ObjectStorageRuntime.bucketConfig("locked"))).isFalse(); + assertThat(ReferenceScan.run("locked").references()).isZero(); + assertThat(ReferenceScan.applies(ObjectStorageRuntime.bucketConfig("files"))).isFalse(); + assertThat(ReferenceScan.applies(ObjectStorageRuntime.bucketConfig("indexed"))).isTrue(); + } + + + + @Test + void usageSnapshotsRecordBytesAndPrefixesOncePerDayAndAlertNearTheQuota() throws Exception + { + rawPut(target.indexBucket, "p1/a", 100); + rawPut(target.indexBucket, "p1/b", 100); + rawPut(target.indexBucket, "p2/c", 50); + ObjectIndex.scan("indexed"); + Db.update(ObjectStorageTables.BUCKET, List.of(new QRecord().withValue("id", ConfigSync.bucketId("indexed")).withValue("quotaBytes", 260L))); + + StorageJobs.usageSnapshot("indexed"); + StorageJobs.usageSnapshot("indexed"); + List usage = Db.query(ObjectStorageTables.USAGE_DAILY, "bucketId", ConfigSync.bucketId("indexed")); + assertThat(usage).hasSize(1); + assertThat(usage.get(0).getValueLong("totalBytes")).isEqualTo(250); + assertThat(usage.get(0).getValueLong("objectCount")).isEqualTo(3); + assertThat(usage.get(0).getValueString("byPrefix")).contains("p1/").contains("200").contains("p2/"); + assertThat(alerts).contains("raise storage.quota.near bucket:indexed error"); + + Db.update(ObjectStorageTables.BUCKET, List.of(new QRecord().withValue("id", ConfigSync.bucketId("indexed")).withValue("quotaBytes", 10_000L))); + alerts.clear(); + StorageJobs.usageSnapshot("indexed"); + assertThat(alerts).contains("resolve storage.quota.near bucket:indexed"); + } + + + + @Test + void theNightlyJobScansHeadsSnapshotsAndPurgesExpiredTrash() throws Exception + { + rawPut(target.indexBucket, "n/1.txt", 4); + ObjectStorage.bucket("soft").put(PutRequest.of("n/trashed.txt", "x".getBytes(StandardCharsets.UTF_8))); + ObjectStorage.bucket("soft").delete(DeleteRequest.of("n/trashed.txt")); + assertThat(ObjectStorage.bucket("trashbin").head(".trash/n/trashed.txt")).isPresent(); + + ObjectStorageRuntime.setClock(Clock.fixed(Instant.now().plus(Duration.ofDays(31)), ZoneOffset.UTC)); + List log = StorageJobs.nightly(); + assertThat(log).anyMatch(l -> l.startsWith("indexed scan: OK")).anyMatch(l -> l.startsWith("indexed HEAD backlog: 1")).anyMatch(l -> l.startsWith("indexed references:")) + .anyMatch(l -> l.equals("soft trash purge: 1 objects")); + assertThat(ObjectStorage.bucket("trashbin").head(".trash/n/trashed.txt")).isEmpty(); + assertThat(Db.query(ObjectStorageTables.USAGE_DAILY, "bucketId", ConfigSync.bucketId("indexed"))).hasSize(1); + assertThat(Db.first(ObjectStorageTables.BUCKET, "alias", "indexed").getValueLong("headersKnownCount")).isEqualTo(1); + } + + + + @Test + void theHomeShowsRealNumbersFromTheIndexAndHonestEmptyStates() throws Exception + { + String empty = com.kingsrook.qbits.objectstorage.app.StorageHome.render("/x", Instant.now()); + assertThat(empty).contains("No storage writes have been recorded yet").contains("not scanned yet").contains("No usage snapshots yet").contains("Never tested"); + + rawPut(target.indexBucket, "media/1/a.jpg", 2048); + rawPut(target.indexBucket, "media/9/orphan.jpg", 1024); + ObjectIndex.scan("indexed"); + ObjectIndex.headBacklog("indexed", 10); + StorageTestSupport.insert("media", new QRecord().withValue("storageKey", "media/1/")); + ReferenceScan.run("indexed"); + StorageJobs.usageSnapshot("indexed"); + ObjectStorage.bucket("indexed").put(PutRequest.of("media/1/c.jpg", new byte[3])); + + String html = com.kingsrook.qbits.objectstorage.app.StorageHome.render("/x", Instant.now()); + assertThat(html).contains("Buckets").contains("indexed").contains("/x/object_storage_bucket/").contains("KB") + .contains("Data health").contains("Orphaned").contains("object_storage_object?filter=") + .contains("Recent changes").contains("PUT").contains("media/1/c.jpg") + .contains("Cache headers").contains("objects match the header policy") + .contains("Usage trend").contains("collecting since") + .contains("Connections").contains("Never tested"); + } +} diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/index/PostgresIndexTest.java b/src/test/java/com/kingsrook/qbits/objectstorage/index/PostgresIndexTest.java new file mode 100644 index 0000000..a1bcbd3 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/objectstorage/index/PostgresIndexTest.java @@ -0,0 +1,273 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.index; + + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig; +import com.kingsrook.qbits.objectstorage.api.FieldReferenceProvider; +import com.kingsrook.qbits.objectstorage.api.ObjectStorage; +import com.kingsrook.qbits.objectstorage.conformance.ConformanceTarget; +import com.kingsrook.qbits.objectstorage.conformance.Targets; +import com.kingsrook.qbits.objectstorage.config.ChecksumMode; +import com.kingsrook.qbits.objectstorage.internal.ConfigSync; +import com.kingsrook.qbits.objectstorage.internal.Db; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageSchema; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qbits.objectstorage.processes.DeleteRestoreProcesses; +import com.kingsrook.qbits.objectstorage.testsupport.ProcessRunner; +import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.postgresql.PostgreSQLContainer; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The object index on its intended store: PostgreSQL, with the schema this qbit + ** generates (ObjectStorageSchema) and the real SQL of scans, references, record + ** locks and actions, over a MinIO bucket. + *******************************************************************************/ +class PostgresIndexTest +{ + private static PostgreSQLContainer postgres; + + private ConformanceTarget target; + + + + /******************************************************************************* + ** Start PostgreSQL once. + *******************************************************************************/ + @BeforeAll + static void startPostgres() + { + Assumptions.assumeTrue(Targets.dockerAvailable(), "Docker is not available"); + postgres = new PostgreSQLContainer("postgres:17-alpine").withDatabaseName("storage").withUsername("qqq").withPassword("qqq"); + postgres.start(); + } + + + + /******************************************************************************* + ** Stop PostgreSQL. + *******************************************************************************/ + @AfterAll + static void stopPostgres() + { + if(postgres != null) + { + postgres.stop(); + } + } + + + + /******************************************************************************* + ** A clean schema generated by the qbit, and the qbit installed on it. + *******************************************************************************/ + @BeforeEach + void setUp() throws Exception + { + target = Targets.minio(); + try(Connection c = DriverManager.getConnection(postgres.getJdbcUrl(), "qqq", "qqq"); Statement st = c.createStatement()) + { + st.execute("DROP SCHEMA public CASCADE"); + st.execute("CREATE SCHEMA public"); + ObjectStorageQBitConfig forSchema = new ObjectStorageQBitConfig().withBackendName("rdbms"); + for(String sql : ObjectStorageSchema.postgresStatements(forSchema)) + { + st.execute(sql); + } + st.execute("CREATE TABLE media (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, storage_key VARCHAR(500))"); + } + empty(target.indexBucket); + install(false); + } + + + + /******************************************************************************* + ** Install the qbit on the PostgreSQL backend. + *******************************************************************************/ + private void install(boolean scoping) throws Exception + { + ObjectStorageQBitConfig config = StorageTestSupport.config(target, ChecksumMode.WHEN_REQUIRED).withBackendName("rdbms").withBucketScoping(scoping).withOpenWhenAnonymous(true) + .withReferenceProvider(FieldReferenceProvider.key("indexed", "media", "storageKey")); + StorageTestSupport.setUp(config, instance -> + { + PostgreSQLBackendMetaData backend = new PostgreSQLBackendMetaData().withName("rdbms").withHostName(postgres.getHost()).withPort(postgres.getFirstMappedPort()) + .withDatabaseName("storage").withUsername("qqq").withPassword("qqq"); + instance.addBackend(backend); + instance.addTable(new QTableMetaData().withName("media").withBackendName("rdbms").withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("storageKey", QFieldType.STRING).withBackendName("storage_key"))); + }); + QContext.setQSession(new QSession().withUser(new QUser().withIdReference("pg@example.com"))); + } + + + + /******************************************************************************* + ** Tear down. + *******************************************************************************/ + @AfterEach + void tearDown() + { + StorageTestSupport.tearDown(); + } + + + + /******************************************************************************* + ** Empty a bucket (raw client). + *******************************************************************************/ + private void empty(String bucket) + { + try(S3Client raw = target.rawClient()) + { + raw.listObjectsV2Paginator(b -> b.bucket(bucket)).forEach(page -> page.contents().forEach(o -> raw.deleteObject(DeleteObjectRequest.builder().bucket(bucket).key(o.key()).build()))); + } + } + + + + /******************************************************************************* + ** Put with the raw client. + *******************************************************************************/ + private void rawPut(String key, int size) + { + try(S3Client raw = target.rawClient()) + { + raw.putObject(PutObjectRequest.builder().bucket(target.indexBucket).key(key).contentType("text/plain").build(), RequestBody.fromBytes(new byte[size])); + } + } + + + + @Test + void theGeneratedSchemaAndTheQbitAgree() throws Exception + { + List statements = ObjectStorageSchema.postgresStatements(new ObjectStorageQBitConfig().withBackendName("rdbms")); + assertThat(statements).anyMatch(s -> s.startsWith("CREATE TABLE IF NOT EXISTS object_storage_object (")).anyMatch(s -> s.contains("UNIQUE (bucket_id, object_key)")); + assertThat(String.join("\n", statements)).contains("object_key VARCHAR(1100)").contains("user_metadata TEXT").contains("last_modified TIMESTAMP WITHOUT TIME ZONE") + .contains("id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL").doesNotContain("preview_url VARCHAR(2000) NOT NULL"); + + // the config upsert wrote through the real backend + assertThat(Db.query(ObjectStorageTables.BUCKET).stream().map(r -> r.getValueString("alias")).toList()).contains("indexed", "files", "soft"); + assertThat(Db.first(ObjectStorageTables.CONNECTION, "name", "primary").getValueString("providerType")).isEqualTo("MINIO"); + } + + + + @Test + void scanFoldersRollupsAndReferencesOnPostgres() throws Exception + { + rawPut("a/1.txt", 10); + rawPut("a/b/2.txt", 20); + rawPut("top.txt", 5); + ObjectIndex.ScanResult scan = ObjectIndex.scan("indexed"); + assertThat(scan.status()).isEqualTo("OK"); + assertThat(scan.added()).isEqualTo(3); + + Map folders = new java.util.TreeMap<>(); + for(QRecord f : Db.query(ObjectStorageTables.FOLDER, "bucketId", ConfigSync.bucketId("indexed"))) + { + folders.put(f.getValueString("path"), f); + } + assertThat(folders.keySet()).containsExactly("a/", "a/b/"); + assertThat(folders.get("a/").getValueLong("totalBytes")).isEqualTo(30); + assertThat(folders.get("a/b/").getValueInteger("parentFolderId")).isEqualTo(folders.get("a/").getValueInteger("id")); + + // outside change: removal and rollups through the real SQL + try(S3Client raw = target.rawClient()) + { + raw.deleteObject(DeleteObjectRequest.builder().bucket(target.indexBucket).key("top.txt").build()); + } + rawPut("a/3.txt", 7); + ObjectIndex.scan("indexed"); + assertThat(Db.query(ObjectStorageTables.OBJECT, "bucketId", ConfigSync.bucketId("indexed")).stream().map(r -> r.getValueString("objectKey")).sorted().toList()) + .containsExactly("a/1.txt", "a/3.txt", "a/b/2.txt"); + + Db.insert("media", List.of(new QRecord().withValue("storageKey", "a/1.txt"), new QRecord().withValue("storageKey", "a/missing.txt"))); + ReferenceScan.Result refs = ReferenceScan.run("indexed"); + assertThat(refs.missing()).isEqualTo(1); + assertThat(refs.orphaned()).isEqualTo(2); + assertThat(Db.first(ObjectStorageTables.BUCKET, "alias", "indexed").getValueLong("orphanedBytes")).isEqualTo(27); + + // the write-through hook on the same backend + ObjectStorage.bucket("indexed").put(com.kingsrook.qbits.objectstorage.api.PutRequest.of("w/new.txt", new byte[4])); + assertThat(Db.first(ObjectStorageTables.OBJECT, "objectKey", "w/new.txt").getValueBoolean("headersKnown")).isTrue(); + } + + + + @Test + void actionsAndRecordLocksWorkOnPostgres() throws Exception + { + rawPut("d/ref.txt", 1); + rawPut("d/free.txt", 1); + ObjectIndex.scan("indexed"); + Db.insert("media", List.of(new QRecord().withValue("storageKey", "d/ref.txt"))); + ReferenceScan.run("indexed"); + Map rows = new java.util.TreeMap<>(); + for(QRecord r : Db.query(ObjectStorageTables.OBJECT, "bucketId", ConfigSync.bucketId("indexed"))) + { + rows.put(r.getValueString("objectKey"), r); + } + String ids = rows.get("d/ref.txt").getValue("id") + "," + rows.get("d/free.txt").getValue("id"); + ProcessRunner.Run run = ProcessRunner.run(DeleteRestoreProcesses.DELETE_NAME, ids, null); + assertThat(String.join("|", run.review())).contains("1 object is skipped: it is referenced").contains("1 object will be deleted permanently"); + assertThat(String.join("|", run.result())).contains("1 object was deleted permanently"); + assertThat(ObjectStorage.bucket("indexed").head("d/free.txt")).isEmpty(); + assertThat(ObjectStorage.bucket("indexed").head("d/ref.txt")).isPresent(); + + // per-bucket record locks on the real backend + install(true); + QContext.setQSession(new QSession().withUser(new QUser().withIdReference("scoped@example.com")).withPermissions(Set.of("object_storage.view"))); + assertThat(new QueryAction().execute(new QueryInput(ObjectStorageTables.OBJECT)).getRecords()).as("no bucket key, no rows").isEmpty(); + QContext.setQSession(new QSession().withUser(new QUser().withIdReference("scoped@example.com")).withPermissions(Set.of("object_storage.view")) + .withSecurityKeyValue(ObjectStorageTables.KEY_TYPE_BUCKET, ConfigSync.bucketId("indexed"))); + assertThat(new QueryAction().execute(new QueryInput(ObjectStorageTables.OBJECT)).getRecords()).extracting(r -> r.getValueString("objectKey")).containsExactly("d/ref.txt"); + } +} diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/internal/InternalsTest.java b/src/test/java/com/kingsrook/qbits/objectstorage/internal/InternalsTest.java new file mode 100644 index 0000000..76c0367 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/objectstorage/internal/InternalsTest.java @@ -0,0 +1,233 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.HexFormat; +import java.util.List; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import com.kingsrook.qbits.objectstorage.api.Capability; +import com.kingsrook.qbits.objectstorage.api.CapabilityStatus; +import com.kingsrook.qbits.objectstorage.api.PresignedPost; +import com.kingsrook.qbits.objectstorage.api.StorageException; +import com.kingsrook.qbits.objectstorage.config.AddressingStyle; +import com.kingsrook.qbits.objectstorage.config.ChecksumMode; +import com.kingsrook.qbits.objectstorage.config.ProviderType; +import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.services.s3.model.S3Exception; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Unit tests for the small internals: secret references, globs, the rate + ** limiter, provider defaults, error mapping and POST policy signing. + *******************************************************************************/ +class InternalsTest +{ + /******************************************************************************* + ** Only ${...} references matching the allow-list are accepted; a raw value is not. + *******************************************************************************/ + @Test + void testSecretRefAllowList() + { + List allowed = SecretRefs.DEFAULT_ALLOWED_PATTERNS; + assertThat(SecretRefs.isAllowed("${env.S3_SECRET_KEY}", allowed)).isTrue(); + assertThat(SecretRefs.isAllowed("${env.STORAGE_KEY}", allowed)).isTrue(); + assertThat(SecretRefs.isAllowed("${file./var/run/secrets/storage/key}", allowed)).isTrue(); + assertThat(SecretRefs.isAllowed("${env.AWS_SECRET_ACCESS_KEY}", allowed)).isFalse(); + assertThat(SecretRefs.isAllowed("${env.DATABASE_PASSWORD}", allowed)).isFalse(); + assertThat(SecretRefs.isAllowed("plain-literal-secret", allowed)).isFalse(); + assertThat(SecretRefs.isAllowed(null, allowed)).isTrue(); + assertThat(SecretRefs.isAllowed("", allowed)).isTrue(); + } + + + + /******************************************************************************* + ** References resolve from properties and mounted files; unset ones are null. + *******************************************************************************/ + @Test + void testSecretRefResolution(@TempDir Path dir) throws Exception + { + System.setProperty("os.unit.secret", "s3cr3t"); + assertThat(SecretRefs.resolve("${prop.os.unit.secret}")).isEqualTo("s3cr3t"); + assertThat(SecretRefs.resolve("${prop.os.unit.not.set}")).isNull(); + assertThat(SecretRefs.resolve(null)).isNull(); + assertThat(SecretRefs.resolveSetting("")).isNull(); + + Path file = dir.resolve("key"); + Files.writeString(file, " from-file\n"); + assertThat(SecretRefs.resolve("${file." + file + "}")).isEqualTo("from-file"); + assertThat(SecretRefs.resolve("${file." + dir.resolve("missing") + "}")).isNull(); + } + + + + /******************************************************************************* + ** Globs: * crosses slashes, ? is one character, the rest is literal. + *******************************************************************************/ + @Test + void testGlobs() + { + assertThat(Globs.matches("media/*", "media/42/hero.jpg")).isTrue(); + assertThat(Globs.matches("*.jpg", "a/b.jpg")).isTrue(); + assertThat(Globs.matches("a?c", "abc")).isTrue(); + assertThat(Globs.matches("a?c", "abbc")).isFalse(); + assertThat(Globs.matches("a.c", "abc")).isFalse(); + assertThat(Globs.matches("*", null)).isTrue(); + } + + + + /******************************************************************************* + ** The token bucket slows callers down and the permit releases. + *******************************************************************************/ + @Test + void testRateLimiter() + { + RateLimiter limiter = new RateLimiter(50, 2); + long start = System.nanoTime(); + for(int i = 0; i < 60; i++) + { + try(RateLimiter.Permit permit = limiter.acquire()) + { + assertThat(permit).isNotNull(); + } + } + assertThat(Duration.ofNanos(System.nanoTime() - start)).isGreaterThan(Duration.ofMillis(100)); + assertThat(limiter.getThrottledWaits()).isGreaterThan(0); + } + + + + /******************************************************************************* + ** Provider defaults: checksums, addressing, and the documented capability cells. + *******************************************************************************/ + @Test + void testProviderDefaults() + { + assertThat(new StorageConnectionConfig("a").withProviderType(ProviderType.AWS_S3).effectiveChecksumMode()).isEqualTo(ChecksumMode.WHEN_SUPPORTED); + for(ProviderType type : ProviderType.values()) + { + if(type != ProviderType.AWS_S3) + { + assertThat(new StorageConnectionConfig("a").withProviderType(type).effectiveChecksumMode()).as(type.name()).isEqualTo(ChecksumMode.WHEN_REQUIRED); + } + } + assertThat(new StorageConnectionConfig("a").withProviderType(ProviderType.MINIO).effectiveAddressingStyle()).isEqualTo(AddressingStyle.PATH); + assertThat(new StorageConnectionConfig("a").withProviderType(ProviderType.CLOUDFLARE_R2).effectiveAddressingStyle()).isEqualTo(AddressingStyle.VIRTUAL_HOST); + assertThat(new StorageConnectionConfig("a").withProviderType(ProviderType.GARAGE).withAddressingStyle(AddressingStyle.VIRTUAL_HOST).effectiveAddressingStyle()).isEqualTo(AddressingStyle.VIRTUAL_HOST); + + assertThat(ProviderDefaults.defaultStatus(ProviderType.GARAGE, Capability.VERSIONING)).isEqualTo(CapabilityStatus.UNSUPPORTED); + assertThat(ProviderDefaults.defaultStatus(ProviderType.CLOUDFLARE_R2, Capability.LIFECYCLE)).isEqualTo(CapabilityStatus.PARTIAL); + assertThat(ProviderDefaults.defaultStatus(ProviderType.AWS_S3, Capability.OBJECT_LOCK)).isEqualTo(CapabilityStatus.SUPPORTED); + assertThat(ProviderDefaults.defaultStatus(ProviderType.BACKBLAZE_B2, Capability.LIFECYCLE)).isEqualTo(CapabilityStatus.UNKNOWN); + assertThat(ProviderDefaults.defaultStatus(ProviderType.OTHER, Capability.PUT_GET_DELETE)).isEqualTo(CapabilityStatus.UNKNOWN); + assertThat(ProviderDefaults.defaultStatus(ProviderType.MINIO, Capability.PUT_GET_DELETE)).isEqualTo(CapabilityStatus.SUPPORTED); + assertThat(ProviderDefaults.defaultStatus(ProviderType.MINIO, Capability.USAGE_ADMIN_API)).isEqualTo(CapabilityStatus.UNSUPPORTED); + assertThat(ProviderDefaults.defaultStatus(ProviderType.OTHER, Capability.CORS)).isEqualTo(CapabilityStatus.UNKNOWN); + } + + + + /******************************************************************************* + ** SDK exceptions map to the stable reasons. + *******************************************************************************/ + @Test + void testErrorMapping() + { + assertThat(ErrorMapper.map(s3(404, "NoSuchKey"), "x").getReason()).isEqualTo(StorageException.Reason.NOT_FOUND); + assertThat(ErrorMapper.map(s3(403, "AccessDenied"), "x").getReason()).isEqualTo(StorageException.Reason.ACCESS_DENIED); + assertThat(ErrorMapper.map(s3(412, "PreconditionFailed"), "x").getReason()).isEqualTo(StorageException.Reason.PRECONDITION_FAILED); + assertThat(ErrorMapper.map(s3(503, "SlowDown"), "x").getReason()).isEqualTo(StorageException.Reason.THROTTLED); + assertThat(ErrorMapper.map(s3(501, "NotImplemented"), "x").getReason()).isEqualTo(StorageException.Reason.UNSUPPORTED); + assertThat(ErrorMapper.map(s3(500, "InternalError"), "x").getReason()).isEqualTo(StorageException.Reason.PROVIDER_ERROR); + assertThat(ErrorMapper.map(new IllegalStateException("boom"), "x").getReason()).isEqualTo(StorageException.Reason.PROVIDER_ERROR); + StorageException passthrough = new StorageException(StorageException.Reason.UNSUPPORTED, "u"); + assertThat(ErrorMapper.map(passthrough, "x")).isSameAs(passthrough); + assertThat(ErrorMapper.errorCode(new RuntimeException())).isNull(); + assertThat(ErrorMapper.map(s3(404, "NoSuchKey"), "head k").getRequestId()).isEqualTo("req-1"); + } + + + + /******************************************************************************* + ** An S3 exception with a status and code. + *******************************************************************************/ + private static S3Exception s3(int status, String code) + { + return ((S3Exception) S3Exception.builder().statusCode(status).requestId("req-1").awsErrorDetails(AwsErrorDetails.builder().errorCode(code).build()).message(code).build()); + } + + + + /******************************************************************************* + ** A POST policy: the signature verifies independently, the size range is in the + ** policy, and the form URL follows the addressing style. + *******************************************************************************/ + @Test + void testPostPolicySigning() throws Exception + { + Instant now = Instant.parse("2026-10-12T10:15:30Z"); + PresignedPost post = PostPolicySigner.sign("AKIDEXAMPLE", "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", "us-east-1", "http://minio.local:9000", AddressingStyle.PATH, + "uploads", "in/a \"q\".txt", "image/png", 1L, 5000L, now, Duration.ofMinutes(10)); + + assertThat(post.getUrl()).isEqualTo("http://minio.local:9000/uploads"); + assertThat(post.getFields()).containsEntry("key", "in/a \"q\".txt").containsEntry("bucket", "uploads").containsEntry("Content-Type", "image/png") + .containsEntry("x-amz-algorithm", "AWS4-HMAC-SHA256").containsEntry("x-amz-date", "20261012T101530Z") + .containsEntry("x-amz-credential", "AKIDEXAMPLE/20261012/us-east-1/s3/aws4_request"); + String policyJson = new String(java.util.Base64.getDecoder().decode(post.getFields().get("policy")), StandardCharsets.UTF_8); + assertThat(policyJson).contains("\"expiration\":\"2026-10-12T10:25:30Z\"").contains("[\"content-length-range\",1,5000]").contains("{\"bucket\":\"uploads\"}") + .contains("{\"key\":\"in/a \\\"q\\\".txt\"}"); + + byte[] k = hmac(hmac(hmac(hmac("AWS4wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".getBytes(StandardCharsets.UTF_8), "20261012"), "us-east-1"), "s3"), "aws4_request"); + assertThat(post.getFields().get("x-amz-signature")).isEqualTo(HexFormat.of().formatHex(hmac(k, post.getFields().get("policy")))); + + assertThat(PostPolicySigner.sign("a", "b", "eu-west-1", null, AddressingStyle.VIRTUAL_HOST, "bkt", "k", null, null, null, now, Duration.ofMinutes(1)).getUrl()) + .isEqualTo("https://bkt.s3.eu-west-1.amazonaws.com/"); + assertThat(PostPolicySigner.sign("a", "b", "r", "https://s3.example.com", AddressingStyle.VIRTUAL_HOST, "bkt", "k", null, null, null, now, Duration.ofMinutes(1)).getUrl()) + .isEqualTo("https://bkt.s3.example.com/"); + assertThatThrownBy(() -> PostPolicySigner.sign(null, null, "r", null, AddressingStyle.PATH, "b", "k", null, null, null, now, Duration.ofMinutes(1))) + .isInstanceOf(StorageException.class); + } + + + + /******************************************************************************* + ** HMAC-SHA256. + *******************************************************************************/ + private static byte[] hmac(byte[] key, String data) throws Exception + { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return (mac.doFinal(data.getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/internal/TablesAndConfigTest.java b/src/test/java/com/kingsrook/qbits/objectstorage/internal/TablesAndConfigTest.java new file mode 100644 index 0000000..dafb2bd --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/objectstorage/internal/TablesAndConfigTest.java @@ -0,0 +1,362 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.internal; + + +import java.util.List; +import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig; +import com.kingsrook.qbits.objectstorage.api.ObjectHeaders; +import com.kingsrook.qbits.objectstorage.api.StorageCaller; +import com.kingsrook.qbits.objectstorage.config.BucketRole; +import com.kingsrook.qbits.objectstorage.config.ProviderType; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Tests of the config validation, the table naming rules, the config upsert, + ** header policy, public URL resolution and protection rules (no store needed). + *******************************************************************************/ +class TablesAndConfigTest +{ + /******************************************************************************* + ** Tear down. + *******************************************************************************/ + @AfterEach + void tearDown() + { + StorageTestSupport.tearDown(); + } + + + + /******************************************************************************* + ** A config with a connection and buckets that never touches a real store. + *******************************************************************************/ + private static ObjectStorageQBitConfig config() + { + System.setProperty("os.unit.ak", "ak"); + System.setProperty("os.unit.sk", "sk"); + return (new ObjectStorageQBitConfig().withBackendName(StorageTestSupport.MEMORY).withBucketScoping(false).withOpenWhenAnonymous(true).withAdminApp(true) + .withAllowedSecretRefPatterns(List.of("prop.os.unit.*")) + .withConnection(new StorageConnectionConfig("main").withProviderType(ProviderType.MINIO).withEndpoint("http://localhost:1").withRegion("us-east-1") + .withAccessKeyRef("${prop.os.unit.ak}").withSecretKeyRef("${prop.os.unit.sk}").withPresignEndpoint("https://files.example.com")) + .withBucket(new StorageBucketConfig("media").withConnection("main").withBucketName("media-bucket").withRole(BucketRole.PUBLIC).withPublicBaseUrl("https://cdn.example.com/media/").withReferenceTracked(true)) + .withBucket(new StorageBucketConfig("backups").withConnection("main").withBucketName("backup-bucket").withRole(BucketRole.BACKUP).withIndexed(false)) + .withBucket(new StorageBucketConfig("incoming").withConnection("main").withBucketName("incoming-bucket").withRole(BucketRole.INCOMING).withIndexed(false))); + } + + + + /******************************************************************************* + ** Validation: names, unknown connections, BACKUP is always private, trash is + ** never public, and secret references must match the allow-list. + *******************************************************************************/ + @Test + void testValidation() + { + assertThat(errors(config())).isEmpty(); + + assertThat(errors(config().withBucket(new StorageBucketConfig("media").withConnection("main").withBucketName("x")))).anyMatch(e -> e.contains("Duplicate storage bucket alias")); + assertThat(errors(config().withBucket(new StorageBucketConfig("orphan").withConnection("nope").withBucketName("x")))).anyMatch(e -> e.contains("unknown connection")); + assertThat(errors(config().withBucket(new StorageBucketConfig("nobucket").withConnection("main")))).anyMatch(e -> e.contains("needs a bucketName")); + assertThat(errors(config().withConnection(new StorageConnectionConfig("main")))).anyMatch(e -> e.contains("Duplicate storage connection")); + assertThat(errors(config().withConnection(new StorageConnectionConfig("")))).anyMatch(e -> e.contains("needs a name")); + assertThat(errors(config().withBucket(new StorageBucketConfig("")))).anyMatch(e -> e.contains("needs an alias")); + assertThat(errors(config().withBackendName(null))).anyMatch(e -> e.contains("backendName")); + + assertThat(errors(config().withBucket(new StorageBucketConfig("bad-backup").withConnection("main").withBucketName("b").withRole(BucketRole.BACKUP).withPublicBaseUrl("https://x")))) + .anyMatch(e -> e.contains("BACKUP") && e.contains("public base URL")); + + assertThat(errors(config().withBucket(new StorageBucketConfig("pub-trash").withConnection("main").withBucketName("b").withRole(BucketRole.PUBLIC).withTrashEnabled(true)))) + .anyMatch(e -> e.contains("trash location must be a private bucket")); + assertThat(errors(config().withBucket(new StorageBucketConfig("trash-to-unknown").withConnection("main").withBucketName("b").withTrashEnabled(true).withTrashBucket("ghost")))) + .anyMatch(e -> e.contains("unknown trash bucket")); + assertThat(errors(config().withBucket(new StorageBucketConfig("ok-trash").withConnection("main").withBucketName("b").withTrashEnabled(true).withTrashBucket("backups")))).isEmpty(); + assertThat(errors(config().withBucket(new StorageBucketConfig("trash-to-public").withConnection("main").withBucketName("b").withTrashEnabled(true).withTrashBucket("media")))) + .anyMatch(e -> e.contains("trash location must be a private bucket")); + + assertThat(errors(config().withConnection(new StorageConnectionConfig("leaky").withSecretKeyRef("${env.DATABASE_PASSWORD}")))) + .anyMatch(e -> e.contains("not an allowed reference")); + assertThat(errors(config().withConnection(new StorageConnectionConfig("literal").withSecretKeyRef("plain-value")))).anyMatch(e -> e.contains("not an allowed reference")); + assertThat(errors(config().withConnection(new StorageConnectionConfig("insecure").withEndpoint("https://store.example.com").withTlsVerify(false)))) + .anyMatch(e -> e.contains("tlsVerify=false")); + assertThat(errors(config().withConnection(new StorageConnectionConfig("devhttp").withEndpoint("http://localhost:9000").withTlsVerify(false)))).isEmpty(); + } + + + + /******************************************************************************* + ** The validation errors of a config. + *******************************************************************************/ + private static List errors(ObjectStorageQBitConfig config) + { + List errors = new java.util.ArrayList<>(); + config.validate(new QInstance(), errors); + return (errors); + } + + + + /******************************************************************************* + ** Owner naming rules: every table is lowercase snake_case with the full qbit + ** name as prefix; no table has a field that could hold a secret value; the nav + ** lists only the main tables and the app has a home widget. + *******************************************************************************/ + @Test + void testNamingAndNoSecretValues() throws Exception + { + QInstance instance = StorageTestSupport.setUp(config()); + for(String name : ObjectStorageTables.ALL) + { + QTableMetaData table = instance.getTable(name); + assertThat(table).as(name).isNotNull(); + assertThat(name).matches("^object_storage_[a-z]+(_[a-z]+)*$"); + assertThat(table.getBackendName()).isEqualTo(StorageTestSupport.MEMORY); + for(QFieldMetaData field : table.getFields().values()) + { + String lower = field.getName().toLowerCase(); + boolean secretish = lower.contains("secret") || lower.contains("password") || lower.contains("token"); + assertThat(!secretish || lower.endsWith("ref")).as(name + "." + field.getName() + " must be a reference, not a value").isTrue(); + } + } + assertThat(instance.getApp("objectStorageAdmin").getChildren()).extracting(c -> c.getName()).containsExactlyInAnyOrderElementsOf(ObjectStorageTables.NAV_TABLES); + assertThat(instance.getApp("objectStorageAdmin").getWidgets()).containsExactly("objectStorageHome"); + assertThat(instance.getProcesses().keySet()).allMatch(n -> n.startsWith("objectStorage") || n.matches("^object_storage_[a-z_]+[.]bulk[A-Za-z]+$")); + assertThat(instance.getApp("objectStorageAdmin").getSections().stream().flatMap(s -> s.getProcesses() == null ? java.util.stream.Stream.empty() : s.getProcesses().stream()).toList()) + .as("processes are never in the nav").isEmpty(); + assertThat(instance.getTable(ObjectStorageTables.BUCKET).getExposedJoins()).extracting(j -> j.getJoinTable()).contains(ObjectStorageTables.OBJECT, ObjectStorageTables.BUCKET_CAPABILITY); + } + + + + /******************************************************************************* + ** Without the admin app a headless consumer still gets the tables and no app. + *******************************************************************************/ + @Test + void testHeadlessHasNoApp() throws Exception + { + QInstance instance = StorageTestSupport.setUp(config().withAdminApp(false)); + assertThat(instance.getApp("objectStorageAdmin")).isNull(); + assertThat(instance.getTable(ObjectStorageTables.OBJECT)).isNotNull(); + } + + + + /******************************************************************************* + ** A parent app name places the Storage app under Admin. + *******************************************************************************/ + @Test + void testParentApp() throws Exception + { + QInstance instance = new QInstance(); + instance.addApp(new com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData().withName("admin")); + ObjectStorageQBitConfig config = config().withParentAppName("admin"); + new com.kingsrook.qbits.objectstorage.ObjectStorageQBitProducer().withQBitConfig(config).produce(instance); + assertThat(instance.getApp("objectStorageAdmin").getParentAppName()).isEqualTo("admin"); + assertThat(instance.getApp("admin").getChildren()).extracting(c -> c.getName()).contains("objectStorageAdmin"); + assertThat(com.kingsrook.qbits.objectstorage.app.StorageWidgets.tablePathPrefix()).isEqualTo("/admin/objectStorageAdmin"); + } + + + + /******************************************************************************* + ** Config is upserted into visible rows; identity comes from code, operational + ** fields belong to the row after the first sync; no secret value is stored. + *******************************************************************************/ + @Test + void testConfigUpsert() throws Exception + { + StorageTestSupport.setUp(config()); + QRecord connection = Db.first(ObjectStorageTables.CONNECTION, "name", "main"); + assertThat(connection.getValueString("source")).isEqualTo("CONFIG"); + assertThat(connection.getValueString("providerType")).isEqualTo("MINIO"); + assertThat(connection.getValueString("checksumMode")).isEqualTo("WHEN_REQUIRED"); + assertThat(connection.getValueString("addressingStyle")).isEqualTo("PATH"); + assertThat(connection.getValueBoolean("credentialsResolved")).isTrue(); + assertThat(connection.getValueBoolean("presignKeyConfigured")).isFalse(); + assertThat(connection.getValueString("presignWarning")).contains("least-privilege"); + assertThat(connection.getValueString("secretKeyRef")).isEqualTo("${prop.os.unit.sk}"); + assertThat(String.valueOf(connection.getValues())).doesNotContain("\"sk\"").doesNotContain("=sk,"); + + assertThat(Db.query(ObjectStorageTables.BUCKET).stream().map(r -> r.getValueString("alias")).toList()).containsExactlyInAnyOrder("media", "backups", "incoming"); + QRecord media = Db.first(ObjectStorageTables.BUCKET, "alias", "media"); + assertThat(media.getValueString("role")).isEqualTo("PUBLIC"); + assertThat(media.getValueString("managedBy")).isEqualTo("IAC"); + assertThat(media.getValueBoolean("referenceTracked")).isTrue(); + assertThat(media.getValueInteger("connectionId")).isEqualTo(connection.getValueInteger("id")); + assertThat(ConfigSync.bucketId("media")).isEqualTo(media.getValueInteger("id")); + assertThat(ConfigSync.aliasOf(media.getValueInteger("id"))).isEqualTo("media"); + assertThat(ConfigSync.connectionId("main")).isEqualTo(connection.getValueInteger("id")); + + // an operational edit survives the next sync; identity is rewritten from code + Db.update(ObjectStorageTables.CONNECTION, List.of(new QRecord().withValue("id", connection.getValue("id")).withValue("maxOpsPerSecond", 7).withValue("endpoint", "http://tampered"))); + ConfigSync.sync(); + QRecord after = Db.first(ObjectStorageTables.CONNECTION, "name", "main"); + assertThat(after.getValueInteger("maxOpsPerSecond")).isEqualTo(7); + assertThat(after.getValueString("endpoint")).isEqualTo("http://localhost:1"); + assertThat(Db.query(ObjectStorageTables.CONNECTION)).hasSize(1); + assertThat(ConfigSync.withOverrides(new StorageConnectionConfig("main")).getMaxOpsPerSecond()).isEqualTo(7); + assertThat(ConfigSync.bucketId("nope")).isNull(); + } + + + + /******************************************************************************* + ** The default header policy: first matching enabled rule by priority; BACKUP + ** buckets are never touched; rules can be scoped to a bucket. + *******************************************************************************/ + @Test + void testHeaderPolicy() throws Exception + { + ObjectStorageQBitConfig config = config(); + StorageTestSupport.setUp(config); + Integer mediaId = ConfigSync.bucketId("media"); + StorageTestSupport.insert(ObjectStorageTables.HEADER_RULE, + new QRecord().withValue("name", "hashed").withValue("keyRegex", ".*/[0-9a-f]{64}/.*").withValue("cacheControl", "public, max-age=31536000, immutable").withValue("priority", 10).withValue("enabled", true), + new QRecord().withValue("name", "images").withValue("contentTypePattern", "image/*").withValue("cacheControl", "public, max-age=3600").withValue("priority", 50).withValue("enabled", true), + new QRecord().withValue("name", "pdf").withValue("keyGlob", "*.pdf").withValue("cacheControl", "no-cache").withValue("contentDisposition", "inline").withValue("priority", 60).withValue("bucketId", mediaId).withValue("enabled", true), + new QRecord().withValue("name", "off").withValue("keyGlob", "*").withValue("cacheControl", "never").withValue("priority", 1).withValue("enabled", false)); + DefaultObjectHeaderPolicy.clearCache(); + + DefaultObjectHeaderPolicy policy = new DefaultObjectHeaderPolicy(); + StorageBucketConfig media = config.findBucket("media"); + String sha = "a".repeat(64); + assertThat(policy.headersFor(media, "media/42/" + sha + "/x.jpg", "image/jpeg").getCacheControl()).isEqualTo("public, max-age=31536000, immutable"); + assertThat(policy.headersFor(media, "media/42/x.jpg", "image/jpeg").getCacheControl()).isEqualTo("public, max-age=3600"); + ObjectHeaders pdf = policy.headersFor(media, "docs/a.pdf", "application/pdf"); + assertThat(pdf.getCacheControl()).isEqualTo("no-cache"); + assertThat(pdf.getContentDisposition()).isEqualTo("inline"); + assertThat(policy.headersFor(config.findBucket("incoming"), "docs/a.pdf", "application/pdf").isEmpty()).as("the pdf rule is scoped to the media bucket").isTrue(); + assertThat(policy.headersFor(media, "other.bin", "application/octet-stream").isEmpty()).isTrue(); + assertThat(policy.headersFor(config.findBucket("backups"), "media/42/x.jpg", "image/jpeg").isEmpty()).as("BACKUP buckets are excluded from the policy").isTrue(); + } + + + + /******************************************************************************* + ** Public URLs: base + encoded key, and parsed back to the bucket and key. + *******************************************************************************/ + @Test + void testPublicUrls() throws Exception + { + StorageTestSupport.setUp(config()); + DefaultPublicUrlResolver resolver = new DefaultPublicUrlResolver(); + StorageBucketConfig media = ObjectStorageRuntime.bucketConfig("media"); + String url = resolver.resolve(media, "photos/café one+two.jpg"); + assertThat(url).isEqualTo("https://cdn.example.com/media/photos/caf%C3%A9%20one%2Btwo.jpg"); + assertThat(resolver.parse(url + "?v=2")).hasValueSatisfying(bk -> + { + assertThat(bk.getBucketAlias()).isEqualTo("media"); + assertThat(bk.getKey()).isEqualTo("photos/café one+two.jpg"); + }); + assertThat(resolver.parse("https://elsewhere.example.com/x")).isEmpty(); + assertThat(resolver.parse(null)).isEmpty(); + assertThat(resolver.resolve(ObjectStorageRuntime.bucketConfig("backups"), "x")).isNull(); + } + + + + /******************************************************************************* + ** Protection rules: READ_ONLY blocks every write, NO_DELETE blocks deletes + ** only, WARN only notes it, and a rule's allowedSystemCallers lets that + ** caller's system-context delete through. + *******************************************************************************/ + @Test + void testProtectionRules() throws Exception + { + StorageTestSupport.setUp(config()); + Integer mediaId = ConfigSync.bucketId("media"); + StorageTestSupport.insert(ObjectStorageTables.PROTECTION_RULE, + new QRecord().withValue("bucketId", mediaId).withValue("prefixGlob", "legal/*").withValue("mode", "READ_ONLY").withValue("reason", "retention").withValue("enabled", true), + new QRecord().withValue("bucketId", mediaId).withValue("prefixGlob", "keep/*").withValue("mode", "NO_DELETE").withValue("allowedSystemCallers", "backups, other").withValue("enabled", true), + new QRecord().withValue("bucketId", mediaId).withValue("prefixGlob", "careful/*").withValue("mode", "WARN").withValue("reason", "be careful").withValue("enabled", true), + new QRecord().withValue("bucketId", mediaId).withValue("prefixGlob", "off/*").withValue("mode", "READ_ONLY").withValue("enabled", false)); + + StorageCaller user = StorageCaller.user(); + StorageCaller backups = StorageCaller.system("backups", "run-9"); + StorageCaller stranger = StorageCaller.system("stranger", null); + + ProtectionRules.Result legal = ProtectionRules.check("media", "legal/contract.pdf", ProtectionRules.Op.WRITE, user); + assertThat(legal.blocked()).isTrue(); + assertThat(legal.reason()).contains("retention"); + assertThat(ProtectionRules.check("media", "legal/contract.pdf", ProtectionRules.Op.DELETE, backups).blocked()).as("READ_ONLY has no system exception").isTrue(); + + assertThat(ProtectionRules.check("media", "keep/a", ProtectionRules.Op.WRITE, user).blocked()).as("NO_DELETE allows writes").isFalse(); + assertThat(ProtectionRules.check("media", "keep/a", ProtectionRules.Op.DELETE, user).blocked()).isTrue(); + assertThat(ProtectionRules.check("media", "keep/a", ProtectionRules.Op.DELETE, stranger).blocked()).isTrue(); + assertThat(ProtectionRules.check("media", "keep/a", ProtectionRules.Op.DELETE, backups).blocked()).as("allowedSystemCallers names backups").isFalse(); + + ProtectionRules.Result warn = ProtectionRules.check("media", "careful/a", ProtectionRules.Op.DELETE, user); + assertThat(warn.blocked()).isFalse(); + assertThat(warn.warned()).isTrue(); + assertThat(ProtectionRules.check("media", "off/a", ProtectionRules.Op.WRITE, user).blocked()).isFalse(); + assertThat(ProtectionRules.check("incoming", "legal/x", ProtectionRules.Op.WRITE, user).blocked()).as("rules are per bucket").isFalse(); + } + + + + /******************************************************************************* + ** Per-bucket scoping: a user without the bucket's key sees none of its rows. + *******************************************************************************/ + @Test + void testBucketScoping() throws Exception + { + StorageTestSupport.setUp(config().withBucketScoping(true)); + Integer mediaId = ConfigSync.bucketId("media"); + Integer incomingId = ConfigSync.bucketId("incoming"); + Db.asSystem(() -> Db.insert(ObjectStorageTables.OPERATION, List.of(new QRecord().withValue("bucketId", mediaId).withValue("action", "PUT").withValue("bucketAlias", "media"), + new QRecord().withValue("bucketId", incomingId).withValue("action", "PUT").withValue("bucketAlias", "incoming")))); + + QSession none = new QSession(); + QContext.setQSession(none); + assertThat(new QueryAction().execute(new QueryInput(ObjectStorageTables.OPERATION)).getRecords()).as("no key, no rows").isEmpty(); + assertThat(new QueryAction().execute(new QueryInput(ObjectStorageTables.BUCKET)).getRecords()).isEmpty(); + + QSession mediaOnly = new QSession().withSecurityKeyValue(ObjectStorageTables.KEY_TYPE_BUCKET, mediaId); + QContext.setQSession(mediaOnly); + assertThat(new QueryAction().execute(new QueryInput(ObjectStorageTables.OPERATION)).getRecords()).extracting(r -> r.getValueString("bucketAlias")).containsExactly("media"); + assertThat(new QueryAction().execute(new QueryInput(ObjectStorageTables.BUCKET)).getRecords()).extracting(r -> r.getValueString("alias")).containsExactly("media"); + + QSession all = new QSession().withSecurityKeyValue(ObjectStorageTables.ALL_ACCESS_BUCKET, true).withSecurityKeyValue(ObjectStorageTables.ALL_ACCESS_CONN, true); + QContext.setQSession(all); + assertThat(new QueryAction().execute(new QueryInput(ObjectStorageTables.OPERATION)).getRecords()).hasSize(2); + + // the audit log still writes for a scoped user (the service writes as the system user) + QContext.setQSession(mediaOnly); + new OperationLog.Entry(StorageCaller.user(), "PUT", "incoming").key("k").write(); + QContext.setQSession(all); + assertThat(new QueryAction().execute(new QueryInput(ObjectStorageTables.OPERATION)).getRecords()).hasSize(3); + } +} diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/processes/MinioProcessTest.java b/src/test/java/com/kingsrook/qbits/objectstorage/processes/MinioProcessTest.java new file mode 100644 index 0000000..29e2865 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/objectstorage/processes/MinioProcessTest.java @@ -0,0 +1,639 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.processes; + + +import java.io.ByteArrayInputStream; +import java.io.Serializable; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import com.kingsrook.qbits.objectstorage.api.DeleteRequest; +import com.kingsrook.qbits.objectstorage.api.FieldReferenceProvider; +import com.kingsrook.qbits.objectstorage.api.ObjectHeaders; +import com.kingsrook.qbits.objectstorage.api.ObjectStorage; +import com.kingsrook.qbits.objectstorage.api.PutRequest; +import com.kingsrook.qbits.objectstorage.conformance.ConformanceTarget; +import com.kingsrook.qbits.objectstorage.conformance.Targets; +import com.kingsrook.qbits.objectstorage.config.ChecksumMode; +import com.kingsrook.qbits.objectstorage.index.ObjectIndex; +import com.kingsrook.qbits.objectstorage.index.ReferenceScan; +import com.kingsrook.qbits.objectstorage.internal.ConfigSync; +import com.kingsrook.qbits.objectstorage.internal.DefaultObjectHeaderPolicy; +import com.kingsrook.qbits.objectstorage.internal.Db; +import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables; +import com.kingsrook.qbits.objectstorage.testsupport.ProcessRunner; +import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The record and table actions, run the way the UI runs them (Review, then + ** Result) against MinIO: what the Review promises is what the Result does, and + ** the Review changes nothing. + *******************************************************************************/ +class MinioProcessTest +{ + private ConformanceTarget target; + + + + /******************************************************************************* + ** Install the qbit with an empty, scanned index bucket (no anonymous admin). + *******************************************************************************/ + @BeforeEach + void setUp() throws Exception + { + Assumptions.assumeTrue(Targets.dockerAvailable(), "Docker is not available"); + target = Targets.minio(); + for(String bucket : new String[] { target.indexBucket, target.versionedBucket, target.trashBucket, target.bucket }) + { + empty(bucket); + } + reinstall(c -> + { + }); + } + + + + /******************************************************************************* + ** Tear down. + *******************************************************************************/ + @AfterEach + void tearDown() + { + UploadProcess.setOpener(null); + StorageTestSupport.tearDown(); + } + + + + /******************************************************************************* + ** (Re)install the qbit, with a media table, as a user holding all permissions. + *******************************************************************************/ + private void reinstall(java.util.function.Consumer customize) throws Exception + { + com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig config = StorageTestSupport.config(target, ChecksumMode.WHEN_REQUIRED) + .withOpenWhenAnonymous(false).withReferenceProvider(FieldReferenceProvider.key("indexed", "media", "storageKey")); + customize.accept(config); + StorageTestSupport.setUp(config, instance -> instance.addTable(new QTableMetaData().withName("media").withBackendName(StorageTestSupport.MEMORY).withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("storageKey", QFieldType.STRING)))); + as("object_storage.view", "object_storage.write", "object_storage.delete", "object_storage.admin"); + } + + + + /******************************************************************************* + ** Act as a user with exactly these permissions. + *******************************************************************************/ + private void as(String... permissions) + { + QContext.setQSession(new QSession().withUser(new QUser().withIdReference("operator@example.com")).withPermissions(Set.of(permissions))); + } + + + + /******************************************************************************* + ** Empty a bucket with the raw client. + *******************************************************************************/ + private void empty(String bucket) + { + try(S3Client raw = target.rawClient()) + { + raw.listObjectVersionsPaginator(b -> b.bucket(bucket)).forEach(page -> + { + page.versions().forEach(v -> raw.deleteObject(DeleteObjectRequest.builder().bucket(bucket).key(v.key()).versionId(v.versionId()).build())); + page.deleteMarkers().forEach(m -> raw.deleteObject(DeleteObjectRequest.builder().bucket(bucket).key(m.key()).versionId(m.versionId()).build())); + }); + raw.listObjectsV2Paginator(b -> b.bucket(bucket)).forEach(page -> page.contents().forEach(o -> raw.deleteObject(DeleteObjectRequest.builder().bucket(bucket).key(o.key()).build()))); + } + } + + + + /******************************************************************************* + ** Put with the raw client (outside QQQ) and scan, returning the rows by key. + *******************************************************************************/ + private Map seed(String alias, String bucket, Map keysAndSizes) throws Exception + { + try(S3Client raw = target.rawClient()) + { + keysAndSizes.forEach((k, size) -> raw.putObject(PutObjectRequest.builder().bucket(bucket).key(k).contentType("text/plain").build(), RequestBody.fromBytes(new byte[size]))); + } + ObjectIndex.scan(alias); + ObjectIndex.headBacklog(alias, 1000); + return (rows(alias)); + } + + + + /******************************************************************************* + ** Object rows by key. + *******************************************************************************/ + private Map rows(String alias) throws Exception + { + Map map = new java.util.TreeMap<>(); + for(QRecord r : Db.query(ObjectStorageTables.OBJECT, "bucketId", ConfigSync.bucketId(alias))) + { + map.put(r.getValueString("objectKey"), r); + } + return (map); + } + + + + /******************************************************************************* + ** Comma-separated ids of the rows of some keys. + *******************************************************************************/ + private static String ids(Map rows, String... keys) + { + return (java.util.Arrays.stream(keys).map(k -> String.valueOf(rows.get(k).getValue("id"))).collect(Collectors.joining(","))); + } + + + + /******************************************************************************* + ** One line of a screen containing some text (fails the test with all lines). + *******************************************************************************/ + private static void assertHas(List lines, String text) + { + assertThat(lines).as("lines: " + lines).anyMatch(l -> l.contains(text)); + } + + + + @Test + void everyActionIsATableOrRecordActionWithAPlainLabelAndAPermission() throws Exception + { + Set labels = new java.util.HashSet<>(); + for(QProcessMetaData process : QContext.getQInstance().getProcesses().values()) + { + if(!process.getName().startsWith("objectStorage")) + { + continue; + } + if(!Boolean.TRUE.equals(process.getIsHidden())) + { + assertThat(process.getTableName()).as(process.getName()).isNotNull(); + assertThat(process.getLabel()).as(process.getName()).doesNotContain("ETL").doesNotContain("Process").doesNotContain("Run "); + labels.add(process.getLabel()); + } + assertThat(com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime.processPermissions()).as(process.getName()).containsKey(process.getName()); + } + assertThat(labels).contains("Test connection", "Rescan bucket", "Check configuration", "Refresh from bucket", "Apply header policy", "Change headers and metadata", "Copy objects", + "Move or rename objects", "Move or rename folder", "Delete objects", "Delete folder contents", "Restore objects", "Edit tags", "Create download link", "Create upload link", "Upload files"); + assertThat(QContext.getQInstance().getApp("objectStorageAdmin").getSections().stream().flatMap(s -> s.getProcesses() == null ? java.util.stream.Stream.empty() : s.getProcesses().stream()).toList()).isEmpty(); + } + + + + @Test + void refreshFromBucketReadsObjectsAndMarksVanishedOnesDeleted() throws Exception + { + Map rows = seed("indexed", target.indexBucket, Map.of("r/a.txt", 3, "r/b.txt", 4)); + try(S3Client raw = target.rawClient()) + { + raw.putObject(PutObjectRequest.builder().bucket(target.indexBucket).key("r/a.txt").cacheControl("max-age=5").build(), RequestBody.fromBytes(new byte[9])); + raw.deleteObject(DeleteObjectRequest.builder().bucket(target.indexBucket).key("r/b.txt").build()); + } + List review = ProcessRunner.review(ObjectWriteProcesses.REFRESH_NAME, ids(rows, "r/a.txt", "r/b.txt")); + assertHas(review, "2 objects will be read from the bucket"); + assertThat(rows("indexed").get("r/a.txt").getValueLong("size")).as("review changes nothing").isEqualTo(3); + + ProcessRunner.Run run = ProcessRunner.run(ObjectWriteProcesses.REFRESH_NAME, ids(rows, "r/a.txt", "r/b.txt"), null); + assertHas(run.result(), "1 object was refreshed from the bucket"); + assertHas(run.result(), "1 object no longer exists and was marked deleted"); + assertThat(rows("indexed").get("r/a.txt").getValueLong("size")).isEqualTo(9); + assertThat(rows("indexed").get("r/a.txt").getValueString("cacheControl")).isEqualTo("max-age=5"); + assertThat(rows("indexed").get("r/b.txt").getValueBoolean("deleted")).isTrue(); + } + + + + @Test + void applyHeaderPolicyReviewsThenRewritesOnlyWhatIsOff() throws Exception + { + StorageTestSupport.insert(ObjectStorageTables.HEADER_RULE, + new QRecord().withValue("name", "text").withValue("contentTypePattern", "text/*").withValue("cacheControl", "public, max-age=300").withValue("priority", 1).withValue("enabled", true)); + DefaultObjectHeaderPolicy.clearCache(); + Map rows = seed("indexed", target.indexBucket, Map.of("p/a.txt", 1, "p/b.txt", 1, "p/c.txt", 1)); + ObjectStorage.bucket("indexed").updateHeaders("p/c.txt", ObjectHeaders.cacheControl("public, max-age=300")); + rows = rows("indexed"); + + String all = ids(rows, "p/a.txt", "p/b.txt", "p/c.txt"); + List review = ProcessRunner.review(ObjectWriteProcesses.POLICY_NAME, all); + assertHas(review, "2 objects will change to match the policy"); + assertHas(review, "1 object already matches the policy"); + assertHas(review, "changes Last-Modified"); + + ProcessRunner.Run run = ProcessRunner.run(ObjectWriteProcesses.POLICY_NAME, all, null); + assertHas(run.result(), "2 objects were changed to match the policy"); + for(String key : List.of("p/a.txt", "p/b.txt", "p/c.txt")) + { + assertThat(ObjectStorage.bucket("indexed").head(key).orElseThrow().getHeaders().getCacheControl()).isEqualTo("public, max-age=300"); + } + assertThat(ProcessRunner.review(ObjectWriteProcesses.POLICY_NAME, all)).as("run it again: nothing to do").anyMatch(l -> l.contains("3 objects already match the policy")); + } + + + + @Test + void changeHeadersShowsBeforeAndAfterAndMergesMetadata() throws Exception + { + Map rows = seed("indexed", target.indexBucket, Map.of("h/a.txt", 1, "h/b.txt", 1)); + ObjectStorage.bucket("indexed").updateHeaders("h/a.txt", new ObjectHeaders().withUserMetadata(Map.of("keep", "1", "drop", "2")).withCacheControl("old")); + rows = rows("indexed"); + String both = ids(rows, "h/a.txt", "h/b.txt"); + + Map none = new HashMap<>(); + assertHas(ProcessRunner.review(ObjectWriteProcesses.HEADERS_NAME, both), "Nothing to change"); + + Map inputs = new HashMap<>(); + inputs.put(ObjectWriteProcesses.FIELD_CACHE_CONTROL, "public, max-age=10"); + inputs.put(ObjectWriteProcesses.FIELD_USER_METADATA, "added=yes\ndrop="); + ProcessRunner.Run run = ProcessRunnerWithInputs.run(ObjectWriteProcesses.HEADERS_NAME, both, inputs); + assertHas(run.review(), "2 objects will change"); + assertHas(run.review(), "h/a.txt: Cache-Control old -> public, max-age=10"); + assertHas(run.result(), "2 objects were changed"); + assertThat(ObjectStorage.bucket("indexed").head("h/a.txt").orElseThrow().getHeaders().getUserMetadata()).containsEntry("keep", "1").containsEntry("added", "yes").doesNotContainKey("drop"); + assertThat(ObjectStorage.bucket("indexed").head("h/b.txt").orElseThrow().getHeaders().getCacheControl()).isEqualTo("public, max-age=10"); + assertThat(none).isEmpty(); + + Map clear = new HashMap<>(); + clear.put(ObjectWriteProcesses.FIELD_CACHE_CONTROL, "-"); + ProcessRunnerWithInputs.run(ObjectWriteProcesses.HEADERS_NAME, ids(rows("indexed"), "h/b.txt"), clear); + assertThat(ObjectStorage.bucket("indexed").head("h/b.txt").orElseThrow().getHeaders().getCacheControl()).isNull(); + + Map bad = new HashMap<>(); + bad.put(ObjectWriteProcesses.FIELD_USER_METADATA, "no equals sign"); + assertHas(ProcessRunnerWithInputs.run(ObjectWriteProcesses.HEADERS_NAME, both, bad).review(), "The metadata is not valid"); + } + + + + @Test + void copyHonoursOverwriteAndDestinationChoices() throws Exception + { + Map rows = seed("indexed", target.indexBucket, Map.of("c/one.txt", 1, "c/two.txt", 2, "dst/one.txt", 9)); + String both = ids(rows, "c/one.txt", "c/two.txt"); + + Map inputs = new HashMap<>(); + inputs.put(CopyMoveProcesses.FIELD_DEST_PREFIX, "dst"); + ProcessRunner.Run first = ProcessRunnerWithInputs.run(CopyMoveProcesses.COPY_NAME, both, inputs); + assertHas(first.review(), "1 object will be copied"); + assertHas(first.review(), "1 object is skipped: the destination already exists"); + assertHas(first.result(), "1 object was copied"); + assertThat(ObjectStorage.bucket("indexed").head("dst/two.txt")).isPresent(); + assertThat(ObjectStorage.bucket("indexed").head("dst/one.txt").orElseThrow().getSize()).as("not overwritten").isEqualTo(9); + assertThat(ObjectStorage.bucket("indexed").head("c/one.txt")).as("the original stays").isPresent(); + + inputs.put(CopyMoveProcesses.FIELD_OVERWRITE, true); + ProcessRunner.Run second = ProcessRunnerWithInputs.run(CopyMoveProcesses.COPY_NAME, both, inputs); + assertHas(second.review(), "2 objects will replace ones that already exist"); + assertThat(ObjectStorage.bucket("indexed").head("dst/one.txt").orElseThrow().getSize()).isEqualTo(1); + + Map keep = new HashMap<>(); + keep.put(CopyMoveProcesses.FIELD_DEST_PREFIX, "mirror"); + keep.put(CopyMoveProcesses.FIELD_KEEP_PATH, true); + keep.put(CopyMoveProcesses.FIELD_DEST_BUCKET, ConfigSync.bucketId("files")); + ProcessRunnerWithInputs.run(CopyMoveProcesses.COPY_NAME, both, keep); + assertThat(ObjectStorage.bucket("files").head("mirror/c/one.txt")).as("another bucket, whole path kept").isPresent(); + assertThat(Db.query(ObjectStorageTables.OPERATION, "action", "COPY")).hasSizeGreaterThanOrEqualTo(4); + } + + + + @Test + void moveRenamesOneObjectAndSkipsReferencedAndProtectedOnes() throws Exception + { + Map rows = seed("indexed", target.indexBucket, Map.of("m/solo.txt", 1, "m/ref.txt", 1, "m/held.txt", 1)); + StorageTestSupport.insert("media", new QRecord().withValue("storageKey", "m/ref.txt")); + ReferenceScan.run("indexed"); + StorageTestSupport.insert(ObjectStorageTables.PROTECTION_RULE, new QRecord().withValue("bucketId", ConfigSync.bucketId("indexed")).withValue("prefixGlob", "m/held*") + .withValue("mode", "NO_DELETE").withValue("enabled", true)); + rows = rows("indexed"); + + Map rename = new HashMap<>(); + rename.put(CopyMoveProcesses.FIELD_NEW_NAME, "renamed.txt"); + ProcessRunner.Run one = ProcessRunnerWithInputs.run(CopyMoveProcesses.MOVE_NAME, ids(rows, "m/solo.txt"), rename); + assertHas(one.review(), "1 object will be moved"); + assertHas(one.review(), "not one atomic step"); + assertHas(one.result(), "1 object was moved"); + assertThat(ObjectStorage.bucket("indexed").head("m/renamed.txt")).isPresent(); + assertThat(ObjectStorage.bucket("indexed").head("m/solo.txt")).isEmpty(); + + Map move = new HashMap<>(); + move.put(CopyMoveProcesses.FIELD_DEST_PREFIX, "moved"); + ProcessRunner.Run two = ProcessRunnerWithInputs.run(CopyMoveProcesses.MOVE_NAME, ids(rows, "m/ref.txt", "m/held.txt"), move); + assertHas(two.review(), "1 object is skipped: it is referenced"); + assertHas(two.review(), "1 object is skipped: it (or its destination) is protected"); + assertThat(ObjectStorage.bucket("indexed").head("m/ref.txt")).isPresent(); + assertThat(ObjectStorage.bucket("indexed").head("m/held.txt")).isPresent(); + assertThat(ObjectStorage.bucket("indexed").head("moved/ref.txt")).isEmpty(); + } + + + + @Test + void deleteIsSoftWhereItCanBeAndPermanentOnlyForAdmins() throws Exception + { + Map vrows = seed("versioned", target.versionedBucket, Map.of("d/soft.txt", 1)); + ProcessRunner.Run soft = ProcessRunner.run(DeleteRestoreProcesses.DELETE_NAME, ids(vrows, "d/soft.txt"), null); + assertHas(soft.review(), "1 object will be deleted (a delete marker is added; Restore undoes it)"); + assertHas(soft.result(), "1 object was deleted (a delete marker was added"); + assertThat(ObjectStorage.bucket("versioned").head("d/soft.txt")).isEmpty(); + assertThat(rows("versioned").get("d/soft.txt").getValueBoolean("deleted")).isTrue(); + + ProcessRunner.Run restore = ProcessRunner.run(DeleteRestoreProcesses.RESTORE_NAME, ids(rows("versioned"), "d/soft.txt"), null); + assertHas(restore.review(), "1 delete marker will be removed"); + assertHas(restore.result(), "1 object was restored"); + assertThat(ObjectStorage.bucket("versioned").head("d/soft.txt")).isPresent(); + assertHas(ProcessRunner.review(DeleteRestoreProcesses.RESTORE_NAME, ids(rows("versioned"), "d/soft.txt")), "1 object is not deleted (no change)"); + + Map rows = seed("indexed", target.indexBucket, Map.of("d/perm.txt", 1)); + as("object_storage.view", "object_storage.write", "object_storage.delete"); + ProcessRunner.Run denied = ProcessRunner.run(DeleteRestoreProcesses.DELETE_NAME, ids(rows, "d/perm.txt"), null); + assertHas(denied.review(), "1 object is skipped: this bucket has no versioning or trash"); + assertThat(ObjectStorage.bucket("indexed").head("d/perm.txt")).isPresent(); + + as("object_storage.view", "object_storage.write", "object_storage.delete", "object_storage.admin"); + ProcessRunner.Run admin = ProcessRunner.run(DeleteRestoreProcesses.DELETE_NAME, ids(rows, "d/perm.txt"), null); + assertHas(admin.review(), "1 object will be deleted permanently"); + assertHas(admin.result(), "1 object was deleted permanently"); + assertThat(ObjectStorage.bucket("indexed").head("d/perm.txt")).isEmpty(); + } + + + + @Test + void deleteSkipsReferencedProtectedAndLockedObjectsAndSaysWhy() throws Exception + { + Map rows = seed("indexed", target.indexBucket, Map.of("x/ref.txt", 1, "x/prot.txt", 1, "x/free.txt", 1)); + StorageTestSupport.insert("media", new QRecord().withValue("storageKey", "x/ref.txt")); + ReferenceScan.run("indexed"); + StorageTestSupport.insert(ObjectStorageTables.PROTECTION_RULE, new QRecord().withValue("bucketId", ConfigSync.bucketId("indexed")).withValue("prefixGlob", "x/prot*") + .withValue("mode", "NO_DELETE").withValue("reason", "legal hold").withValue("enabled", true)); + rows = rows("indexed"); + + ProcessRunner.Run run = ProcessRunner.run(DeleteRestoreProcesses.DELETE_NAME, ids(rows, "x/ref.txt", "x/prot.txt", "x/free.txt"), null); + assertHas(run.review(), "1 object is skipped: it is referenced"); + assertHas(run.review(), "1 object is skipped: it is protected"); + assertHas(run.review(), "1 object will be deleted permanently"); + assertHas(run.result(), "1 object was deleted permanently"); + assertThat(ObjectStorage.bucket("indexed").head("x/ref.txt")).isPresent(); + assertThat(ObjectStorage.bucket("indexed").head("x/prot.txt")).isPresent(); + assertThat(ObjectStorage.bucket("indexed").head("x/free.txt")).isEmpty(); + } + + + + @Test + void editTagsSetsTagsWhereTheProviderSupportsThem() throws Exception + { + Map rows = seed("indexed", target.indexBucket, Map.of("t/a.txt", 1)); + Map inputs = new HashMap<>(); + inputs.put(ObjectWriteProcesses.FIELD_TAGS, "team=media\nstatus=draft"); + ProcessRunner.Run run = ProcessRunnerWithInputs.run(ObjectWriteProcesses.TAGS_NAME, ids(rows, "t/a.txt"), inputs); + assertHas(run.review(), "1 object will get these tags"); + assertHas(run.result(), "1 object got these tags"); + assertThat(ObjectStorage.bucket("indexed").getTags("t/a.txt")).containsEntry("team", "media").containsEntry("status", "draft"); + } + + + + @Test + void downloadLinksAreShownOnceAndLoggedWithoutTheirSignature() throws Exception + { + Map rows = seed("indexed", target.indexBucket, Map.of("l/file.txt", 5)); + ProcessRunner.Run run = ProcessRunner.run(LinkProcesses.DOWNLOAD_NAME, ids(rows, "l/file.txt"), null); + assertHas(run.review(), "1 download link will be created"); + Object lines = run.resultOutput().getValues().get("processResults"); + String url = ((List) lines).stream().map(l -> ((com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine) l).getBulletsOfText()) + .filter(b -> b != null && !b.isEmpty()).map(b -> b.get(0)).findFirst().orElseThrow(); + HttpResponse response = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create(url)).GET().build(), HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).hasSize(5); + String signature = url.replaceAll(".*X-Amz-Signature=([^&]+).*", "$1"); + for(QRecord row : Db.query(ObjectStorageTables.OPERATION, "action", "PRESIGN_GET")) + { + assertThat(String.valueOf(row.getValues())).doesNotContain(signature); + } + } + + + + @Test + void uploadLinkPinsTheTypeAndRejectsBadKeys() throws Exception + { + String bucketId = String.valueOf(ConfigSync.bucketId("indexed")); + Map inputs = new HashMap<>(); + inputs.put(BucketProcesses.FIELD_LINK_KEY, "../escape"); + assertHas(ProcessRunnerWithInputs.review(BucketProcesses.UPLOAD_LINK, bucketId, inputs), "The link cannot be created"); + + inputs.put(BucketProcesses.FIELD_LINK_KEY, "in/pinned.txt"); + inputs.put(BucketProcesses.FIELD_LINK_TYPE, "text/plain"); + ProcessRunner.Run run = ProcessRunnerWithInputs.run(BucketProcesses.UPLOAD_LINK, bucketId, inputs); + assertHas(run.review(), "1 upload link will be created"); + String url = ((List) run.resultOutput().getValues().get("processResults")).stream().map(l -> ((com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine) l).getBulletsOfText()) + .filter(b -> b != null && !b.isEmpty()).map(b -> b.get(0)).findFirst().orElseThrow(); + HttpResponse put = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create(url)).header("Content-Type", "text/plain").PUT(HttpRequest.BodyPublishers.ofString("hello")).build(), HttpResponse.BodyHandlers.ofString()); + assertThat(put.statusCode()).isBetween(200, 299); + assertThat(ObjectStorage.bucket("indexed").head("in/pinned.txt")).isPresent(); + } + + + + @Test + void rescanBucketRunsTheScanAndReportsIt() throws Exception + { + try(S3Client raw = target.rawClient()) + { + raw.putObject(PutObjectRequest.builder().bucket(target.indexBucket).key("s/new.txt").build(), RequestBody.fromBytes(new byte[2])); + } + String ids = String.valueOf(ConfigSync.bucketId("indexed")) + "," + ConfigSync.bucketId("files"); + ProcessRunner.Run run = ProcessRunner.run(BucketProcesses.RESCAN_NAME, ids, null); + assertHas(run.review(), "1 bucket will be listed and re-indexed"); + assertHas(run.review(), "1 bucket is not indexed (skipped)"); + assertHas(run.result(), "indexed: scan OK - 1 objects seen, 1 new"); + assertThat(rows("indexed")).containsKey("s/new.txt"); + } + + + + @Test + void checkConfigurationObservesAndFlagsDriftAgainstTheConfig() throws Exception + { + reinstall(c -> c.findBucket("versioned").withExpectVersioning(false).withExpectLifecycle(true)); + String id = String.valueOf(ConfigSync.bucketId("versioned")); + ProcessRunner.Run run = ProcessRunner.run(BucketProcesses.CHECK_NAME, id, null); + assertHas(run.review(), "changes nothing"); + assertHas(run.result(), "versioned: versioning Enabled"); + assertHas(run.result(), "drift - expected versioning off, observed Enabled"); + assertHas(run.result(), "drift - expected a lifecycle configuration"); + QRecord row = Db.first(ObjectStorageTables.BUCKET, "alias", "versioned"); + assertThat(row.getValueString("versioningState")).isEqualTo("Enabled"); + assertThat(row.getValueString("configDriftNote")).contains("expected versioning off"); + assertThat(row.getValueString("lifecycleSummary")).isEqualTo("none"); + + QRecord plain = Db.first(ObjectStorageTables.BUCKET, "alias", "indexed"); + ProcessRunner.run(BucketProcesses.CHECK_NAME, String.valueOf(plain.getValue("id")), null); + assertThat(Db.first(ObjectStorageTables.BUCKET, "alias", "indexed").getValueString("configDriftNote")).isNull(); + } + + + + @Test + void applyHeaderPolicyToABucketUsesTheIndexAndCountsUnknownHeaders() throws Exception + { + StorageTestSupport.insert(ObjectStorageTables.HEADER_RULE, + new QRecord().withValue("name", "all").withValue("keyGlob", "*").withValue("cacheControl", "public, max-age=1").withValue("priority", 1).withValue("enabled", true)); + DefaultObjectHeaderPolicy.clearCache(); + seed("indexed", target.indexBucket, Map.of("b/a.txt", 1, "b/b.txt", 1)); + try(S3Client raw = target.rawClient()) + { + raw.putObject(PutObjectRequest.builder().bucket(target.indexBucket).key("b/unseen.txt").build(), RequestBody.fromBytes(new byte[1])); + } + ObjectIndex.scan("indexed"); + String id = String.valueOf(ConfigSync.bucketId("indexed")); + ProcessRunner.Run run = ProcessRunner.run(BucketProcesses.POLICY_NAME, id, null); + assertHas(run.review(), "2 objects in these buckets will change to match the policy"); + assertHas(run.review(), "1 object has unknown headers"); + assertHas(run.result(), "2 objects were changed to match the policy"); + assertThat(ObjectStorage.bucket("indexed").head("b/a.txt").orElseThrow().getHeaders().getCacheControl()).isEqualTo("public, max-age=1"); + assertThat(ObjectStorage.bucket("indexed").head("b/unseen.txt").orElseThrow().getHeaders().getCacheControl()).as("unknown headers wait for the HEAD backlog").isNull(); + } + + + + @Test + void renameAndDeleteAFolderActOnTheObjectsUnderIt() throws Exception + { + Map rows = seed("indexed", target.indexBucket, Map.of("f/old/1.txt", 1, "f/old/deep/2.txt", 2, "f/other/3.txt", 3)); + QRecord old = Db.first(ObjectStorageTables.FOLDER, "path", "f/old/"); + Map inputs = new HashMap<>(); + inputs.put(FolderProcesses.FIELD_NEW_PATH, "f/new/"); + ProcessRunner.Run run = ProcessRunnerWithInputs.run(FolderProcesses.RENAME_NAME, String.valueOf(old.getValue("id")), inputs); + assertHas(run.review(), "2 objects will be moved"); + assertHas(run.result(), "2 objects were moved"); + assertThat(ObjectStorage.bucket("indexed").head("f/new/1.txt")).isPresent(); + assertThat(ObjectStorage.bucket("indexed").head("f/new/deep/2.txt")).isPresent(); + assertThat(ObjectStorage.bucket("indexed").head("f/old/1.txt")).isEmpty(); + assertThat(ObjectStorage.bucket("indexed").head("f/other/3.txt")).isPresent(); + + QRecord other = Db.first(ObjectStorageTables.FOLDER, "path", "f/other/"); + ProcessRunner.Run delete = ProcessRunner.run(FolderProcesses.DELETE_NAME, String.valueOf(other.getValue("id")), null); + assertHas(delete.review(), "1 object will be deleted permanently"); + assertHas(delete.result(), "1 object was deleted permanently"); + assertThat(ObjectStorage.bucket("indexed").head("f/other/3.txt")).isEmpty(); + + QRecord f = Db.first(ObjectStorageTables.FOLDER, "path", "f/"); + assertThat(rows).isNotEmpty(); + Map two = new HashMap<>(); + two.put(FolderProcesses.FIELD_NEW_PATH, "z/"); + assertHas(ProcessRunnerWithInputs.review(FolderProcesses.RENAME_NAME, f.getValue("id") + "," + old.getValue("id"), two), "Rename works on one folder at a time"); + } + + + + @Test + void uploadStreamsFilesIntoAFolderAndNeverOverwritesByDefault() throws Exception + { + Map files = new HashMap<>(); + files.put("2026/up/hello.txt", "hello".getBytes(StandardCharsets.UTF_8)); + files.put("2026/up/exists.txt", "new".getBytes(StandardCharsets.UTF_8)); + files.put("2026/up/big.bin", new byte[3000]); + UploadProcess.setOpener(ref -> new ByteArrayInputStream(files.get(String.valueOf(ref)))); + seed("indexed", target.indexBucket, Map.of("incoming/exists.txt", 1)); + ObjectStorage.bucket("indexed").put(PutRequest.of("incoming/exists.txt", "old".getBytes(StandardCharsets.UTF_8))); + com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime.getConfig().findBucket("indexed").setMaxUploadBytes(2000L); + + Map inputs = new HashMap<>(); + inputs.put(UploadProcess.FIELD_FILE, new ArrayList<>(List.of("2026/up/hello.txt", "2026/up/exists.txt", "2026/up/big.bin"))); + inputs.put(UploadProcess.FIELD_BUCKET, ConfigSync.bucketId("indexed")); + inputs.put(UploadProcess.FIELD_PREFIX, "incoming"); + ProcessRunner.Run run = ProcessRunnerWithInputs.run(UploadProcess.UPLOAD_NAME, "", inputs); + assertHas(run.review(), "2 files will be uploaded"); + assertHas(run.review(), "1 file is skipped: an object with that name exists"); + assertHas(run.review(), "Each file may be up to"); + assertHas(run.result(), "1 file was uploaded"); + assertHas(run.result(), "record failed"); + assertThat(ObjectStorage.bucket("indexed").head("incoming/hello.txt")).isPresent(); + assertThat(ObjectStorage.bucket("indexed").head("incoming/big.bin")).as("over the upload limit").isEmpty(); + assertThat(ObjectStorage.bucket("indexed").head("incoming/exists.txt").orElseThrow().getSize()).isEqualTo(3); + assertThat(rows("indexed")).containsKey("incoming/hello.txt"); + } + + + + @Test + void bulkRunsOverTheCapNeedAdmin() throws Exception + { + reinstall(c -> c.setBulkCap(2)); + Map rows = seed("indexed", target.indexBucket, Map.of("cap/1", 1, "cap/2", 1, "cap/3", 1)); + String all = ids(rows, "cap/1", "cap/2", "cap/3"); + as("object_storage.view"); + assertHas(ProcessRunner.review(ObjectWriteProcesses.REFRESH_NAME, all), "1 record is over the bulk cap and needs object_storage.admin"); + as("object_storage.view", "object_storage.admin"); + assertThat(ProcessRunner.review(ObjectWriteProcesses.REFRESH_NAME, all)).as("admin may exceed the cap").anyMatch(l -> l.contains("3 objects will be read from the bucket")); + } + + + + @Test + void aUserWithoutTheBucketKeyCannotActOnItsObjects() throws Exception + { + reinstall(c -> c.setBucketScoping(true)); + Map rows; + QContext.setQSession(new QSession().withUser(new QUser().withIdReference("scoped@example.com")).withPermissions(Set.of("object_storage.view", "object_storage.admin")) + .withSecurityKeyValue(ObjectStorageTables.ALL_ACCESS_BUCKET, true).withSecurityKeyValue(ObjectStorageTables.ALL_ACCESS_CONN, true)); + rows = seed("indexed", target.indexBucket, Map.of("k/a", 1)); + String id = ids(rows, "k/a"); + QContext.setQSession(new QSession().withUser(new QUser().withIdReference("scoped@example.com")).withPermissions(Set.of("object_storage.view", "object_storage.admin")) + .withSecurityKeyValue(ObjectStorageTables.KEY_TYPE_BUCKET, ConfigSync.bucketId("files"))); + List review = ProcessRunner.review(ObjectWriteProcesses.REFRESH_NAME, id); + assertThat(review).as("the record is not visible to this user at all: " + review).noneMatch(l -> l.contains("will be read from the bucket")); + } +} diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/processes/ProcessRunnerWithInputs.java b/src/test/java/com/kingsrook/qbits/objectstorage/processes/ProcessRunnerWithInputs.java new file mode 100644 index 0000000..a331a0c --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/objectstorage/processes/ProcessRunnerWithInputs.java @@ -0,0 +1,62 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.processes; + + +import java.io.Serializable; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.objectstorage.testsupport.ProcessRunner; + + +/******************************************************************************* + ** Runs an action the way the UI does after the input form: the form's values + ** are process values, then Review, then Result. + *******************************************************************************/ +final class ProcessRunnerWithInputs +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private ProcessRunnerWithInputs() + { + } + + + + /******************************************************************************* + ** Review and Result with input values. + *******************************************************************************/ + static ProcessRunner.Run run(String process, String recordIds, Map inputs) throws Exception + { + return (ProcessRunner.run(process, recordIds, inputs)); + } + + + + /******************************************************************************* + ** Review only, with input values. + *******************************************************************************/ + static List review(String process, String recordIds, Map inputs) throws Exception + { + return (ProcessRunner.review(process, recordIds, inputs)); + } +} diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/testsupport/ProcessRunner.java b/src/test/java/com/kingsrook/qbits/objectstorage/testsupport/ProcessRunner.java new file mode 100644 index 0000000..ed685dc --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/objectstorage/testsupport/ProcessRunner.java @@ -0,0 +1,150 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.testsupport; + + +import java.io.Serializable; +import java.util.List; +import java.util.Map; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; + + +/******************************************************************************* + ** Runs a standard streamed ETL the way the UI does: Review (the dry run), then + ** Result. Returns the lines of both screens as text. + *******************************************************************************/ +public final class ProcessRunner +{ + /******************************************************************************* + ** The two screens of one run. + *******************************************************************************/ + public record Run(List review, List result, RunProcessOutput resultOutput) + { + } + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private ProcessRunner() + { + } + + + + /******************************************************************************* + ** Run review, then execute, for record ids. + *******************************************************************************/ + @SuppressWarnings("unchecked") + public static Run run(String process, String recordIds, Map extraValues) throws Exception + { + RunProcessOutput reviewOutput = toReview(process, recordIds, extraValues); + List reviewLines = (List) reviewOutput.getValues().get(StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY); + + RunProcessInput go = new RunProcessInput(); + go.setProcessName(process); + go.setProcessUUID(reviewOutput.getProcessUUID()); + go.setStartAfterStep(StreamedETLWithFrontendProcess.STEP_NAME_REVIEW); + go.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP); + RunProcessOutput result = new RunProcessAction().execute(go); + List resultLines = (List) result.getValues().get(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY); + return (new Run(text(reviewLines, false), text(resultLines, true), result)); + } + + + + /******************************************************************************* + ** Start the process and stop at the Review screen. When the action has an input + ** form, the first screen is that form: submit it with the values, as the UI does. + *******************************************************************************/ + private static RunProcessOutput toReview(String process, String recordIds, Map extraValues) throws Exception + { + RunProcessInput input = new RunProcessInput(); + input.setProcessName(process); + input.addValue("recordsParam", "recordIds"); + input.addValue("recordIds", recordIds); + if(extraValues != null) + { + extraValues.forEach(input::addValue); + } + input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK); + RunProcessOutput output = new RunProcessAction().execute(input); + if(QContext.getQInstance().getProcess(process).getFrontendStep("input") != null) + { + RunProcessInput submit = new RunProcessInput(); + submit.setProcessName(process); + submit.setProcessUUID(output.getProcessUUID()); + submit.setStartAfterStep("input"); + if(extraValues != null) + { + extraValues.forEach(submit::addValue); + } + submit.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK); + output = new RunProcessAction().execute(submit); + } + return (output); + } + + + + /******************************************************************************* + ** Only the review (the dry run): nothing is changed. + *******************************************************************************/ + public static List review(String process, String recordIds) throws Exception + { + return (review(process, recordIds, null)); + } + + + + /******************************************************************************* + ** Only the review, with the input form's values. + *******************************************************************************/ + @SuppressWarnings("unchecked") + public static List review(String process, String recordIds, Map extraValues) throws Exception + { + RunProcessOutput review = toReview(process, recordIds, extraValues); + return (text((List) review.getValues().get(StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY), false)); + } + + + + /******************************************************************************* + ** Lines as shown (count and message). + *******************************************************************************/ + private static List text(List lines, boolean past) + { + return (lines == null ? List.of() : lines.stream().map(line -> + { + ProcessSummaryLine summary = (ProcessSummaryLine) line; + summary.pickMessage(past); + String bullets = summary.getBulletsOfText() == null || summary.getBulletsOfText().isEmpty() ? "" : " [" + String.join(" | ", summary.getBulletsOfText()) + "]"; + return ((summary.getCount() == null || summary.getCount() == 0 ? "" : summary.getCount() + " ") + summary.getMessage() + bullets); + }).toList()); + } +} diff --git a/src/test/java/com/kingsrook/qbits/objectstorage/testsupport/StorageTestSupport.java b/src/test/java/com/kingsrook/qbits/objectstorage/testsupport/StorageTestSupport.java new file mode 100644 index 0000000..b76492c --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/objectstorage/testsupport/StorageTestSupport.java @@ -0,0 +1,199 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.objectstorage.testsupport; + + +import java.util.List; +import java.util.function.Consumer; +import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig; +import com.kingsrook.qbits.objectstorage.ObjectStorageQBitProducer; +import com.kingsrook.qbits.objectstorage.conformance.ConformanceTarget; +import com.kingsrook.qbits.objectstorage.config.AddressingStyle; +import com.kingsrook.qbits.objectstorage.config.BucketRole; +import com.kingsrook.qbits.objectstorage.config.ChecksumMode; +import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig; +import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig; +import com.kingsrook.qbits.objectstorage.internal.CapabilityStore; +import com.kingsrook.qbits.objectstorage.internal.ConfigSync; +import com.kingsrook.qbits.objectstorage.internal.DefaultObjectHeaderPolicy; +import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore; + + +/******************************************************************************* + ** A memory-backend QQQ instance with the qbit installed against a target store. + ** Secrets reach the config as ${prop.X} references (system properties), the + ** same reference mechanism an app uses with ${env.X}. + *******************************************************************************/ +public final class StorageTestSupport +{ + public static final String MEMORY = "memory"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private StorageTestSupport() + { + } + + + + /******************************************************************************* + ** The config for a target: connection "primary" and buckets files (the + ** scratch bucket), versioned (when the store has versioning), soft (trash in + ** the trash bucket), and trashbin (the private trash). + *******************************************************************************/ + public static ObjectStorageQBitConfig config(ConformanceTarget t, ChecksumMode checksumMode) + { + System.setProperty("os.test.endpoint", t.endpoint); + System.setProperty("os.test.ak", t.accessKey); + System.setProperty("os.test.sk", t.secretKey); + System.setProperty("os.test.files", t.bucket); + ObjectStorageQBitConfig config = new ObjectStorageQBitConfig() + .withBackendName(MEMORY) + .withBucketScoping(false) + .withOpenWhenAnonymous(true) + .withAdminApp(true) + .withAllowedSecretRefPatterns(List.of("prop.os.test.*")) + .withConnection(new StorageConnectionConfig("primary") + .withProviderType(t.providerType) + .withEndpoint("${prop.os.test.endpoint}") + .withRegion(t.region) + .withAddressingStyle(AddressingStyle.PATH) + .withChecksumMode(checksumMode) + .withAccessKeyRef("${prop.os.test.ak}") + .withSecretKeyRef("${prop.os.test.sk}") + .withMaxOpsPerSecond(2000) + .withMaxConcurrency(16)) + .withBucket(new StorageBucketConfig("files").withConnection("primary").withBucketName("${prop.os.test.files}").withRole(BucketRole.PRIVATE).withIndexed(false)); + if(t.indexBucket != null) + { + System.setProperty("os.test.index", t.indexBucket); + config.withBucket(new StorageBucketConfig("indexed").withConnection("primary").withBucketName("${prop.os.test.index}").withRole(BucketRole.PUBLIC).withPublicBaseUrl("https://cdn.example.com/idx") + .withIndexed(true).withReferenceTracked(true).withExemptPrefixes(new java.util.ArrayList<>(List.of("exempt/")))); + } + if(t.trashBucket != null) + { + System.setProperty("os.test.trash", t.trashBucket); + config.withBucket(new StorageBucketConfig("trashbin").withConnection("primary").withBucketName("${prop.os.test.trash}").withRole(BucketRole.PRIVATE).withIndexed(false)); + config.withBucket(new StorageBucketConfig("soft").withConnection("primary").withBucketName("${prop.os.test.files}").withRole(BucketRole.PRIVATE).withTrashEnabled(true).withTrashBucket("trashbin") + .withTrashPrefix(".trash/").withIndexed(false)); + } + if(t.versionedBucket != null) + { + System.setProperty("os.test.versioned", t.versionedBucket); + config.withBucket(new StorageBucketConfig("versioned").withConnection("primary").withBucketName("${prop.os.test.versioned}").withRole(BucketRole.PRIVATE).withIndexed(true)); + } + if(t.lockBucket != null) + { + System.setProperty("os.test.lock", t.lockBucket); + config.withBucket(new StorageBucketConfig("locked").withConnection("primary").withBucketName("${prop.os.test.lock}").withRole(BucketRole.BACKUP).withIndexed(false)); + } + return (config); + } + + + + /******************************************************************************* + ** Fresh instance, store and runtime for a target. + *******************************************************************************/ + public static QInstance setUp(ConformanceTarget t, Consumer customize) throws QException + { + ObjectStorageQBitConfig config = config(t, t.providerType == null || t.checksumsSupportedWorks ? ChecksumMode.WHEN_REQUIRED : ChecksumMode.WHEN_REQUIRED); + if(customize != null) + { + customize.accept(config); + } + return (setUp(config)); + } + + + + /******************************************************************************* + ** Fresh instance, store and runtime for a config. + *******************************************************************************/ + public static QInstance setUp(ObjectStorageQBitConfig config) throws QException + { + return (setUp(config, null)); + } + + + + /******************************************************************************* + ** Fresh instance, store and runtime for a config, letting the test add its own + ** tables to the instance before the qbit is produced. + *******************************************************************************/ + public static QInstance setUp(ObjectStorageQBitConfig config, Consumer instanceCustomizer) throws QException + { + MemoryRecordStore.getInstance().reset(); + ObjectStorageRuntime.reset(); + ConfigSync.reset(); + CapabilityStore.clearCache(); + DefaultObjectHeaderPolicy.clearCache(); + + QInstance instance = new QInstance(); + instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS)); + instance.addBackend(new QBackendMetaData().withName(MEMORY).withBackendType(MemoryBackendModule.class)); + if(instanceCustomizer != null) + { + instanceCustomizer.accept(instance); + } + new ObjectStorageQBitProducer().withQBitConfig(config).produce(instance); + QContext.init(instance, new QSession()); + ConfigSync.sync(); + return (instance); + } + + + + /******************************************************************************* + ** Insert rows. + *******************************************************************************/ + public static List insert(String table, QRecord... records) throws QException + { + return (new InsertAction().execute(new InsertInput(table).withRecords(List.of(records))).getRecords()); + } + + + + /******************************************************************************* + ** Tear down. + *******************************************************************************/ + public static void tearDown() + { + ObjectStorageRuntime.reset(); + ConfigSync.reset(); + QContext.clear(); + } +}