const { S3Client, PutObjectCommand, GetObjectCommand, HeadObjectCommand, } = require("@aws-sdk/client-s3"); const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); const { getAWSConfig } = require("../config/aws"); const { v4: uuidv4 } = require("uuid"); const path = require("path"); const logger = require("../utils/logger"); // Cache-Control: 24 hours for public content (allows moderation takedowns to propagate) // Private content (messages, condition-checks) uses presigned URLs so cache doesn't matter as much const DEFAULT_CACHE_MAX_AGE = 86400; // 24 hours in seconds const UPLOAD_CONFIGS = { profile: { folder: "profiles", maxSize: 5 * 1024 * 1024, cacheMaxAge: DEFAULT_CACHE_MAX_AGE, public: true, }, item: { folder: "items", maxSize: 10 * 1024 * 1024, cacheMaxAge: DEFAULT_CACHE_MAX_AGE, public: true, }, message: { folder: "messages", maxSize: 5 * 1024 * 1024, cacheMaxAge: 3600, public: false, }, forum: { folder: "forum", maxSize: 10 * 1024 * 1024, cacheMaxAge: DEFAULT_CACHE_MAX_AGE, public: true, }, "condition-check": { folder: "condition-checks", maxSize: 10 * 1024 * 1024, cacheMaxAge: 3600, public: false, }, }; const ALLOWED_TYPES = [ "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", ]; const PRESIGN_EXPIRY = 300; // 5 minutes class S3Service { constructor() { this.client = null; this.bucket = null; this.region = null; this.enabled = false; } /** * Check if S3 is enabled * @returns {boolean} */ isEnabled() { return this.enabled; } initialize() { if (process.env.S3_ENABLED !== "true") { logger.info("S3 Service disabled (S3_ENABLED !== true)"); this.enabled = false; return; } // S3 is enabled - validate required configuration const bucket = process.env.S3_BUCKET; if (!bucket) { logger.error("S3_ENABLED=true but S3_BUCKET is not set"); process.exit(1); } try { const config = getAWSConfig(); this.client = new S3Client({ ...config, // Disable automatic checksums - browser uploads can't calculate them requestChecksumCalculation: "WHEN_REQUIRED", }); this.bucket = bucket; this.region = config.region || "us-east-1"; this.enabled = true; logger.info("S3 Service initialized", { bucket: this.bucket, region: this.region, }); } catch (error) { logger.error("Failed to initialize S3 Service", { error: error.message, stack: error.stack }); process.exit(1); } } /** * Check if image processing (metadata stripping) is enabled * When enabled, uploads go to staging/ prefix and Lambda processes them * @returns {boolean} */ isImageProcessingEnabled() { return process.env.IMAGE_PROCESSING_ENABLED === "true"; } /** * Get a presigned URL for uploading a file directly to S3 * @param {string} uploadType - Type of upload (profile, item, message, forum, condition-check) * @param {string} contentType - MIME type of the file * @param {string} fileName - Original filename (used for extension) * @param {number} fileSize - File size in bytes (required for size enforcement) * @param {string} [baseKey] - Optional base key (UUID) for coordinated variant uploads * @returns {Promise<{uploadUrl: string, key: string, stagingKey: string|null, publicUrl: string, expiresAt: Date}>} */ async getPresignedUploadUrl(uploadType, contentType, fileName, fileSize, baseKey = null) { if (!this.enabled) { throw new Error("S3 storage is not enabled"); } const config = UPLOAD_CONFIGS[uploadType]; if (!config) { throw new Error(`Invalid upload type: ${uploadType}`); } if (!ALLOWED_TYPES.includes(contentType)) { throw new Error(`Invalid content type: ${contentType}`); } if (!fileSize || fileSize <= 0) { throw new Error("File size is required"); } if (fileSize > config.maxSize) { throw new Error( `File too large. Maximum size is ${config.maxSize / (1024 * 1024)}MB` ); } // Extract known variant suffix from fileName if present (e.g., "photo_th.jpg" -> "_th") const ext = path.extname(fileName) || this.getExtFromMime(contentType); const baseName = path.basename(fileName, ext); // Only recognize known variant suffixes let suffix = ""; if (baseName.endsWith("_th")) { suffix = "_th"; } else if (baseName.endsWith("_md")) { suffix = "_md"; } // Use provided baseKey or generate new UUID const uuid = baseKey || uuidv4(); // Final key is where the processed image will be (what frontend stores in DB) const finalKey = `${config.folder}/${uuid}${suffix}${ext}`; // When image processing is enabled, upload to staging/ prefix // Lambda will process and move to final location const useStaging = this.isImageProcessingEnabled(); const uploadKey = useStaging ? `staging/${finalKey}` : finalKey; const cacheDirective = config.public ? "public" : "private"; const command = new PutObjectCommand({ Bucket: this.bucket, Key: uploadKey, ContentType: contentType, ContentLength: fileSize, // Enforce exact file size CacheControl: `${cacheDirective}, max-age=${config.cacheMaxAge}`, }); const uploadUrl = await getSignedUrl(this.client, command, { expiresIn: PRESIGN_EXPIRY, }); return { uploadUrl, key: finalKey, // Frontend stores this in database stagingKey: useStaging ? uploadKey : null, // Actual upload location (if staging enabled) publicUrl: config.public ? `https://${this.bucket}.s3.${this.region}.amazonaws.com/${finalKey}` : null, expiresAt: new Date(Date.now() + PRESIGN_EXPIRY * 1000), }; } /** * Get a presigned URL for downloading a private file from S3 * @param {string} key - S3 object key * @param {number} expiresIn - Expiration time in seconds (default 1 hour) * @returns {Promise} */ async getPresignedDownloadUrl(key, expiresIn = 3600) { if (!this.enabled) { throw new Error("S3 storage is not enabled"); } const command = new GetObjectCommand({ Bucket: this.bucket, Key: key, }); return getSignedUrl(this.client, command, { expiresIn }); } /** * Get the public URL for a file (only for public folders) * @param {string} key - S3 object key * @returns {string|null} */ getPublicUrl(key) { if (!this.enabled) { return null; } return `https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`; } /** * Verify that a file exists in S3 * @param {string} key - S3 object key * @returns {Promise} */ async verifyUpload(key) { if (!this.enabled) { return false; } try { await this.client.send( new HeadObjectCommand({ Bucket: this.bucket, Key: key, }) ); return true; } catch (err) { if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) { return false; } throw err; } } /** * Get file extension from MIME type * @param {string} mime - MIME type * @returns {string} */ getExtFromMime(mime) { const map = { "image/jpeg": ".jpg", "image/jpg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", }; return map[mime] || ".jpg"; } } const s3Service = new S3Service(); module.exports = s3Service;