feat: add CI/CD pipeline, backup system, and deployment automation
Some checks failed
CI/CD Pipeline / Run Tests (pull_request) Has been cancelled
CI/CD Pipeline / Code Quality (pull_request) Has been cancelled
CI Pipeline / Lint Code (pull_request) Has been cancelled
CI Pipeline / Test API Package (pull_request) Has been cancelled
CI Pipeline / Test Web Package (pull_request) Has been cancelled
CI Pipeline / Test Shared Package (pull_request) Has been cancelled
Docker Build & Deploy / Build Docker Images (pull_request) Has been cancelled
E2E Tests / End-to-End Tests (pull_request) Has been cancelled
E2E Tests / E2E Tests (Mobile) (pull_request) Has been cancelled
Security Scanning / NPM Audit (pull_request) Has been cancelled
Security Scanning / Dependency License Check (pull_request) Has been cancelled
Security Scanning / Code Quality Scan (pull_request) Has been cancelled
Security Scanning / Docker Image Security (pull_request) Has been cancelled
CI/CD Pipeline / Build and Push Docker Images (pull_request) Has been cancelled
CI Pipeline / Build All Packages (pull_request) Has been cancelled
CI Pipeline / Generate Coverage Report (pull_request) Has been cancelled
Docker Build & Deploy / Push Docker Images (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Staging (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Production (pull_request) Has been cancelled
Security Scanning / Security Summary (pull_request) Has been cancelled

## Summary
- Add complete CI/CD pipeline with Gitea Actions for automated testing, building, and deployment
- Implement backup and restore system with full database and file backup to ZIP
- Add deployment automation with webhook receiver and systemd service
- Enhance recipe editing UI with improved ingredient parsing and cooking mode features
- Add comprehensive documentation for CI/CD, deployment, and backup features

## CI/CD Pipeline
- New workflow in .gitea/workflows/ci-cd.yml with test, build, and deploy stages
- Automated Docker image building and pushing to registry
- Webhook-triggered deployments to production servers

## Backup & Restore
- New backup service with ZIP creation including database dump and uploads
- REST API endpoints for create, list, download, restore, and delete operations
- Configurable backup path via BACKUP_PATH environment variable

## Deployment
- Automated deployment scripts (deploy.sh, manual-deploy.sh)
- Webhook receiver with systemd service for deployment triggers
- Environment configuration template (.env.deploy.example)

## Documentation
- docs/CI-CD-SETUP.md - Complete CI/CD pipeline setup guide
- docs/DEPLOYMENT-QUICK-START.md - Quick deployment reference
- docs/BACKUP.md - Backup and restore documentation
- docs/REMOTE_DATABASE.md - Remote database configuration guide
- scripts/README.md - Deployment scripts documentation

## Web Improvements
- Enhanced ingredient parser with better unit and quantity detection
- Improved recipe editing interface with unified edit experience
- Better cooking mode functionality
- Updated dependencies in package.json

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-08 05:04:39 +00:00
parent 35a3088c30
commit d1156833a2
24 changed files with 3543 additions and 229 deletions

View File

@@ -0,0 +1,258 @@
import express, { Request, Response } from 'express';
import path from 'path';
import fs from 'fs/promises';
import { createBackup, restoreBackup, listBackups, deleteBackup } from '../services/backup.service';
import multer from 'multer';
const router = express.Router();
// Configure multer for backup file uploads
const upload = multer({
dest: '/tmp/basil-restore/',
limits: {
fileSize: 1024 * 1024 * 1024, // 1GB max
},
});
// Get backup directory from env or use default
const getBackupDir = (): string => {
return process.env.BACKUP_PATH || path.join(__dirname, '../../../backups');
};
/**
* POST /api/backup
* Creates a new backup of all data and files
*/
router.post('/', async (req: Request, res: Response) => {
try {
const backupDir = getBackupDir();
await fs.mkdir(backupDir, { recursive: true });
const backupPath = await createBackup(backupDir);
const fileName = path.basename(backupPath);
const stats = await fs.stat(backupPath);
res.json({
success: true,
message: 'Backup created successfully',
backup: {
name: fileName,
path: backupPath,
size: stats.size,
created: stats.birthtime,
},
});
} catch (error) {
console.error('Backup creation error:', error);
res.status(500).json({
success: false,
error: 'Failed to create backup',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
/**
* GET /api/backup
* Lists all available backups
*/
router.get('/', async (req: Request, res: Response) => {
try {
const backupDir = getBackupDir();
const backups = await listBackups(backupDir);
res.json({
success: true,
backups,
});
} catch (error) {
console.error('Error listing backups:', error);
res.status(500).json({
success: false,
error: 'Failed to list backups',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
/**
* GET /api/backup/:filename
* Downloads a specific backup file
*/
router.get('/:filename', async (req: Request, res: Response) => {
try {
const { filename } = req.params;
const backupDir = getBackupDir();
const backupPath = path.join(backupDir, filename);
// Security check: ensure the file is within the backup directory
const resolvedPath = path.resolve(backupPath);
const resolvedBackupDir = path.resolve(backupDir);
if (!resolvedPath.startsWith(resolvedBackupDir)) {
return res.status(403).json({
success: false,
error: 'Access denied',
});
}
// Check if file exists
try {
await fs.access(backupPath);
} catch {
return res.status(404).json({
success: false,
error: 'Backup file not found',
});
}
// Send file
res.download(backupPath, filename, (err) => {
if (err) {
console.error('Error downloading backup:', err);
if (!res.headersSent) {
res.status(500).json({
success: false,
error: 'Failed to download backup',
});
}
}
});
} catch (error) {
console.error('Error downloading backup:', error);
res.status(500).json({
success: false,
error: 'Failed to download backup',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
/**
* POST /api/backup/restore
* Restores data from a backup file
* Accepts either:
* - multipart/form-data with 'backup' file field
* - JSON with 'filename' field (for existing backup in backup directory)
*/
router.post('/restore', upload.single('backup'), async (req: Request, res: Response) => {
let backupPath: string | null = null;
let isTemporaryFile = false;
try {
const backupDir = getBackupDir();
// Check if file was uploaded or filename provided
if (req.file) {
backupPath = req.file.path;
isTemporaryFile = true;
} else if (req.body.filename) {
backupPath = path.join(backupDir, req.body.filename);
// Security check
const resolvedPath = path.resolve(backupPath);
const resolvedBackupDir = path.resolve(backupDir);
if (!resolvedPath.startsWith(resolvedBackupDir)) {
return res.status(403).json({
success: false,
error: 'Access denied',
});
}
// Check if file exists
try {
await fs.access(backupPath);
} catch {
return res.status(404).json({
success: false,
error: 'Backup file not found',
});
}
} else {
return res.status(400).json({
success: false,
error: 'No backup file provided. Either upload a file or specify a filename.',
});
}
// Perform restore
const metadata = await restoreBackup(backupPath, backupDir);
// Clean up temporary file if it was uploaded
if (isTemporaryFile && backupPath) {
try {
await fs.unlink(backupPath);
} catch (err) {
console.warn('Failed to clean up temporary file:', err);
}
}
res.json({
success: true,
message: 'Backup restored successfully',
metadata,
});
} catch (error) {
console.error('Restore error:', error);
// Clean up temporary file on error
if (isTemporaryFile && backupPath) {
try {
await fs.unlink(backupPath);
} catch {}
}
res.status(500).json({
success: false,
error: 'Failed to restore backup',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
/**
* DELETE /api/backup/:filename
* Deletes a backup file
*/
router.delete('/:filename', async (req: Request, res: Response) => {
try {
const { filename } = req.params;
const backupDir = getBackupDir();
const backupPath = path.join(backupDir, filename);
// Security check
const resolvedPath = path.resolve(backupPath);
const resolvedBackupDir = path.resolve(backupDir);
if (!resolvedPath.startsWith(resolvedBackupDir)) {
return res.status(403).json({
success: false,
error: 'Access denied',
});
}
// Check if file exists
try {
await fs.access(backupPath);
} catch {
return res.status(404).json({
success: false,
error: 'Backup file not found',
});
}
await deleteBackup(backupPath);
res.json({
success: true,
message: 'Backup deleted successfully',
});
} catch (error) {
console.error('Error deleting backup:', error);
res.status(500).json({
success: false,
error: 'Failed to delete backup',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
export default router;

View File

@@ -0,0 +1,437 @@
import { PrismaClient } from '@prisma/client';
import fs from 'fs/promises';
import path from 'path';
import archiver from 'archiver';
import { createWriteStream, createReadStream } from 'fs';
import extract from 'extract-zip';
const prisma = new PrismaClient();
export interface BackupMetadata {
version: string;
timestamp: string;
recipeCount: number;
cookbookCount: number;
tagCount: number;
}
export interface BackupData {
metadata: BackupMetadata;
recipes: any[];
cookbooks: any[];
tags: any[];
recipeTags: any[];
cookbookRecipes: any[];
}
/**
* Creates a complete backup of all database data and uploaded files
* Returns the path to the backup file
*/
export async function createBackup(backupDir: string): Promise<string> {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupName = `basil-backup-${timestamp}`;
const tempDir = path.join(backupDir, 'temp', backupName);
const backupFilePath = path.join(backupDir, `${backupName}.zip`);
try {
// Create temp directory for backup assembly
await fs.mkdir(tempDir, { recursive: true });
// Export all database data
const backupData = await exportDatabaseData();
// Write database backup to JSON file
const dbBackupPath = path.join(tempDir, 'database.json');
await fs.writeFile(dbBackupPath, JSON.stringify(backupData, null, 2));
// Copy uploaded files
const uploadsPath = process.env.LOCAL_STORAGE_PATH || path.join(__dirname, '../../../uploads');
const backupUploadsPath = path.join(tempDir, 'uploads');
try {
await fs.access(uploadsPath);
await copyDirectory(uploadsPath, backupUploadsPath);
} catch (error) {
console.warn('No uploads directory found, skipping file backup');
}
// Create ZIP archive
await createZipArchive(tempDir, backupFilePath);
// Clean up temp directory
await fs.rm(tempDir, { recursive: true, force: true });
return backupFilePath;
} catch (error) {
// Clean up on error
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {}
throw error;
}
}
/**
* Exports all database data to a structured object
*/
async function exportDatabaseData(): Promise<BackupData> {
// Fetch all data with relations
const recipes = await prisma.recipe.findMany({
include: {
sections: true,
ingredients: {
include: {
instructions: true,
},
},
instructions: {
include: {
ingredients: true,
},
},
images: true,
tags: true,
cookbooks: true,
},
});
const cookbooks = await prisma.cookbook.findMany({
include: {
recipes: true,
},
});
const tags = await prisma.tag.findMany({
include: {
recipes: true,
},
});
const recipeTags = await prisma.recipeTag.findMany();
const cookbookRecipes = await prisma.cookbookRecipe.findMany();
const metadata: BackupMetadata = {
version: '1.0',
timestamp: new Date().toISOString(),
recipeCount: recipes.length,
cookbookCount: cookbooks.length,
tagCount: tags.length,
};
return {
metadata,
recipes,
cookbooks,
tags,
recipeTags,
cookbookRecipes,
};
}
/**
* Restores database and files from a backup file
*/
export async function restoreBackup(backupFilePath: string, backupDir: string): Promise<BackupMetadata> {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const tempDir = path.join(backupDir, 'temp', `restore-${timestamp}`);
try {
// Extract backup archive
await fs.mkdir(tempDir, { recursive: true });
await extract(backupFilePath, { dir: tempDir });
// Read and parse database backup
const dbBackupPath = path.join(tempDir, 'database.json');
const backupData: BackupData = JSON.parse(await fs.readFile(dbBackupPath, 'utf-8'));
// Clear existing data (in reverse order of dependencies)
await clearDatabase();
// Restore data (in order of dependencies)
await restoreDatabaseData(backupData);
// Restore uploaded files
const backupUploadsPath = path.join(tempDir, 'uploads');
const uploadsPath = process.env.LOCAL_STORAGE_PATH || path.join(__dirname, '../../../uploads');
try {
await fs.access(backupUploadsPath);
// Clear existing uploads
try {
await fs.rm(uploadsPath, { recursive: true, force: true });
} catch {}
await fs.mkdir(uploadsPath, { recursive: true });
// Restore uploads
await copyDirectory(backupUploadsPath, uploadsPath);
} catch (error) {
console.warn('No uploads in backup, skipping file restore');
}
// Clean up temp directory
await fs.rm(tempDir, { recursive: true, force: true });
return backupData.metadata;
} catch (error) {
// Clean up on error
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {}
throw error;
}
}
/**
* Clears all data from the database
*/
async function clearDatabase(): Promise<void> {
// Delete in order to respect foreign key constraints
await prisma.cookbookRecipe.deleteMany();
await prisma.recipeTag.deleteMany();
await prisma.ingredientInstructionMapping.deleteMany();
await prisma.recipeImage.deleteMany();
await prisma.instruction.deleteMany();
await prisma.ingredient.deleteMany();
await prisma.recipeSection.deleteMany();
await prisma.recipe.deleteMany();
await prisma.cookbook.deleteMany();
await prisma.tag.deleteMany();
}
/**
* Restores database data from backup
*/
async function restoreDatabaseData(backupData: BackupData): Promise<void> {
// Restore tags first (no dependencies)
for (const tag of backupData.tags) {
await prisma.tag.create({
data: {
id: tag.id,
name: tag.name,
},
});
}
// Restore cookbooks (no dependencies)
for (const cookbook of backupData.cookbooks) {
await prisma.cookbook.create({
data: {
id: cookbook.id,
name: cookbook.name,
description: cookbook.description,
coverImageUrl: cookbook.coverImageUrl,
autoFilterCategories: cookbook.autoFilterCategories,
autoFilterTags: cookbook.autoFilterTags,
createdAt: new Date(cookbook.createdAt),
updatedAt: new Date(cookbook.updatedAt),
},
});
}
// Restore recipes with all nested relations
for (const recipe of backupData.recipes) {
await prisma.recipe.create({
data: {
id: recipe.id,
title: recipe.title,
description: recipe.description,
prepTime: recipe.prepTime,
cookTime: recipe.cookTime,
totalTime: recipe.totalTime,
servings: recipe.servings,
imageUrl: recipe.imageUrl,
sourceUrl: recipe.sourceUrl,
author: recipe.author,
cuisine: recipe.cuisine,
categories: recipe.categories,
rating: recipe.rating,
createdAt: new Date(recipe.createdAt),
updatedAt: new Date(recipe.updatedAt),
sections: {
create: recipe.sections?.map((section: any) => ({
id: section.id,
name: section.name,
order: section.order,
timing: section.timing,
})) || [],
},
ingredients: {
create: recipe.ingredients
?.filter((ing: any) => !ing.sectionId)
.map((ing: any) => ({
id: ing.id,
name: ing.name,
amount: ing.amount,
unit: ing.unit,
notes: ing.notes,
order: ing.order,
})) || [],
},
instructions: {
create: recipe.instructions
?.filter((inst: any) => !inst.sectionId)
.map((inst: any) => ({
id: inst.id,
step: inst.step,
text: inst.text,
imageUrl: inst.imageUrl,
timing: inst.timing,
})) || [],
},
images: {
create: recipe.images?.map((img: any) => ({
id: img.id,
url: img.url,
order: img.order,
})) || [],
},
},
});
// Restore section ingredients and instructions
for (const section of recipe.sections || []) {
const sectionIngredients = recipe.ingredients?.filter((ing: any) => ing.sectionId === section.id) || [];
const sectionInstructions = recipe.instructions?.filter((inst: any) => inst.sectionId === section.id) || [];
for (const ing of sectionIngredients) {
await prisma.ingredient.create({
data: {
id: ing.id,
recipeId: recipe.id,
sectionId: section.id,
name: ing.name,
amount: ing.amount,
unit: ing.unit,
notes: ing.notes,
order: ing.order,
},
});
}
for (const inst of sectionInstructions) {
await prisma.instruction.create({
data: {
id: inst.id,
recipeId: recipe.id,
sectionId: section.id,
step: inst.step,
text: inst.text,
imageUrl: inst.imageUrl,
timing: inst.timing,
},
});
}
}
}
// Restore ingredient-instruction mappings
for (const recipe of backupData.recipes) {
for (const instruction of recipe.instructions || []) {
for (const mapping of instruction.ingredients || []) {
await prisma.ingredientInstructionMapping.create({
data: {
id: mapping.id,
ingredientId: mapping.ingredientId,
instructionId: mapping.instructionId,
order: mapping.order,
},
});
}
}
}
// Restore recipe tags
for (const recipeTag of backupData.recipeTags) {
await prisma.recipeTag.create({
data: {
recipeId: recipeTag.recipeId,
tagId: recipeTag.tagId,
},
});
}
// Restore cookbook recipes
for (const cookbookRecipe of backupData.cookbookRecipes) {
await prisma.cookbookRecipe.create({
data: {
id: cookbookRecipe.id,
cookbookId: cookbookRecipe.cookbookId,
recipeId: cookbookRecipe.recipeId,
addedAt: new Date(cookbookRecipe.addedAt),
},
});
}
}
/**
* Creates a ZIP archive from a directory
*/
async function createZipArchive(sourceDir: string, outputPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const output = createWriteStream(outputPath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', () => resolve());
archive.on('error', (err) => reject(err));
archive.pipe(output);
archive.directory(sourceDir, false);
archive.finalize();
});
}
/**
* Recursively copies a directory
*/
async function copyDirectory(source: string, destination: string): Promise<void> {
await fs.mkdir(destination, { recursive: true });
const entries = await fs.readdir(source, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(source, entry.name);
const destPath = path.join(destination, entry.name);
if (entry.isDirectory()) {
await copyDirectory(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
}
}
}
/**
* Lists all available backups in the backup directory
*/
export async function listBackups(backupDir: string): Promise<Array<{ name: string; path: string; size: number; created: Date }>> {
try {
await fs.mkdir(backupDir, { recursive: true });
const files = await fs.readdir(backupDir);
const backups = [];
for (const file of files) {
if (file.startsWith('basil-backup-') && file.endsWith('.zip')) {
const filePath = path.join(backupDir, file);
const stats = await fs.stat(filePath);
backups.push({
name: file,
path: filePath,
size: stats.size,
created: stats.birthtime,
});
}
}
// Sort by creation date, newest first
return backups.sort((a, b) => b.created.getTime() - a.created.getTime());
} catch (error) {
console.error('Error listing backups:', error);
return [];
}
}
/**
* Deletes a backup file
*/
export async function deleteBackup(backupFilePath: string): Promise<void> {
await fs.unlink(backupFilePath);
}