Subversion Repositories SmartDukaan

Rev

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.
 */
@Service
public 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();
    }
}