Files
basil/packages/api/src/routes/backup.routes.ts
Paul R Kartchner d1156833a2
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
feat: add CI/CD pipeline, backup system, and deployment automation
## 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>
2025-12-08 05:04:39 +00:00

259 lines
6.6 KiB
TypeScript

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;