Docker mit traefik und portainer

This commit is contained in:
rxf
2025-09-22 16:35:59 +02:00
parent 6d04ab93c0
commit a255543da6
64 changed files with 5421 additions and 25 deletions

View File

@@ -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);

View File

@@ -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
});

View File

@@ -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,