Docker mit traefik und portainer
This commit is contained in:
@@ -30,15 +30,36 @@ const limiter = rateLimit({
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// CORS configuration
|
||||
app.use(cors({
|
||||
origin: config.cors.origin,
|
||||
credentials: true,
|
||||
}));
|
||||
// CORS configuration - Allow both development and production origins
|
||||
const allowedOrigins = [
|
||||
'http://localhost:5173', // Vite dev server
|
||||
'http://localhost:3000', // Docker frontend
|
||||
config.cors.origin // Environment configured origin
|
||||
].filter(Boolean);
|
||||
|
||||
// Add local network origins if CORS_ORIGIN is "*" (for local network access)
|
||||
const corsConfig = config.cors.origin === '*'
|
||||
? {
|
||||
origin: true, // Allow all origins for local network
|
||||
credentials: true,
|
||||
}
|
||||
: {
|
||||
origin: allowedOrigins,
|
||||
credentials: true,
|
||||
};
|
||||
|
||||
app.use(cors(corsConfig));
|
||||
|
||||
// Additional CORS headers for all requests
|
||||
app.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', 'http://localhost:5173');
|
||||
const origin = req.headers.origin;
|
||||
|
||||
if (config.cors.origin === '*') {
|
||||
// Allow all origins for local network access
|
||||
res.header('Access-Control-Allow-Origin', origin || '*');
|
||||
} else if (origin && allowedOrigins.includes(origin)) {
|
||||
res.header('Access-Control-Allow-Origin', origin);
|
||||
}
|
||||
res.header('Access-Control-Allow-Credentials', 'true');
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
|
||||
@@ -56,6 +77,9 @@ app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
// Request logging
|
||||
app.use(requestLogger);
|
||||
|
||||
// Static file serving for uploads
|
||||
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')));
|
||||
|
||||
// API routes
|
||||
app.use('/api/health', healthRoutes);
|
||||
app.use('/api/recipes', recipeRoutes);
|
||||
|
||||
@@ -1,11 +1,180 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { config } from '../config/config';
|
||||
|
||||
const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Utility function to get correct uploads directory path
|
||||
const getUploadsDir = (subPath?: string): string => {
|
||||
const baseDir = process.env.NODE_ENV === 'production'
|
||||
? path.join(process.cwd(), 'uploads')
|
||||
: path.join(process.cwd(), '../../uploads');
|
||||
|
||||
return subPath ? path.join(baseDir, subPath) : baseDir;
|
||||
};
|
||||
|
||||
// Configure multer for file uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const recipeNumber = req.body.recipeNumber || req.params.recipeNumber;
|
||||
if (!recipeNumber) {
|
||||
return cb(new Error('Recipe number is required'), '');
|
||||
}
|
||||
|
||||
const uploadDir = getUploadsDir(recipeNumber);
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const recipeNumber = req.body.recipeNumber || req.params.recipeNumber;
|
||||
if (!recipeNumber) {
|
||||
return cb(new Error('Recipe number is required'), '');
|
||||
}
|
||||
|
||||
// Get existing files count to determine next index
|
||||
const uploadDir = getUploadsDir(recipeNumber);
|
||||
const existingFiles = fs.existsSync(uploadDir)
|
||||
? fs.readdirSync(uploadDir).filter(f => f.match(new RegExp(`^${recipeNumber}_\\d+\\.jpg$`)))
|
||||
: [];
|
||||
|
||||
const nextIndex = existingFiles.length;
|
||||
const filename = `${recipeNumber}_${nextIndex}.jpg`;
|
||||
|
||||
cb(null, filename);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: config.upload.maxFileSize, // 5MB
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type. Only JPEG, PNG and WebP are allowed.'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Upload images for a recipe
|
||||
router.post('/upload/:recipeId', upload.array('images', 10), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { recipeId } = req.params;
|
||||
const files = req.files as Express.Multer.File[];
|
||||
|
||||
if (!recipeId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Recipe ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'No files uploaded',
|
||||
});
|
||||
}
|
||||
|
||||
// Get recipe to validate it exists and get recipe number
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: parseInt(recipeId) }
|
||||
});
|
||||
|
||||
if (!recipe) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Recipe not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Create database entries for uploaded images
|
||||
const imagePromises = files.map(file => {
|
||||
const relativePath = `uploads/${recipe.recipeNumber}/${file.filename}`;
|
||||
return prisma.recipeImage.create({
|
||||
data: {
|
||||
recipeId: parseInt(recipeId),
|
||||
filePath: relativePath,
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const images = await Promise.all(imagePromises);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: images,
|
||||
message: `${files.length} images uploaded successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
// Clean up uploaded files if database operation fails
|
||||
if (req.files) {
|
||||
const files = req.files as Express.Multer.File[];
|
||||
files.forEach(file => {
|
||||
if (fs.existsSync(file.path)) {
|
||||
fs.unlinkSync(file.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete an image
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Image ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
const image = await prisma.recipeImage.findUnique({
|
||||
where: { id: parseInt(id) }
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Image not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Delete file from filesystem
|
||||
const fullPath = path.join(process.cwd(), '../..', image.filePath);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
fs.unlinkSync(fullPath);
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
await prisma.recipeImage.delete({
|
||||
where: { id: parseInt(id) }
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Image deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get all images for a recipe by recipe ID
|
||||
router.get('/recipe/:recipeId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
@@ -46,7 +215,7 @@ router.get('/serve/:imagePath(*)', (req: Request, res: Response, next: NextFunct
|
||||
|
||||
// Remove leading 'uploads/' if present to avoid duplication
|
||||
const cleanPath = imagePath.replace(/^uploads\//, '');
|
||||
const fullPath = path.join(process.cwd(), '../../uploads', cleanPath);
|
||||
const fullPath = path.join(getUploadsDir(), cleanPath);
|
||||
|
||||
console.log(`Serving image: ${imagePath} -> ${fullPath}`);
|
||||
|
||||
@@ -60,9 +229,17 @@ router.get('/serve/:imagePath(*)', (req: Request, res: Response, next: NextFunct
|
||||
});
|
||||
}
|
||||
|
||||
// Set CORS headers for images
|
||||
// Set CORS headers for images - support multiple origins including local network
|
||||
const allowedOrigins = ['http://localhost:5173', 'http://localhost:3000'];
|
||||
const origin = req.headers.origin;
|
||||
|
||||
// Check if CORS_ORIGIN is set to "*" for local network access
|
||||
const corsOrigin = process.env.CORS_ORIGIN === '*'
|
||||
? (origin || '*')
|
||||
: (origin && allowedOrigins.includes(origin)) ? origin : 'http://localhost:3000';
|
||||
|
||||
res.set({
|
||||
'Access-Control-Allow-Origin': 'http://localhost:5173',
|
||||
'Access-Control-Allow-Origin': corsOrigin,
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Cache-Control': 'public, max-age=31536000', // Cache for 1 year
|
||||
});
|
||||
|
||||
@@ -91,9 +91,17 @@ router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
message: 'Recipe ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
const recipeId = parseInt(id);
|
||||
if (isNaN(recipeId)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid recipe ID format',
|
||||
});
|
||||
}
|
||||
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: parseInt(id) },
|
||||
where: { id: recipeId },
|
||||
include: {
|
||||
images: true,
|
||||
ingredientsList: true,
|
||||
|
||||
Reference in New Issue
Block a user