Blame | Last modification | View Log | RSS feed
package com.spice.profitmandi.service.storage;import com.amazonaws.ClientConfiguration;import com.amazonaws.auth.AWSStaticCredentialsProvider;import com.amazonaws.auth.BasicAWSCredentials;import com.amazonaws.client.builder.AwsClientBuilder;import com.amazonaws.services.s3.AmazonS3;import com.amazonaws.services.s3.AmazonS3ClientBuilder;import com.amazonaws.services.s3.model.ObjectMetadata;import com.amazonaws.services.s3.model.S3Object;import org.apache.logging.log4j.LogManager;import org.apache.logging.log4j.Logger;import org.springframework.beans.factory.annotation.Value;import org.springframework.stereotype.Service;import java.io.InputStream;/*** S3-compatible object storage, added for LMS call-recording retention (SOP §17 — 24 months).** <p>The app's existing file store ({@code dtr.document} + {@code new File(path + name)}) is local* disk, which does not carry the volume recordings imply (~1 MB/min of stereo audio) and does not* survive a multi-node deploy. This service is deliberately generic rather than recording-specific* so other bulk artefacts can move off local disk later.** <p>Works against AWS S3 or any S3-compatible endpoint (MinIO, Wasabi, DO Spaces): leave* {@code endpoint} blank for real AWS, or set it plus {@code pathStyle=true} for the others.** <p>The client is built lazily and the service reports {@link #isEnabled()} rather than throwing at* startup — an unconfigured store must not stop the app from booting, it must only stop the archival* worker from claiming it stored anything.*/@Servicepublic class ObjectStoreService {private static final Logger LOGGER = LogManager.getLogger(ObjectStoreService.class);@Value("${lms.recording.store.enabled:false}")private boolean enabled;/** Blank for real AWS; set for MinIO/Wasabi/Spaces. */@Value("${lms.recording.store.endpoint:}")private String endpoint;@Value("${lms.recording.store.region:ap-south-1}")private String region;@Value("${lms.recording.store.bucket:}")private String bucket;@Value("${lms.recording.store.accessKey:}")private String accessKey;@Value("${lms.recording.store.secretKey:}")private String secretKey;/** Non-AWS S3 implementations generally need path-style addressing. */@Value("${lms.recording.store.pathStyle:true}")private boolean pathStyle;private volatile AmazonS3 client;/** False when the store is switched off or half-configured — callers must not assume a write. */public boolean isEnabled() {return enabled&& bucket != null && !bucket.trim().isEmpty()&& accessKey != null && !accessKey.trim().isEmpty()&& secretKey != null && !secretKey.trim().isEmpty();}public String getBucket() {return bucket;}/*** Store an object. {@code contentLength} must be accurate — S3 streams without buffering the* whole body, and a wrong length is a corrupt upload rather than an error.** @return the key it was stored under, for persisting alongside the row that owns it*/public String put(String key, InputStream content, long contentLength, String contentType) {requireEnabled();ObjectMetadata metadata = new ObjectMetadata();metadata.setContentLength(contentLength);if (contentType != null && !contentType.trim().isEmpty()) {metadata.setContentType(contentType);}client().putObject(bucket, key, content, metadata);LOGGER.info("Stored object {} ({} bytes) in bucket {}", key, contentLength, bucket);return key;}/*** Open an object for reading. The caller owns the stream and MUST close it — an unclosed S3* object holds a connection out of the pool until it times out.*/public S3Object get(String key) {requireEnabled();return client().getObject(bucket, key);}public boolean exists(String key) {if (!isEnabled()) {return false;}try {return client().doesObjectExist(bucket, key);} catch (Exception e) {LOGGER.warn("Could not check object {} in bucket {}", key, bucket, e);return false;}}public void delete(String key) {requireEnabled();client().deleteObject(bucket, key);}private void requireEnabled() {if (!isEnabled()) {throw new IllegalStateException("Object store is not configured — set lms.recording.store.{enabled,bucket,accessKey,secretKey}");}}/** Double-checked lazy build so a bad config surfaces on first use, not at context startup. */private AmazonS3 client() {AmazonS3 local = client;if (local == null) {synchronized (this) {local = client;if (local == null) {local = build();client = local;}}}return local;}private AmazonS3 build() {ClientConfiguration config = new ClientConfiguration();config.setConnectionTimeout(5000);// Recordings are a few MB; generous enough for a slow link, bounded enough that the// archival worker cannot wedge on a hanging endpoint.config.setSocketTimeout(60000);AmazonS3ClientBuilder builder = AmazonS3ClientBuilder.standard().withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKey.trim(), secretKey.trim()))).withClientConfiguration(config);if (endpoint != null && !endpoint.trim().isEmpty()) {builder = builder.withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(endpoint.trim(), region)).withPathStyleAccessEnabled(pathStyle);} else {builder = builder.withRegion(region);}LOGGER.info("Object store client built for bucket {} (endpoint={})", bucket,endpoint == null || endpoint.trim().isEmpty() ? "aws" : endpoint);return builder.build();}}