feat: StorageService S3/MinIO, POST /users, invoice upload, portal dashboard §08 statuses + notifications
This commit is contained in:
@@ -3,38 +3,121 @@ import { ConfigService } from "@nestjs/config";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import { S3Client, DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
/**
|
||||
* StorageService — dual mode:
|
||||
* • If S3_ENDPOINT is set → MinIO / S3-compatible storage
|
||||
* • Otherwise → local disk under ./uploads/ (development)
|
||||
*/
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
private readonly uploadDir: string;
|
||||
private readonly baseUrl: string;
|
||||
private readonly s3?: S3Client;
|
||||
private readonly bucket?: string;
|
||||
private readonly s3Public?: string;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
this.uploadDir = path.join(process.cwd(), "uploads");
|
||||
this.baseUrl = this.config.get("API_URL", "http://localhost:3001");
|
||||
// ensure uploads dir exists
|
||||
if (!fs.existsSync(this.uploadDir)) {
|
||||
fs.mkdirSync(this.uploadDir, { recursive: true });
|
||||
this.baseUrl = this.config.get("API_URL", "http://localhost:3001");
|
||||
|
||||
const endpoint = this.config.get<string>("S3_ENDPOINT");
|
||||
const accessKey = this.config.get<string>("S3_ACCESS_KEY");
|
||||
const secretKey = this.config.get<string>("S3_SECRET_KEY");
|
||||
const region = this.config.get<string>("S3_REGION", "us-east-1");
|
||||
const forcePath = this.config.get<string>("S3_FORCE_PATH_STYLE", "false") === "true";
|
||||
this.bucket = this.config.get<string>("S3_BUCKET", "moraworld");
|
||||
this.s3Public = this.config.get<string>("S3_PUBLIC_URL") ?? endpoint;
|
||||
|
||||
if (endpoint && accessKey && secretKey) {
|
||||
this.s3 = new S3Client({
|
||||
endpoint,
|
||||
region,
|
||||
forcePathStyle: forcePath,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
});
|
||||
this.logger.log(`[STORAGE] S3/MinIO mode — endpoint: ${endpoint}, bucket: ${this.bucket}`);
|
||||
} else {
|
||||
// Local disk fallback
|
||||
if (!fs.existsSync(this.uploadDir)) {
|
||||
fs.mkdirSync(this.uploadDir, { recursive: true });
|
||||
}
|
||||
this.logger.log("[STORAGE] Local disk mode (no S3_ENDPOINT configured)");
|
||||
}
|
||||
}
|
||||
|
||||
async saveFile(subdir: string, originalName: string, buffer: Buffer): Promise<string> {
|
||||
const ext = path.extname(originalName);
|
||||
get isS3Mode(): boolean { return !!this.s3; }
|
||||
|
||||
/**
|
||||
* Store a file. Returns its public URL.
|
||||
* @param subdir e.g. "packages/pkg-123" or "invoices"
|
||||
* @param originalName e.g. "photo.jpg"
|
||||
* @param buffer file contents
|
||||
* @param mimeType e.g. "image/jpeg"
|
||||
*/
|
||||
async saveFile(
|
||||
subdir: string,
|
||||
originalName: string,
|
||||
buffer: Buffer,
|
||||
mimeType = "application/octet-stream",
|
||||
): Promise<string> {
|
||||
const ext = path.extname(originalName);
|
||||
const filename = `${randomUUID()}${ext}`;
|
||||
const key = `${subdir}/${filename}`;
|
||||
|
||||
if (this.s3) {
|
||||
await this.s3.send(new PutObjectCommand({
|
||||
Bucket: this.bucket!,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: mimeType,
|
||||
// ACL not used — presigned URLs or public endpoint handle access
|
||||
}));
|
||||
// Return public path (either bucket/key or via configured public URL)
|
||||
return `${this.s3Public}/${this.bucket}/${key}`;
|
||||
}
|
||||
|
||||
// Local disk
|
||||
const dir = path.join(this.uploadDir, subdir);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const filepath = path.join(dir, filename);
|
||||
fs.writeFileSync(filepath, buffer);
|
||||
// Return URL relative to API base
|
||||
return `${this.baseUrl}/uploads/${subdir}/${filename}`;
|
||||
fs.writeFileSync(path.join(dir, filename), buffer);
|
||||
return `${this.baseUrl}/uploads/${key}`;
|
||||
}
|
||||
|
||||
async deleteFile(url: string): Promise<void> {
|
||||
/**
|
||||
* Generate a presigned GET URL for an S3 key (valid 1 hour).
|
||||
* Falls through to a direct URL if not in S3 mode.
|
||||
*/
|
||||
async presign(keyOrUrl: string, expiresIn = 3600): Promise<string> {
|
||||
if (!this.s3) return keyOrUrl;
|
||||
// If a full URL was passed, extract the key
|
||||
const key = keyOrUrl.includes(`/${this.bucket}/`)
|
||||
? keyOrUrl.split(`/${this.bucket}/`)[1]
|
||||
: keyOrUrl;
|
||||
return getSignedUrl(
|
||||
this.s3,
|
||||
new GetObjectCommand({ Bucket: this.bucket!, Key: key }),
|
||||
{ expiresIn },
|
||||
);
|
||||
}
|
||||
|
||||
/** Delete a file by its URL or S3 key */
|
||||
async deleteFile(urlOrKey: string): Promise<void> {
|
||||
try {
|
||||
const relative = url.replace(/^https?:\/\/[^/]+\/uploads\//, "");
|
||||
const filepath = path.join(this.uploadDir, relative);
|
||||
if (fs.existsSync(filepath)) fs.unlinkSync(filepath);
|
||||
if (this.s3) {
|
||||
const key = urlOrKey.includes(`/${this.bucket}/`)
|
||||
? urlOrKey.split(`/${this.bucket}/`)[1]
|
||||
: urlOrKey;
|
||||
await this.s3.send(new DeleteObjectCommand({ Bucket: this.bucket!, Key: key }));
|
||||
} else {
|
||||
const relative = urlOrKey.replace(/^https?:\/\/[^/]+\/uploads\//, "");
|
||||
const filepath = path.join(this.uploadDir, relative);
|
||||
if (fs.existsSync(filepath)) fs.unlinkSync(filepath);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
this.logger.warn(`deleteFile failed: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user