Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8616197f0
|
||
|
|
39c87523fa
|
||
|
|
04d8a61801
|
||
|
|
c3347b56d8
|
||
|
|
9816e138b8
|
||
|
|
a590c7f055
|
||
|
|
4ff79233ee
|
||
|
|
18ffbf1e75
|
||
|
|
c4ffe01459 | ||
|
|
387f52b565 |
No files matched your search
+68
-11
@@ -1,14 +1,71 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
workflows:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /v.*/
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*|hotfix\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: hotfix
|
||||
filters:
|
||||
branches:
|
||||
only: [/hotfix\/.*/]
|
||||
@@ -1,25 +0,0 @@
|
||||
# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository.
|
||||
name: qrun-release
|
||||
on:
|
||||
push: {}
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
jobs:
|
||||
release:
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-docker.yaml@qrun-release-v1
|
||||
with:
|
||||
runner: linux-small
|
||||
docker-runner: container-build
|
||||
browsers: true
|
||||
browser-runner: browser-build
|
||||
postpublish:
|
||||
if: ${{ needs.release.outputs.capture == 'true' }}
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1
|
||||
with:
|
||||
runner: linux-small
|
||||
@@ -1,23 +0,0 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: v-prefix
|
||||
test_branch_policy: non-release-no-hotfix
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
hotfix: true
|
||||
asciidoc: false
|
||||
@@ -66,7 +66,66 @@
|
||||
<tag>HEAD</tag>
|
||||
</scm>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<!-- AWS SDK for Java v2; keep in step with qqq-backend-core's awssdk bom. -->
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>bom</artifactId>
|
||||
<version>2.41.10</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>s3</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>apache-client</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- only for the demo server in src/test (the Storage app in a browser); not shipped -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- the Postgres index test: the real SQL of the object index (src/test only) -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-postgres</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.9</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-postgresql</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
@@ -87,6 +146,37 @@
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>3.3.1</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>10.12.6</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<configuration>
|
||||
<configLocation>checkstyle/config.xml</configLocation>
|
||||
<headerLocation>checkstyle/license.txt</headerLocation>
|
||||
<encoding>UTF-8</encoding>
|
||||
<consoleOutput>true</consoleOutput>
|
||||
<failsOnError>false</failsOnError>
|
||||
<failOnViolation>true</failOnViolation>
|
||||
<violationSeverity>warning</violationSeverity>
|
||||
<includeTestSourceDirectory>false</includeTestSourceDirectory>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>validate</id>
|
||||
<phase>validate</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
|
||||
@@ -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_<name> (lowercase snake_case, backend name equal
|
||||
** to the table name); permissions are object_storage.<action>.
|
||||
/********************************************************************************
|
||||
** 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_<name> (lowercase snake_case, backend name equal
|
||||
** ** to the table name); permissions are object_storage.<action>.
|
||||
*******************************************************************************/
|
||||
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<StorageConnectionConfig> connections = new ArrayList<>();
|
||||
private List<StorageBucketConfig> buckets = new ArrayList<>();
|
||||
private ObjectHeaderPolicy headerPolicy;
|
||||
private PublicUrlResolver publicUrlResolver;
|
||||
private List<StorageReferenceProvider> referenceProviders = new ArrayList<>();
|
||||
private StorageAlertSink alertSink;
|
||||
private Boolean adminApp = false;
|
||||
private String appName = "objectStorageAdmin";
|
||||
private String appLabel = "Storage";
|
||||
private String parentAppName;
|
||||
private List<String> 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<StorageConnectionConfig> getConnections()
|
||||
{
|
||||
return (this.connections);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Setter for connections.
|
||||
*******************************************************************************/
|
||||
public void setConnections(List<StorageConnectionConfig> connections)
|
||||
{
|
||||
this.connections = connections;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Fluent setter for connections.
|
||||
*******************************************************************************/
|
||||
public ObjectStorageQBitConfig withConnections(List<StorageConnectionConfig> connections)
|
||||
{
|
||||
this.connections = connections;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Getter for buckets.
|
||||
*******************************************************************************/
|
||||
public List<StorageBucketConfig> getBuckets()
|
||||
{
|
||||
return (this.buckets);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Setter for buckets.
|
||||
*******************************************************************************/
|
||||
public void setBuckets(List<StorageBucketConfig> buckets)
|
||||
{
|
||||
this.buckets = buckets;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Fluent setter for buckets.
|
||||
*******************************************************************************/
|
||||
public ObjectStorageQBitConfig withBuckets(List<StorageBucketConfig> 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<StorageReferenceProvider> getReferenceProviders()
|
||||
{
|
||||
return (this.referenceProviders);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Setter for referenceProviders.
|
||||
*******************************************************************************/
|
||||
public void setReferenceProviders(List<StorageReferenceProvider> referenceProviders)
|
||||
{
|
||||
this.referenceProviders = referenceProviders;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Fluent setter for referenceProviders.
|
||||
*******************************************************************************/
|
||||
public ObjectStorageQBitConfig withReferenceProviders(List<StorageReferenceProvider> 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<String> getAllowedSecretRefPatterns()
|
||||
{
|
||||
return (this.allowedSecretRefPatterns);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Setter for allowedSecretRefPatterns.
|
||||
*******************************************************************************/
|
||||
public void setAllowedSecretRefPatterns(List<String> allowedSecretRefPatterns)
|
||||
{
|
||||
this.allowedSecretRefPatterns = allowedSecretRefPatterns;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Fluent setter for allowedSecretRefPatterns.
|
||||
*******************************************************************************/
|
||||
public ObjectStorageQBitConfig withAllowedSecretRefPatterns(List<String> 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<String> errors)
|
||||
{
|
||||
assertCondition(StringUtils.hasContent(backendName), "backendName is required for the Object Storage QBit", errors);
|
||||
|
||||
Set<String> 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<String> 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<ObjectStorageQBitConfig>
|
||||
{
|
||||
@@ -32,7 +57,7 @@ public class ObjectStorageQBitProducer implements QBitMetaDataProducer<ObjectSto
|
||||
public static final String VERSION = "0.1.0";
|
||||
|
||||
private ObjectStorageQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
@@ -100,4 +125,74 @@ public class ObjectStorageQBitProducer implements QBitMetaDataProducer<ObjectSto
|
||||
return (qBitConfig != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build everything and add it to the instance.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
|
||||
{
|
||||
qBitConfig.validate(qInstance);
|
||||
QBitMetaData qBit = getQBitMetaData();
|
||||
qInstance.addQBit(qBit);
|
||||
ObjectStorageRuntime.install(qBitConfig);
|
||||
ConfigSync.reset();
|
||||
DefaultObjectHeaderPolicy.clearCache();
|
||||
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
for(QPossibleValueSource possibleValueSource : ObjectStorageTables.possibleValueSources())
|
||||
{
|
||||
output.add(possibleValueSource);
|
||||
}
|
||||
if(Boolean.TRUE.equals(qBitConfig.getBucketScoping()))
|
||||
{
|
||||
for(QSecurityKeyType keyType : ObjectStorageTables.securityKeyTypes())
|
||||
{
|
||||
output.add(keyType);
|
||||
}
|
||||
}
|
||||
for(QJoinMetaData join : ObjectStorageTables.joins())
|
||||
{
|
||||
output.add(join);
|
||||
}
|
||||
for(QWidgetMetaData widget : ObjectStorageTables.childListWidgets())
|
||||
{
|
||||
output.add(widget);
|
||||
}
|
||||
List<QTableMetaData> 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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).
|
||||
**
|
||||
** <pre>
|
||||
** .withReferenceProvider(FieldReferenceProvider.keyPrefix("files", "media", "storageKey"))
|
||||
** </pre>
|
||||
*******************************************************************************/
|
||||
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<StorageReference> references(String alias) throws QException
|
||||
{
|
||||
if(!bucketAlias.equals(alias) && holds != Holds.URL)
|
||||
{
|
||||
return (Stream.empty());
|
||||
}
|
||||
List<StorageReference> 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<BucketKey> 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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<String, String> 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<String, String> getUserMetadata()
|
||||
{
|
||||
return (this.userMetadata);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Setter for userMetadata.
|
||||
*******************************************************************************/
|
||||
public void setUserMetadata(Map<String, String> userMetadata)
|
||||
{
|
||||
this.userMetadata = userMetadata;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Fluent setter for userMetadata.
|
||||
*******************************************************************************/
|
||||
public ObjectHeaders withUserMetadata(Map<String, String> 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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
**
|
||||
** <pre>
|
||||
** 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(...);
|
||||
** </pre>
|
||||
*******************************************************************************/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<T>
|
||||
{
|
||||
private List<T> items = new ArrayList<>();
|
||||
private List<String> prefixes = new ArrayList<>();
|
||||
private String nextToken;
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public Page()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Getter for items.
|
||||
*******************************************************************************/
|
||||
public List<T> getItems()
|
||||
{
|
||||
return (this.items);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Setter for items.
|
||||
*******************************************************************************/
|
||||
public void setItems(List<T> items)
|
||||
{
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Fluent setter for items.
|
||||
*******************************************************************************/
|
||||
public Page<T> withItems(List<T> items)
|
||||
{
|
||||
this.items = items;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Getter for prefixes.
|
||||
*******************************************************************************/
|
||||
public List<String> getPrefixes()
|
||||
{
|
||||
return (this.prefixes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Setter for prefixes.
|
||||
*******************************************************************************/
|
||||
public void setPrefixes(List<String> prefixes)
|
||||
{
|
||||
this.prefixes = prefixes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Fluent setter for prefixes.
|
||||
*******************************************************************************/
|
||||
public Page<T> withPrefixes(List<String> 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<T> withNextToken(String nextToken)
|
||||
{
|
||||
this.nextToken = nextToken;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<String, String> 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<String, String> getFields()
|
||||
{
|
||||
return (this.fields);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Setter for fields.
|
||||
*******************************************************************************/
|
||||
public void setFields(Map<String, String> fields)
|
||||
{
|
||||
this.fields = fields;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Fluent setter for fields.
|
||||
*******************************************************************************/
|
||||
public PresignedPost withFields(Map<String, String> fields)
|
||||
{
|
||||
this.fields = fields;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<BucketKey> parse(String url);
|
||||
}
|
||||
@@ -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<String, String> 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<String, String> getMetadata()
|
||||
{
|
||||
return (this.metadata);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Setter for metadata.
|
||||
*******************************************************************************/
|
||||
public void setMetadata(Map<String, String> metadata)
|
||||
{
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Fluent setter for metadata.
|
||||
*******************************************************************************/
|
||||
public PutRequest withMetadata(Map<String, String> 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<String, Object> 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<String, Object> details)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Does nothing.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void resolve(String alertType, String dedupeKey)
|
||||
{
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<StorageReference> references(String bucketAlias) throws QException;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> 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<QRecord> connections = query(ObjectStorageTables.CONNECTION, new QQueryFilter().withOrderBy(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("name")));
|
||||
List<QRecord> buckets = query(ObjectStorageTables.BUCKET, new QQueryFilter().withOrderBy(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("alias")));
|
||||
List<QRecord> caps = query(ObjectStorageTables.BUCKET_CAPABILITY, new QQueryFilter());
|
||||
List<QRecord> operations = query(ObjectStorageTables.OPERATION, new QQueryFilter().withOrderBy(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("occurredAt", false)).withLimit(20));
|
||||
List<QRecord> 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("<div class=\"object-storage-home\" style=\"" + WRAP + "\">");
|
||||
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("</div>").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query a table (empty list when the user cannot see it).
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> 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<QRecord> buckets, List<QRecord> connections, List<QRecord> 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<Object, String> connectionNames = new LinkedHashMap<>();
|
||||
connections.forEach(c -> connectionNames.put(c.getValue("id"), c.getValueString("name")));
|
||||
html.append("<div style=\"").append(GRID).append("\">");
|
||||
for(QRecord bucket : buckets)
|
||||
{
|
||||
boolean indexed = Boolean.TRUE.equals(bucket.getValueBoolean("indexed"));
|
||||
html.append("<div style=\"").append(ITEM).append("\"><div style=\"").append(HEAD).append("\"><strong>")
|
||||
.append(link(prefix, ObjectStorageTables.BUCKET, bucket.getValue("id"), bucket.getValueString("alias"))).append("</strong><span style=\"").append(MUTED).append("font-size:.8rem;\">")
|
||||
.append(esc(bucket.getValueString("role"))).append("</span></div><dl style=\"").append(PAIRS).append("\">");
|
||||
pair(html, "Connection", esc(connectionNames.getOrDefault(bucket.getValue("connectionId"), "")));
|
||||
pair(html, "Objects", indexed ? number(bucket.getValueLong("objectCount")) : "<span style=\"" + MUTED + "\">not indexed</span>");
|
||||
pair(html, "Size", indexed ? bytes(bucket.getValueLong("totalBytes")) : "<span style=\"" + MUTED + "\">not indexed</span>");
|
||||
pair(html, "Last scan", bucket.getValueInstant("lastScanDate") == null ? "<span style=\"" + MUTED + "\">never</span>" : WHEN.format(bucket.getValueInstant("lastScanDate")));
|
||||
pair(html, "Versioning", esc(bucket.getValueString("versioningState") == null ? "not checked yet" : bucket.getValueString("versioningState")));
|
||||
html.append("</dl>");
|
||||
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("<span style=\"").append(CHIP).append("\">no ").append(name).append("</span>");
|
||||
}
|
||||
else if("PARTIAL".equals(status))
|
||||
{
|
||||
chips.append("<span style=\"").append(CHIP).append("\">").append(name).append(" partial</span>");
|
||||
}
|
||||
else if("NOT_PERMITTED".equals(status))
|
||||
{
|
||||
chips.append("<span style=\"").append(CHIP).append("\">").append(name).append(": key not permitted</span>");
|
||||
}
|
||||
}
|
||||
}
|
||||
if(bucket.getValueString("configDriftNote") != null)
|
||||
{
|
||||
chips.append("<span style=\"").append(CHIP).append("\">config drift</span>");
|
||||
}
|
||||
if(chips.length() > 0)
|
||||
{
|
||||
html.append("<div style=\"margin-top:.55rem;\">").append(chips).append("</div>");
|
||||
}
|
||||
html.append("</div>");
|
||||
}
|
||||
html.append("</div>");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 2. Data health: orphaned and missing objects per bucket.
|
||||
*******************************************************************************/
|
||||
private static void healthSection(StringBuilder html, List<QRecord> buckets, String prefix)
|
||||
{
|
||||
open(html, "Data health");
|
||||
List<QRecord> 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("<div style=\"").append(GRID).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("<div style=\"").append(ITEM).append("\"><strong>").append(link(prefix, ObjectStorageTables.BUCKET, bucket.getValue("id"), bucket.getValueString("alias"))).append("</strong><dl style=\"")
|
||||
.append(PAIRS).append("\">");
|
||||
pair(html, "Orphaned", scanned ? "<a href=\"" + esc(orphanFilter) + "\">" + number(bucket.getValueLong("orphanedCount")) + " objects</a> (" + bytes(bucket.getValueLong("orphanedBytes")) + ")"
|
||||
: "<span style=\"" + MUTED + "\">not scanned yet</span>");
|
||||
pair(html, "Missing", scanned ? "<a href=\"" + esc(missingFilter) + "\">" + number(bucket.getValueLong("missingCount")) + " objects</a>" : "");
|
||||
pair(html, "Last scan", scanned ? WHEN.format(bucket.getValueInstant("lastReferenceScanDate")) : "never");
|
||||
html.append("</dl></div>");
|
||||
}
|
||||
html.append("</div>");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 3. Recent changes: the latest 20 operations, linking to the full table.
|
||||
*******************************************************************************/
|
||||
private static void recentSection(StringBuilder html, List<QRecord> 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("<div style=\"").append(ROW).append("\"><div><strong>").append(link(prefix, ObjectStorageTables.OPERATION, op.getValue("id"), String.valueOf(op.getValueString("action"))))
|
||||
.append("</strong> ").append(esc(op.getValueString("bucketAlias") + "/" + (op.getValueString("objectKey") == null ? "" : op.getValueString("objectKey")))).append("</div><div style=\"")
|
||||
.append(MUTED).append("font-size:.85rem;\">").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("</div></div>");
|
||||
}
|
||||
html.append("<p style=\"margin:.75rem 0 0;\"><a href=\"").append(esc(prefix + "/" + ObjectStorageTables.OPERATION)).append("\">See every operation</a></p>");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 4. Cache headers: how many indexed objects match the header policy.
|
||||
*******************************************************************************/
|
||||
private static void headersSection(StringBuilder html, List<QRecord> buckets, String prefix)
|
||||
{
|
||||
open(html, "Cache headers");
|
||||
List<QRecord> 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("<div style=\"").append(ROW).append("\"><strong>").append(link(prefix, ObjectStorageTables.BUCKET, bucket.getValue("id"), bucket.getValueString("alias"))).append("</strong>: ");
|
||||
if(bucket.getValueInstant("lastScanDate") == null)
|
||||
{
|
||||
html.append("<span style=\"").append(MUTED).append("\">not scanned yet</span>");
|
||||
}
|
||||
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("</div>");
|
||||
}
|
||||
html.append("<p style=\"margin:.75rem 0 0;\">To fix mismatches, run <strong>Apply header policy</strong> on the objects or a bucket. Edge caching lives in the CDN app.</p>");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 5. Usage trend: bytes and object count per bucket over 30 days (snapshots).
|
||||
*******************************************************************************/
|
||||
private static void usageSection(StringBuilder html, List<QRecord> buckets, List<QRecord> usage)
|
||||
{
|
||||
open(html, "Usage trend");
|
||||
Map<Object, List<QRecord>> 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<QRecord> rows = byBucket.getOrDefault(bucket.getValue("id"), List.of());
|
||||
if(rows.size() < 2)
|
||||
{
|
||||
if(!rows.isEmpty())
|
||||
{
|
||||
collecting.append("<div style=\"").append(ROW).append("\"><strong>").append(esc(bucket.getValueString("alias"))).append("</strong>: collecting since ")
|
||||
.append(rows.get(0).getValueString("snapshotDate")).append("</div>");
|
||||
}
|
||||
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("<div style=\"").append(ITEM).append("\"><strong>").append(esc(bucket.getValueString("alias"))).append("</strong><dl style=\"").append(PAIRS).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("</dl></div>");
|
||||
}
|
||||
if(trends.length() > 0)
|
||||
{
|
||||
html.append("<div style=\"").append(GRID).append("\">").append(trends).append("</div>");
|
||||
}
|
||||
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<QRecord> connections, String prefix, Instant now)
|
||||
{
|
||||
open(html, "Connections");
|
||||
if(connections.isEmpty())
|
||||
{
|
||||
empty(html, "No connections are configured.");
|
||||
close(html);
|
||||
return;
|
||||
}
|
||||
html.append("<div style=\"").append(GRID).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("<div style=\"").append(ITEM).append("\"><div style=\"").append(HEAD).append("\"><strong>")
|
||||
.append(link(prefix, ObjectStorageTables.CONNECTION, c.getValue("id"), c.getValueString("name"))).append("</strong><span style=\"").append(MUTED).append("font-size:.8rem;\">")
|
||||
.append(esc(c.getValueString("providerType"))).append("</span></div><dl style=\"").append(PAIRS).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("</dl></div>");
|
||||
}
|
||||
html.append("</div>");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One label/value pair in a card.
|
||||
*******************************************************************************/
|
||||
private static void pair(StringBuilder html, String label, String valueHtml)
|
||||
{
|
||||
html.append("<dt style=\"").append(MUTED).append("\">").append(esc(label)).append("</dt><dd style=\"margin:0;min-width:0;\">").append(valueHtml).append("</dd>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Open a card.
|
||||
*******************************************************************************/
|
||||
private static void open(StringBuilder html, String title)
|
||||
{
|
||||
html.append("<section style=\"").append(CARD).append("\"><h3 style=\"margin:0 0 .75rem;font-size:1.05rem;\">").append(esc(title)).append("</h3>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Close a card.
|
||||
*******************************************************************************/
|
||||
private static void close(StringBuilder html)
|
||||
{
|
||||
html.append("</section>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An honest empty state.
|
||||
*******************************************************************************/
|
||||
private static void empty(StringBuilder html, String text)
|
||||
{
|
||||
html.append("<p style=\"margin:0;").append(MUTED).append("\">").append(esc(text)).append("</p>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A link to a record.
|
||||
*******************************************************************************/
|
||||
private static String link(String prefix, String table, Object id, String text)
|
||||
{
|
||||
return ("<a href=\"" + esc(prefix + "/" + table + "/" + id) + "\">" + esc(text) + "</a>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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("\"", """));
|
||||
}
|
||||
}
|
||||
@@ -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<QWidgetMetaData> 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<QTableMetaData> 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()))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<String> 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<String> getExemptPrefixes()
|
||||
{
|
||||
return (this.exemptPrefixes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Setter for exemptPrefixes.
|
||||
*******************************************************************************/
|
||||
public void setExemptPrefixes(java.util.List<String> exemptPrefixes)
|
||||
{
|
||||
this.exemptPrefixes = exemptPrefixes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/********************************************************************************
|
||||
** Fluent setter for exemptPrefixes.
|
||||
*******************************************************************************/
|
||||
public StorageBucketConfig withExemptPrefixes(java.util.List<String> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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]}");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<String, Integer> 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<Integer, long[]> direct = new HashMap<>();
|
||||
final Map<Integer, Instant> 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<Integer, Integer> parentOf = new HashMap<>();
|
||||
List<Integer> 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<Integer, long[]> total = new HashMap<>();
|
||||
Map<Integer, Instant> newest = new HashMap<>();
|
||||
for(Map.Entry<Integer, long[]> 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<QRecord> 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))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<ObjectSummary> 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<Serializable> unseenIds = new ArrayList<>();
|
||||
List<QRecord> 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<ObjectSummary> items, FolderIndex folders, Instant now) throws QException
|
||||
{
|
||||
if(items.isEmpty())
|
||||
{
|
||||
return (new long[] { 0, 0 });
|
||||
}
|
||||
List<Serializable> keys = new ArrayList<>();
|
||||
items.forEach(i -> keys.add(i.getKey()));
|
||||
Map<String, QRecord> 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<QRecord> inserts = new ArrayList<>();
|
||||
List<QRecord> 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<Serializable> 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<QRecord> 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<QRecord> 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<QRecord> 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<QRecord> 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<StoredObject> 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<String> indexedAliases()
|
||||
{
|
||||
List<String> 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<String> keysOf(List<QRecord> rows)
|
||||
{
|
||||
Set<String> keys = new HashSet<>();
|
||||
rows.forEach(r -> keys.add(r.getValueString("objectKey")));
|
||||
return (keys);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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<StorageReference> references = new ArrayList<>();
|
||||
for(StorageReferenceProvider provider : ObjectStorageRuntime.getConfig().getReferenceProviders())
|
||||
{
|
||||
provider.references(alias).forEach(references::add);
|
||||
}
|
||||
|
||||
// resolve every reference against the index
|
||||
Map<String, Integer> idByKey = new HashMap<>();
|
||||
List<String> 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<Serializable> 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<Integer, Integer> counts = new HashMap<>();
|
||||
List<QRecord> rows = new ArrayList<>();
|
||||
long missing = 0;
|
||||
Map<String, List<Integer>> prefixCache = new HashMap<>();
|
||||
for(StorageReference ref : references)
|
||||
{
|
||||
List<Integer> 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<Serializable> 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<Integer> seenWithCount = new HashSet<>();
|
||||
List<QRecord> 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<Integer, Integer> 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<Integer> idsUnder(Integer bucketId, String prefix)
|
||||
{
|
||||
List<Integer> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> nightly() throws QException
|
||||
{
|
||||
List<String> 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<String, Map<String, Long>> 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<String> expired = new ArrayList<>();
|
||||
String token = null;
|
||||
do
|
||||
{
|
||||
Page<ObjectSummary> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, Map<Capability, CapabilityStatus>> byBucket = new java.util.LinkedHashMap<>();
|
||||
private final Map<String, List<String>> notes = new java.util.LinkedHashMap<>();
|
||||
private final List<String> problems = new ArrayList<>();
|
||||
private Long clockSkewSeconds;
|
||||
private Boolean presignHostReachable;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Per bucket alias, the status of each capability probed.
|
||||
*******************************************************************************/
|
||||
public Map<String, Map<Capability, CapabilityStatus>> getByBucket()
|
||||
{
|
||||
return (byBucket);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Per bucket alias, notes (for example "write probes skipped: object lock").
|
||||
*******************************************************************************/
|
||||
public Map<String, List<String>> getNotes()
|
||||
{
|
||||
return (notes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Connection-level problems (auth, TLS, DNS, clock).
|
||||
*******************************************************************************/
|
||||
public List<String> 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<CapabilityStatus, Integer> counts = new EnumMap<>(CapabilityStatus.class);
|
||||
byBucket.getOrDefault(alias, Map.of()).values().forEach(s -> counts.merge(s, 1, Integer::sum));
|
||||
List<String> 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<StorageBucketConfig> 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<Capability, CapabilityStatus> results = new EnumMap<>(Capability.class);
|
||||
report.byBucket.put(bucket.getAlias(), results);
|
||||
List<String> 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<Capability, CapabilityStatus> e : results.entrySet())
|
||||
{
|
||||
CapabilityStore.record(bucket, e.getKey(), e.getValue(), details.getOrDefault(bucket.getAlias() + ":" + e.getKey(), null));
|
||||
}
|
||||
}
|
||||
|
||||
if(includeWrite && writable != null)
|
||||
{
|
||||
Map<Capability, CapabilityStatus> presign = new EnumMap<>(Capability.class);
|
||||
presignProbes(connection, writable, presign, report);
|
||||
report.byBucket.get(writable.getAlias()).putAll(presign);
|
||||
for(Map.Entry<Capability, CapabilityStatus> 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<String, String> 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<Capability, CapabilityStatus> 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<Capability, CapabilityStatus> results)
|
||||
{
|
||||
S3Client client = connection.client();
|
||||
String name = name(bucket);
|
||||
String alias = bucket.getAlias();
|
||||
String base = PROBE_PREFIX + UUID.randomUUID();
|
||||
List<String> 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<Capability, CapabilityStatus> 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<String> 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<String> 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<String, String> 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<String> 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<Capability, CapabilityStatus> 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()));
|
||||
}
|
||||
}
|
||||
@@ -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<Capability> 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<String, CapabilityStatus> CACHE = new ConcurrentHashMap<>();
|
||||
private static final Set<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, Integer> BUCKET_IDS = new ConcurrentHashMap<>();
|
||||
private static final Map<String, Integer> 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<String, Integer> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<? extends Certificate> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<T>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** 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> T asSystem(Work<T> 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<QRecord> 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<QRecord> 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<List<QRecord>> 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<QRecord> 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<QRecord> records = query(table, nameValuePairs);
|
||||
return (records.isEmpty() ? null : records.get(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert records, returning them with their ids.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> insert(String table, List<QRecord> 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<QRecord> list = new ArrayList<>();
|
||||
list.add(record);
|
||||
return (insert(table, list).get(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Update records (each must carry its primary key).
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> update(String table, List<QRecord> 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<Serializable> 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));
|
||||
}
|
||||
}
|
||||
+146
@@ -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<QRecord> 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<QRecord> rules()
|
||||
{
|
||||
List<QRecord> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
@@ -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<BucketKey> 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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<String, ConnectionRuntime> CONNECTIONS = new ConcurrentHashMap<>();
|
||||
private static final List<StorageChangeListener> LISTENERS = new CopyOnWriteArrayList<>();
|
||||
private static final Map<String, String> 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<StorageChangeListener> 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<String, String> processPermissions()
|
||||
{
|
||||
return (PROCESS_PERMISSIONS);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("\"", "\\\""));
|
||||
}
|
||||
}
|
||||
@@ -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<QRecord> rules = Db.asSystem(() -> Db.query(ObjectStorageTables.PROTECTION_RULE, "enabled", true));
|
||||
List<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<ProviderType, Map<Capability, CapabilityStatus>> 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<Capability, CapabilityStatus> 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<Capability, CapabilityStatus> m = TABLE.get(type);
|
||||
return (m == null ? UNKNOWN : m.getOrDefault(capability, UNKNOWN));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> 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()));
|
||||
}
|
||||
}
|
||||
@@ -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<QRecord> apply(List<QRecord> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<List<String>>> 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<String, List<List<String>>> 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<String> postgresStatements(ObjectStorageQBitConfig config)
|
||||
{
|
||||
List<String> statements = new ArrayList<>();
|
||||
List<String> 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<QTableMetaData> 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<String> 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<String> 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<String> 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()) + ")";
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<String> READ_ONLY = List.of(CONNECTION_CAPABILITY, BUCKET_CAPABILITY, FOLDER, OBJECT, OBJECT_VERSION, OBJECT_REFERENCE, OPERATION, USAGE_DAILY);
|
||||
|
||||
public static final List<String> 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<QPossibleValueSource> possibleValueSources()
|
||||
{
|
||||
List<QPossibleValueSource> 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<QPossibleValue<?>> 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<QSecurityKeyType> 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<String[]> 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<String> 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<QTableMetaData> tables(ObjectStorageQBitConfig config)
|
||||
{
|
||||
List<QTableMetaData> 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<String, String[][]> 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<QJoinMetaData> joins()
|
||||
{
|
||||
List<QJoinMetaData> 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<QWidgetMetaData> childListWidgets()
|
||||
{
|
||||
List<QWidgetMetaData> 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));
|
||||
}
|
||||
}
|
||||
+117
@@ -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<String> 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<String, String> 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()));
|
||||
}
|
||||
}
|
||||
+46
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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<Integer, Boolean> 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<QRecord> refs = Db.asSystem(() -> Db.query(ObjectStorageTables.OBJECT_REFERENCE, "objectId", row.getValue("id")));
|
||||
List<String> 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<String, Serializable> 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]));
|
||||
}
|
||||
}
|
||||
@@ -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<Integer, Boolean> 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<String> 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<ProcessSummaryLineInterface> 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<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<Integer, Boolean> 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<ProcessSummaryLineInterface> lines);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The lines about records that will be left alone, as the Result screen shows
|
||||
** them (the load step appends these).
|
||||
*******************************************************************************/
|
||||
protected List<ProcessSummaryLine> 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<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
addLines(lines);
|
||||
overCap.addSelfToListIfAnyCount(lines);
|
||||
noAccess.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
@@ -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<QProcessMetaData> 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<String> 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<ProcessSummaryLineInterface> 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<ProcessSummaryLine> skipLines()
|
||||
{
|
||||
return (List.of(notIndexed, running));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rescan bucket, execute: scan, HEAD backlog, references.
|
||||
*******************************************************************************/
|
||||
public static class RescanLoad extends BucketLoad
|
||||
{
|
||||
private final ArrayList<ProcessSummaryLineInterface> 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<ProcessSummaryLineInterface> 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<ProcessSummaryLineInterface> 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<String> 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<String> drift(StorageBucketConfig bucket, Observed o)
|
||||
{
|
||||
List<String> 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<ProcessSummaryLineInterface> 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<String> 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<ProcessSummaryLineInterface> 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<ProcessSummaryLineInterface> 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<ProcessSummaryLine> 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<QRecord> 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<ProcessSummaryLineInterface> 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<ProcessSummaryLineInterface> 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<ProcessSummaryLineInterface> 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<ProcessSummaryLineInterface> 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<Serializable> idsOf(List<QRecord> records)
|
||||
{
|
||||
List<Serializable> ids = new ArrayList<>();
|
||||
records.forEach(r -> ids.add(r.getValue("id")));
|
||||
return (ids);
|
||||
}
|
||||
}
|
||||
@@ -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<QProcessMetaData> 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<ProcessSummaryLineInterface> 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<ProcessSummaryLine> 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<ProcessSummaryLineInterface> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+392
@@ -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<QProcessMetaData> 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<String, DeleteMode> 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<ProcessSummaryLineInterface> 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<ProcessSummaryLine> 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<ProcessSummaryLineInterface> 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<String, DeleteMode> 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<ProcessSummaryLineInterface> 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<ProcessSummaryLine> 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<ProcessSummaryLineInterface> lines)
|
||||
{
|
||||
restored.addSelfToListIfAnyCount(lines);
|
||||
nothing.addSelfToListIfAnyCount(lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<? extends AbstractTransformStep> transform,
|
||||
Class<? extends AbstractLoadStep> 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<? extends AbstractExtractStep> extract,
|
||||
Class<? extends AbstractTransformStep> transform, Class<? extends AbstractLoadStep> load, String preview, String permission)
|
||||
{
|
||||
Map<String, Serializable> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<QProcessMetaData> 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<QRecord> 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<QRecord> folders = selectedFolders(input);
|
||||
List<QRecord> 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<QRecord> 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<ProcessSummaryLineInterface> lines)
|
||||
{
|
||||
if(error != null)
|
||||
{
|
||||
lines.add(EtlSupport.text(Status.ERROR, error));
|
||||
}
|
||||
super.addLines(lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The ids of folders, for tests.
|
||||
*******************************************************************************/
|
||||
static List<Serializable> idsOf(List<QRecord> folders)
|
||||
{
|
||||
List<Serializable> ids = new ArrayList<>();
|
||||
folders.forEach(f -> ids.add(f.getValue("id")));
|
||||
return (ids);
|
||||
}
|
||||
}
|
||||
@@ -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<QProcessMetaData> 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<ProcessSummaryLineInterface> 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<ProcessSummaryLine> skipLines()
|
||||
{
|
||||
return (List.of(deleted, tooMany));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create download link, execute.
|
||||
*******************************************************************************/
|
||||
public static class DownloadLoad extends BaseLoad
|
||||
{
|
||||
private final ArrayList<ProcessSummaryLineInterface> 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<ProcessSummaryLineInterface> lines)
|
||||
{
|
||||
lines.addAll(links);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<QProcessMetaData> 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<String> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<QProcessMetaData> processes(ObjectStorageQBitConfig config)
|
||||
{
|
||||
List<QProcessMetaData> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<QProcessMetaData> 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<String, String> parseKeyValues(String text)
|
||||
{
|
||||
Map<String, String> 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<ObjectHeaders> 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<LinkedHashMap<String, String>>()
|
||||
{
|
||||
}) : new LinkedHashMap<>());
|
||||
}
|
||||
catch(java.io.IOException e)
|
||||
{
|
||||
h.setUserMetadata(new LinkedHashMap<>());
|
||||
}
|
||||
return (Optional.of(h));
|
||||
}
|
||||
try
|
||||
{
|
||||
Optional<StoredObject> 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<ProcessSummaryLineInterface> 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<ProcessSummaryLineInterface> 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<ObjectHeaders> 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<ProcessSummaryLineInterface> 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<ProcessSummaryLine> 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<ProcessSummaryLineInterface> 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<ObjectHeaders> current = currentHeaders(record, ActionSupport.storage(ActionSupport.aliasOf(record)));
|
||||
if(current.isEmpty())
|
||||
{
|
||||
unreadable.incrementCountAndAddPrimaryKey(id);
|
||||
return;
|
||||
}
|
||||
ObjectHeaders now = current.get();
|
||||
List<String> 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<String, String> 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<String> 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<String, String> 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<ProcessSummaryLineInterface> 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<ProcessSummaryLine> 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<String, String> metadata = new LinkedHashMap<>(now.getHeaders().getUserMetadata() == null ? Map.of() : now.getHeaders().getUserMetadata());
|
||||
for(Map.Entry<String, String> 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<ProcessSummaryLineInterface> 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<ProcessSummaryLineInterface> 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<ProcessSummaryLine> 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<ProcessSummaryLineInterface> lines)
|
||||
{
|
||||
done.addSelfToListIfAnyCount(lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<QProcessMetaData> 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<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> 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<ProcessSummaryLineInterface> 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<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Serializable, InputStream> 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<Serializable, InputStream> opener = DEFAULT_OPENER;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private UploadProcess()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replace how uploaded files are opened (tests).
|
||||
*******************************************************************************/
|
||||
public static void setOpener(Function<Serializable, InputStream> newOpener)
|
||||
{
|
||||
opener = newOpener == null ? DEFAULT_OPENER : newOpener;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The processes.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> 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<Serializable> files(RunBackendStepInput input)
|
||||
{
|
||||
Serializable value = input.getValue(FIELD_FILE);
|
||||
return (value instanceof List<?> list ? new ArrayList<>((List<Serializable>) 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<ProcessSummaryLineInterface> 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<ProcessSummaryLine> 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<ProcessSummaryLineInterface> 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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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%");
|
||||
}
|
||||
}
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user