remove s3
This commit is contained in:
parent
afcaa0b217
commit
df76a1f0f2
3 changed files with 1312 additions and 126 deletions
1286
backend/package-lock.json
generated
Normal file
1286
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -11,7 +11,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.17.0",
|
"@prisma/client": "^5.17.0",
|
||||||
"@aws-sdk/client-s3": "^3.583.0",
|
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { fileURLToPath } from 'url';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
|
@ -20,28 +20,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const uploadDir = path.join(__dirname, '..', 'uploads');
|
const uploadDir = path.join(__dirname, '..', 'uploads');
|
||||||
const fsPromises = fs.promises;
|
const fsPromises = fs.promises;
|
||||||
const assetBaseUrl = (process.env.ASSET_BASE_URL || '').replace(/\/$/, '');
|
|
||||||
const s3Bucket = process.env.S3_BUCKET || '';
|
|
||||||
const s3Region = process.env.S3_REGION || 'us-east-1';
|
|
||||||
const s3Endpoint = process.env.S3_ENDPOINT || undefined;
|
|
||||||
const s3AccessKey = process.env.S3_ACCESS_KEY || '';
|
|
||||||
const s3SecretKey = process.env.S3_SECRET_KEY || '';
|
|
||||||
const s3ForcePathStyle = parseBoolean(process.env.S3_FORCE_PATH_STYLE ?? 'true');
|
|
||||||
const useS3Storage = Boolean(s3Bucket);
|
|
||||||
const s3Client = useS3Storage
|
|
||||||
? new S3Client({
|
|
||||||
region: s3Region,
|
|
||||||
endpoint: s3Endpoint,
|
|
||||||
forcePathStyle: s3ForcePathStyle,
|
|
||||||
credentials:
|
|
||||||
s3AccessKey && s3SecretKey
|
|
||||||
? {
|
|
||||||
accessKeyId: s3AccessKey,
|
|
||||||
secretAccessKey: s3SecretKey,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const jwtSecret = process.env.JWT_SECRET || 'super-secret-key';
|
const jwtSecret = process.env.JWT_SECRET || 'super-secret-key';
|
||||||
const defaultFrontendOrigins = ['http://localhost:3100', 'http://localhost:3000'];
|
const defaultFrontendOrigins = ['http://localhost:3100', 'http://localhost:3000'];
|
||||||
const configuredOrigins = process.env.FRONTEND_ORIGINS || process.env.FRONTEND_ORIGIN || '';
|
const configuredOrigins = process.env.FRONTEND_ORIGINS || process.env.FRONTEND_ORIGIN || '';
|
||||||
|
|
@ -81,34 +60,26 @@ const corsOptions = {
|
||||||
const port = process.env.PORT || 4000;
|
const port = process.env.PORT || 4000;
|
||||||
const host = process.env.HOST || '0.0.0.0';
|
const host = process.env.HOST || '0.0.0.0';
|
||||||
|
|
||||||
let upload;
|
if (!fs.existsSync(uploadDir)) {
|
||||||
|
|
||||||
if (useS3Storage) {
|
|
||||||
upload = multer({ storage: multer.memoryStorage() });
|
|
||||||
} else {
|
|
||||||
if (!fs.existsSync(uploadDir)) {
|
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const diskStorage = multer.diskStorage({
|
const diskStorage = multer.diskStorage({
|
||||||
destination: (_req, _file, cb) => cb(null, uploadDir),
|
destination: (_req, _file, cb) => cb(null, uploadDir),
|
||||||
filename: (_req, file, cb) => {
|
filename: (_req, file, cb) => {
|
||||||
const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const ext = path.extname(file.originalname) || '.png';
|
const ext = path.extname(file.originalname) || '.png';
|
||||||
cb(null, `${unique}${ext}`);
|
cb(null, `${unique}${ext}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
upload = multer({ storage: diskStorage });
|
const upload = multer({ storage: diskStorage });
|
||||||
}
|
|
||||||
|
|
||||||
app.use(cors(corsOptions));
|
app.use(cors(corsOptions));
|
||||||
app.options('*', cors(corsOptions));
|
app.options('*', cors(corsOptions));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
if (!useS3Storage) {
|
app.use('/uploads', express.static(uploadDir));
|
||||||
app.use('/uploads', express.static(uploadDir));
|
|
||||||
}
|
|
||||||
|
|
||||||
function setAuthCookie(res, token) {
|
function setAuthCookie(res, token) {
|
||||||
res.cookie('token', token, {
|
res.cookie('token', token, {
|
||||||
|
|
@ -319,13 +290,13 @@ app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) =>
|
||||||
try {
|
try {
|
||||||
const { title, description, url, imageMode, imageUrl, isPrivate } = req.body;
|
const { title, description, url, imageMode, imageUrl, isPrivate } = req.body;
|
||||||
if (!title || !title.trim()) {
|
if (!title || !title.trim()) {
|
||||||
if (!useS3Storage && req.file?.filename) {
|
if (req.file?.filename) {
|
||||||
await deleteImageFile(req.file.filename);
|
await deleteImageFile(req.file.filename);
|
||||||
}
|
}
|
||||||
return res.status(400).json({ error: 'Title is required' });
|
return res.status(400).json({ error: 'Title is required' });
|
||||||
}
|
}
|
||||||
if (url && !isValidUrl(url)) {
|
if (url && !isValidUrl(url)) {
|
||||||
if (!useS3Storage && req.file?.filename) {
|
if (req.file?.filename) {
|
||||||
await deleteImageFile(req.file.filename);
|
await deleteImageFile(req.file.filename);
|
||||||
}
|
}
|
||||||
return res.status(400).json({ error: 'Planning link must be a valid URL' });
|
return res.status(400).json({ error: 'Planning link must be a valid URL' });
|
||||||
|
|
@ -335,7 +306,7 @@ app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) =>
|
||||||
let storedImagePath = null;
|
let storedImagePath = null;
|
||||||
if (imageMode === 'url') {
|
if (imageMode === 'url') {
|
||||||
if (!imageUrl || !isValidUrl(imageUrl)) {
|
if (!imageUrl || !isValidUrl(imageUrl)) {
|
||||||
if (!useS3Storage && req.file?.filename) {
|
if (req.file?.filename) {
|
||||||
await deleteImageFile(req.file.filename);
|
await deleteImageFile(req.file.filename);
|
||||||
}
|
}
|
||||||
return res.status(400).json({ error: 'Image URL must be valid' });
|
return res.status(400).json({ error: 'Image URL must be valid' });
|
||||||
|
|
@ -374,7 +345,7 @@ app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) =>
|
||||||
console.error(error);
|
console.error(error);
|
||||||
if (uploadedFile?.path) {
|
if (uploadedFile?.path) {
|
||||||
await deleteImageFile(uploadedFile.path);
|
await deleteImageFile(uploadedFile.path);
|
||||||
} else if (!useS3Storage && req.file?.filename) {
|
} else if (req.file?.filename) {
|
||||||
await deleteImageFile(req.file.filename);
|
await deleteImageFile(req.file.filename);
|
||||||
}
|
}
|
||||||
return res.status(500).json({ error: 'Internal server error' });
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
|
@ -396,14 +367,14 @@ app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, r
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!title || !title.trim()) {
|
if (!title || !title.trim()) {
|
||||||
if (!useS3Storage && req.file?.filename) {
|
if (req.file?.filename) {
|
||||||
await deleteImageFile(req.file.filename);
|
await deleteImageFile(req.file.filename);
|
||||||
}
|
}
|
||||||
return res.status(400).json({ error: 'Title is required' });
|
return res.status(400).json({ error: 'Title is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url && !isValidUrl(url)) {
|
if (url && !isValidUrl(url)) {
|
||||||
if (!useS3Storage && req.file?.filename) {
|
if (req.file?.filename) {
|
||||||
await deleteImageFile(req.file.filename);
|
await deleteImageFile(req.file.filename);
|
||||||
}
|
}
|
||||||
return res.status(400).json({ error: 'Planning link must be a valid URL' });
|
return res.status(400).json({ error: 'Planning link must be a valid URL' });
|
||||||
|
|
@ -411,7 +382,7 @@ app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, r
|
||||||
|
|
||||||
const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } });
|
const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } });
|
||||||
if (!base) {
|
if (!base) {
|
||||||
if (!useS3Storage && req.file?.filename) {
|
if (req.file?.filename) {
|
||||||
await deleteImageFile(req.file.filename);
|
await deleteImageFile(req.file.filename);
|
||||||
}
|
}
|
||||||
return res.status(404).json({ error: 'Base not found' });
|
return res.status(404).json({ error: 'Base not found' });
|
||||||
|
|
@ -431,7 +402,7 @@ app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, r
|
||||||
} else if (imageMode === 'url') {
|
} else if (imageMode === 'url') {
|
||||||
if (imageUrl) {
|
if (imageUrl) {
|
||||||
if (!isValidUrl(imageUrl)) {
|
if (!isValidUrl(imageUrl)) {
|
||||||
if (!useS3Storage && req.file?.filename) {
|
if (req.file?.filename) {
|
||||||
await deleteImageFile(req.file.filename);
|
await deleteImageFile(req.file.filename);
|
||||||
}
|
}
|
||||||
return res.status(400).json({ error: 'Image URL must be valid' });
|
return res.status(400).json({ error: 'Image URL must be valid' });
|
||||||
|
|
@ -478,7 +449,7 @@ app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, r
|
||||||
console.error(error);
|
console.error(error);
|
||||||
if (uploadedFile?.path) {
|
if (uploadedFile?.path) {
|
||||||
await deleteImageFile(uploadedFile.path);
|
await deleteImageFile(uploadedFile.path);
|
||||||
} else if (!useS3Storage && req.file?.filename) {
|
} else if (req.file?.filename) {
|
||||||
await deleteImageFile(req.file.filename);
|
await deleteImageFile(req.file.filename);
|
||||||
}
|
}
|
||||||
return res.status(500).json({ error: 'Internal server error' });
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
|
@ -914,71 +885,16 @@ function buildStoredImageUrl(imagePath) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useS3Storage) {
|
|
||||||
const base = assetBaseUrl || deriveS3EndpointBase();
|
|
||||||
if (!base) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return `${base.replace(/\/$/, '')}/${imagePath.replace(/^\/+/, '')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/uploads/${imagePath.replace(/^\/+/, '')}`;
|
return `/uploads/${imagePath.replace(/^\/+/, '')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveS3EndpointBase() {
|
|
||||||
if (!s3Endpoint) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedEndpoint = s3Endpoint.replace(/\/$/, '');
|
|
||||||
if (s3ForcePathStyle || !trimmedEndpoint) {
|
|
||||||
return `${trimmedEndpoint}/${s3Bucket}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const endpointUrl = new URL(trimmedEndpoint);
|
|
||||||
const portPart = endpointUrl.port ? `:${endpointUrl.port}` : '';
|
|
||||||
return `${endpointUrl.protocol}//${s3Bucket}.${endpointUrl.hostname}${portPart}`;
|
|
||||||
} catch (_error) {
|
|
||||||
return `${trimmedEndpoint}/${s3Bucket}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStorageKey(originalName = '') {
|
|
||||||
const ext = path.extname(originalName || '').trim().slice(0, 10) || '.png';
|
|
||||||
return `bases/${Date.now()}-${randomUUID()}${ext}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function persistUploadedFile(file) {
|
async function persistUploadedFile(file) {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useS3Storage) {
|
|
||||||
const key = buildStorageKey(file.originalname);
|
|
||||||
try {
|
|
||||||
await s3Client.send(
|
|
||||||
new PutObjectCommand({
|
|
||||||
Bucket: s3Bucket,
|
|
||||||
Key: key,
|
|
||||||
Body: file.buffer,
|
|
||||||
ContentType: file.mimetype || 'application/octet-stream',
|
|
||||||
Metadata: {
|
|
||||||
originalname: file.originalname || 'uploaded-image',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to upload image to S3', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
path: key,
|
|
||||||
publicUrl: buildStoredImageUrl(key),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: file.filename,
|
path: file.filename,
|
||||||
publicUrl: buildStoredImageUrl(file.filename),
|
publicUrl: buildStoredImageUrl(file.filename),
|
||||||
|
|
@ -988,22 +904,6 @@ async function persistUploadedFile(file) {
|
||||||
async function deleteImageFile(imagePath) {
|
async function deleteImageFile(imagePath) {
|
||||||
if (!imagePath) return;
|
if (!imagePath) return;
|
||||||
|
|
||||||
if (useS3Storage) {
|
|
||||||
try {
|
|
||||||
await s3Client.send(
|
|
||||||
new DeleteObjectCommand({
|
|
||||||
Bucket: s3Bucket,
|
|
||||||
Key: imagePath,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.$metadata?.httpStatusCode !== 404) {
|
|
||||||
console.error(`Failed to delete image from S3 with key ${imagePath}`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = path.isAbsolute(imagePath) ? imagePath : path.join(uploadDir, imagePath);
|
const filePath = path.isAbsolute(imagePath) ? imagePath : path.join(uploadDir, imagePath);
|
||||||
try {
|
try {
|
||||||
await fsPromises.unlink(filePath);
|
await fsPromises.unlink(filePath);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue