Files
rentall-app/backend/services/s3Service.js
2026-01-14 12:11:50 -05:00

270 lines
7.5 KiB
JavaScript

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<string>}
*/
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<boolean>}
*/
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;