diff --git a/.env b/.env index e6b6f18..330090e 100644 --- a/.env +++ b/.env @@ -1,10 +1,23 @@ # ================================ -# Banco de Dados (PostgreSQL) +# Configuração geral +# ================================ +PORT=8080 +SERVER_URL=https://SEU_SERVICO.onrender.com + +# ================================ +# Autenticação da API +# ================================ +AUTHENTICATION_API_KEY=5UcoExo6p70hSg5JWspzjQcB96NOxbus + +# ================================ +# Banco de Dados (PostgreSQL do Render) # ================================ DATABASE_ENABLED=true DATABASE_PROVIDER=postgresql -DATABASE_CONNECTION_URI=postgresql://postgres:vblPzWkQnzyGmEZBDOKWnejzFAVjGYTJ@postgres.railway.internal:5432/railway -DATABASE_CONNECTION_CLIENT_NAME=evolution_exchange +# Use as variáveis do Render para montar a URI: +# postgresql://:@:/?sslmode=require +DATABASE_CONNECTION_URI=postgresql://evolution_db_2vwr_user:sQP9YtxG7svzDXNepI3wJUYKnYfXZzEe@dpg-d3ab490dl3ps73elkn6g-a/evolution_db_2vwr +# Opcional: salvar dados na base DATABASE_SAVE_DATA_INSTANCE=true DATABASE_SAVE_DATA_NEW_MESSAGE=true DATABASE_SAVE_MESSAGE_UPDATE=true @@ -13,44 +26,31 @@ DATABASE_SAVE_DATA_CHATS=true DATABASE_SAVE_DATA_LABELS=true DATABASE_SAVE_DATA_HISTORIC=true -POSTGRES_USER=postgres -POSTGRES_PASSWORD=vblPzWkQnzyGmEZBDOKWnejzFAVjGYTJ -POSTGRES_DB=railway - # ================================ -# Redis +# Cache (sem Redis) # ================================ -CACHE_REDIS_ENABLED=true -CACHE_REDIS_URI=redis://default:ugHxMaMvdlikEKWrJWCZXAGjjRxLsazy@redis.railway.internal:6379 -CACHE_REDIS_PREFIX_KEY=evolution -CACHE_REDIS_SAVE_INSTANCES=false +CACHE_REDIS_ENABLED=false CACHE_LOCAL_ENABLED=false -# ================================ -# Autenticação da API -# ================================ -AUTHENTICATION_API_KEY=fLfTsOuvE6un1Gyz4nYwyQbho4OXHHqj - # ================================ # WebSocket / QR Code # ================================ WEBSOCKET_ENABLED=true WEBSOCKET_GLOBAL_EVENTS=true - QRCODE_LIMIT=60 QRCODE_COLOR=000000 # ================================ -# RabbitMQ (opcional) +# RabbitMQ (opcional desativado) # ================================ -# Se você não estiver usando RabbitMQ, mantenha RABBITMQ_ENABLED=false RABBITMQ_ENABLED=false RABBITMQ_GLOBAL_ENABLED=false -RABBITMQ_URI=amqp://usuario:senha@rabbitmq:5672 +RABBITMQ_URI= RABBITMQ_EXCHANGE_NAME=evolution_exchange RABBITMQ_EVENTS_QRCODE_UPDATED=true # ================================ -# Integrações Adicionais +# Integrações adicionais # ================================ CHATWOOT_ENABLED=true + diff --git a/cleancut-ai/.env b/cleancut-ai/.env new file mode 100644 index 0000000..3fafd5c --- /dev/null +++ b/cleancut-ai/.env @@ -0,0 +1,56 @@ +# Server Configuration +NODE_ENV=development +PORT=3000 +FRONTEND_URL=http://localhost:3000 + +# Database Configuration +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=cleancut_db +DB_USER=postgres +DB_PASSWORD=postgres + +# Redis Configuration +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + +# JWT Configuration +JWT_SECRET=cleancut_super_secret_jwt_key_2024_change_in_production +JWT_EXPIRES_IN=7d +JWT_REFRESH_SECRET=cleancut_refresh_secret_key_2024 +JWT_REFRESH_EXPIRES_IN=30d + +# Email Configuration +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your_email@gmail.com +SMTP_PASSWORD=your_app_password +EMAIL_FROM=noreply@cleancut-ai.com + +# Stripe Configuration +STRIPE_SECRET_KEY=sk_test_51234567890 +STRIPE_WEBHOOK_SECRET=whsec_test_secret +STRIPE_PRICE_FREE=price_free +STRIPE_PRICE_INTERMEDIATE=price_intermediate +STRIPE_PRICE_PREMIUM=price_premium + +# File Upload Configuration +MAX_FILE_SIZE_MB=500 +UPLOAD_PATH=./uploads + +# Processing Configuration +PYTHON_WORKER_PATH=./workers +VIDEO_PROCESSING_TIMEOUT=3600000 +IMAGE_PROCESSING_TIMEOUT=300000 + +# Project Retention +PROJECT_RETENTION_HOURS=24 + +# Plan Limits +FREE_DAILY_UPLOADS=3 +FREE_MAX_RESOLUTION=720 +INTERMEDIATE_MONTHLY_UPLOADS=30 +INTERMEDIATE_MAX_RESOLUTION=1080 +PREMIUM_MONTHLY_UPLOADS=999 +PREMIUM_MAX_RESOLUTION=2160 diff --git a/cleancut-ai/.env.example b/cleancut-ai/.env.example new file mode 100644 index 0000000..c1ffbb9 --- /dev/null +++ b/cleancut-ai/.env.example @@ -0,0 +1,56 @@ +# Server Configuration +NODE_ENV=development +PORT=3000 +FRONTEND_URL=http://localhost:3000 + +# Database Configuration +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=cleancut_db +DB_USER=postgres +DB_PASSWORD=your_password + +# Redis Configuration +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + +# JWT Configuration +JWT_SECRET=your_super_secret_jwt_key_change_this +JWT_EXPIRES_IN=7d +JWT_REFRESH_SECRET=your_refresh_secret_key +JWT_REFRESH_EXPIRES_IN=30d + +# Email Configuration (for notifications) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your_email@gmail.com +SMTP_PASSWORD=your_app_password +EMAIL_FROM=noreply@cleancut-ai.com + +# Stripe Configuration +STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key +STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret +STRIPE_PRICE_FREE=price_free_trial_id +STRIPE_PRICE_INTERMEDIATE=price_intermediate_id +STRIPE_PRICE_PREMIUM=price_premium_id + +# File Upload Configuration +MAX_FILE_SIZE_MB=500 +UPLOAD_PATH=./uploads + +# Processing Configuration +PYTHON_WORKER_PATH=./workers +VIDEO_PROCESSING_TIMEOUT=3600000 +IMAGE_PROCESSING_TIMEOUT=300000 + +# Project Retention (in hours) +PROJECT_RETENTION_HOURS=24 + +# Plan Limits +FREE_DAILY_UPLOADS=3 +FREE_MAX_RESOLUTION=720 +INTERMEDIATE_MONTHLY_UPLOADS=30 +INTERMEDIATE_MAX_RESOLUTION=1080 +PREMIUM_MONTHLY_UPLOADS=999 +PREMIUM_MAX_RESOLUTION=2160 diff --git a/cleancut-ai/backend/config/database.js b/cleancut-ai/backend/config/database.js new file mode 100644 index 0000000..5474dd9 --- /dev/null +++ b/cleancut-ai/backend/config/database.js @@ -0,0 +1,33 @@ +const { Sequelize } = require('sequelize'); +require('dotenv').config(); + +const sequelize = new Sequelize( + process.env.DB_NAME, + process.env.DB_USER, + process.env.DB_PASSWORD, + { + host: process.env.DB_HOST, + port: process.env.DB_PORT, + dialect: 'postgres', + logging: process.env.NODE_ENV === 'development' ? console.log : false, + pool: { + max: 5, + min: 0, + acquire: 30000, + idle: 10000 + } + } +); + +const testConnection = async () => { + try { + await sequelize.authenticate(); + console.log('✓ Database connection established successfully'); + return true; + } catch (error) { + console.error('✗ Unable to connect to database:', error.message); + return false; + } +}; + +module.exports = { sequelize, testConnection }; diff --git a/cleancut-ai/backend/config/redis.js b/cleancut-ai/backend/config/redis.js new file mode 100644 index 0000000..2dce93c --- /dev/null +++ b/cleancut-ai/backend/config/redis.js @@ -0,0 +1,30 @@ +const redis = require('redis'); +require('dotenv').config(); + +const redisClient = redis.createClient({ + socket: { + host: process.env.REDIS_HOST, + port: process.env.REDIS_PORT + }, + password: process.env.REDIS_PASSWORD || undefined +}); + +redisClient.on('error', (err) => { + console.error('Redis Client Error:', err); +}); + +redisClient.on('connect', () => { + console.log('✓ Redis connected successfully'); +}); + +const connectRedis = async () => { + try { + await redisClient.connect(); + return true; + } catch (error) { + console.error('✗ Redis connection failed:', error.message); + return false; + } +}; + +module.exports = { redisClient, connectRedis }; diff --git a/cleancut-ai/backend/controllers/authController.js b/cleancut-ai/backend/controllers/authController.js new file mode 100644 index 0000000..90205c0 --- /dev/null +++ b/cleancut-ai/backend/controllers/authController.js @@ -0,0 +1,229 @@ +const jwt = require('jsonwebtoken'); +const { User } = require('../models'); +const { validateEmail } = require('../utils/emailValidator'); +const crypto = require('crypto'); + +/** + * Generate JWT token + */ +const generateToken = (userId) => { + return jwt.sign({ userId }, process.env.JWT_SECRET, { + expiresIn: process.env.JWT_EXPIRES_IN + }); +}; + +/** + * Register new user + */ +const register = async (req, res) => { + try { + const { email, password, confirmPassword } = req.body; + + // Validate required fields + if (!email || !password || !confirmPassword) { + return res.status(400).json({ error: 'All fields are required' }); + } + + // Validate password match + if (password !== confirmPassword) { + return res.status(400).json({ error: 'Passwords do not match' }); + } + + // Validate password strength + if (password.length < 8) { + return res.status(400).json({ error: 'Password must be at least 8 characters long' }); + } + + // Validate email format and check for temporary emails + const emailValidation = validateEmail(email); + if (!emailValidation.valid) { + return res.status(400).json({ error: emailValidation.message }); + } + + // Check if user already exists + const existingUser = await User.findOne({ where: { email: email.toLowerCase() } }); + if (existingUser) { + return res.status(400).json({ error: 'Email already registered' }); + } + + // Create new user with free trial + const planStartDate = new Date(); + const planEndDate = new Date(); + planEndDate.setDate(planEndDate.getDate() + 7); // 7 days free trial + + const user = await User.create({ + email: email.toLowerCase(), + password, + plan: 'free', + planStartDate, + planEndDate, + verified: false, + verificationToken: crypto.randomBytes(32).toString('hex') + }); + + // Generate token + const token = generateToken(user.id); + + res.status(201).json({ + message: 'Registration successful', + token, + user: user.toJSON() + }); + } catch (error) { + console.error('Registration error:', error); + res.status(500).json({ error: 'Registration failed. Please try again.' }); + } +}; + +/** + * Login user + */ +const login = async (req, res) => { + try { + const { email, password } = req.body; + + // Validate required fields + if (!email || !password) { + return res.status(400).json({ error: 'Email and password are required' }); + } + + // Find user + const user = await User.findOne({ where: { email: email.toLowerCase() } }); + if (!user) { + return res.status(401).json({ error: 'Invalid email or password' }); + } + + // Verify password + const isPasswordValid = await user.comparePassword(password); + if (!isPasswordValid) { + return res.status(401).json({ error: 'Invalid email or password' }); + } + + // Check if plan has expired + if (user.planEndDate && new Date(user.planEndDate) < new Date()) { + // Downgrade to free plan if expired + await user.update({ + plan: 'free', + planStartDate: new Date(), + planEndDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) + }); + } + + // Generate token + const token = generateToken(user.id); + + res.json({ + message: 'Login successful', + token, + user: user.toJSON() + }); + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ error: 'Login failed. Please try again.' }); + } +}; + +/** + * Get current user profile + */ +const getProfile = async (req, res) => { + try { + res.json({ user: req.user.toJSON() }); + } catch (error) { + console.error('Get profile error:', error); + res.status(500).json({ error: 'Failed to fetch profile' }); + } +}; + +/** + * Request password reset + */ +const requestPasswordReset = async (req, res) => { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ error: 'Email is required' }); + } + + const user = await User.findOne({ where: { email: email.toLowerCase() } }); + + // Always return success to prevent email enumeration + if (!user) { + return res.json({ message: 'If the email exists, a reset link has been sent' }); + } + + // Generate reset token + const resetToken = crypto.randomBytes(32).toString('hex'); + const resetExpires = new Date(Date.now() + 3600000); // 1 hour + + await user.update({ + resetPasswordToken: resetToken, + resetPasswordExpires: resetExpires + }); + + // TODO: Send email with reset link + // For now, just return the token (in production, send via email) + console.log(`Password reset token for ${email}: ${resetToken}`); + + res.json({ + message: 'If the email exists, a reset link has been sent', + // Remove this in production: + resetToken: process.env.NODE_ENV === 'development' ? resetToken : undefined + }); + } catch (error) { + console.error('Password reset request error:', error); + res.status(500).json({ error: 'Failed to process request' }); + } +}; + +/** + * Reset password with token + */ +const resetPassword = async (req, res) => { + try { + const { token, password, confirmPassword } = req.body; + + if (!token || !password || !confirmPassword) { + return res.status(400).json({ error: 'All fields are required' }); + } + + if (password !== confirmPassword) { + return res.status(400).json({ error: 'Passwords do not match' }); + } + + if (password.length < 8) { + return res.status(400).json({ error: 'Password must be at least 8 characters long' }); + } + + const user = await User.findOne({ + where: { + resetPasswordToken: token, + resetPasswordExpires: { [require('sequelize').Op.gt]: new Date() } + } + }); + + if (!user) { + return res.status(400).json({ error: 'Invalid or expired reset token' }); + } + + await user.update({ + password, + resetPasswordToken: null, + resetPasswordExpires: null + }); + + res.json({ message: 'Password reset successful' }); + } catch (error) { + console.error('Password reset error:', error); + res.status(500).json({ error: 'Failed to reset password' }); + } +}; + +module.exports = { + register, + login, + getProfile, + requestPasswordReset, + resetPassword +}; diff --git a/cleancut-ai/backend/controllers/paymentsController.js b/cleancut-ai/backend/controllers/paymentsController.js new file mode 100644 index 0000000..fc77b7a --- /dev/null +++ b/cleancut-ai/backend/controllers/paymentsController.js @@ -0,0 +1,314 @@ +const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); +const { User, Payment } = require('../models'); +const { planLimits } = require('../utils/planLimits'); + +/** + * Create checkout session for plan subscription + */ +const createCheckoutSession = async (req, res) => { + try { + const { plan } = req.body; + const user = req.user; + + if (!['intermediate', 'premium'].includes(plan)) { + return res.status(400).json({ error: 'Invalid plan selected' }); + } + + const planConfig = planLimits[plan]; + + // Create or retrieve Stripe customer + let customerId = user.stripeCustomerId; + if (!customerId) { + const customer = await stripe.customers.create({ + email: user.email, + metadata: { + userId: user.id + } + }); + customerId = customer.id; + await user.update({ stripeCustomerId: customerId }); + } + + // Create checkout session + const session = await stripe.checkout.sessions.create({ + customer: customerId, + payment_method_types: ['card'], + line_items: [ + { + price_data: { + currency: 'brl', + product_data: { + name: `CleanCut IA - ${planConfig.name}`, + description: planConfig.features.join(', ') + }, + unit_amount: planConfig.price * 100, // Convert to cents + recurring: { + interval: 'month', + interval_count: 1 + } + }, + quantity: 1 + } + ], + mode: 'subscription', + success_url: `${process.env.FRONTEND_URL}/billing?success=true&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${process.env.FRONTEND_URL}/billing?cancelled=true`, + metadata: { + userId: user.id, + plan + } + }); + + res.json({ + sessionId: session.id, + url: session.url + }); + } catch (error) { + console.error('Create checkout session error:', error); + res.status(500).json({ error: 'Failed to create checkout session' }); + } +}; + +/** + * Handle Stripe webhooks + */ +const handleWebhook = async (req, res) => { + const sig = req.headers['stripe-signature']; + let event; + + try { + event = stripe.webhooks.constructEvent( + req.body, + sig, + process.env.STRIPE_WEBHOOK_SECRET + ); + } catch (err) { + console.error('Webhook signature verification failed:', err.message); + return res.status(400).send(`Webhook Error: ${err.message}`); + } + + try { + switch (event.type) { + case 'checkout.session.completed': + await handleCheckoutCompleted(event.data.object); + break; + + case 'customer.subscription.updated': + await handleSubscriptionUpdated(event.data.object); + break; + + case 'customer.subscription.deleted': + await handleSubscriptionDeleted(event.data.object); + break; + + case 'invoice.payment_succeeded': + await handlePaymentSucceeded(event.data.object); + break; + + case 'invoice.payment_failed': + await handlePaymentFailed(event.data.object); + break; + + default: + console.log(`Unhandled event type: ${event.type}`); + } + + res.json({ received: true }); + } catch (error) { + console.error('Webhook handler error:', error); + res.status(500).json({ error: 'Webhook handler failed' }); + } +}; + +/** + * Handle successful checkout + */ +const handleCheckoutCompleted = async (session) => { + const { userId, plan } = session.metadata; + + const user = await User.findByPk(userId); + if (!user) { + console.error('User not found:', userId); + return; + } + + const planConfig = planLimits[plan]; + const planStartDate = new Date(); + const planEndDate = new Date(); + planEndDate.setDate(planEndDate.getDate() + planConfig.duration); + + await user.update({ + plan, + planStartDate, + planEndDate, + stripeSubscriptionId: session.subscription + }); + + await Payment.create({ + userId, + plan, + amount: planConfig.price, + currency: 'BRL', + status: 'completed', + stripePaymentId: session.payment_intent, + metadata: { sessionId: session.id } + }); + + console.log(`Plan activated for user ${userId}: ${plan}`); +}; + +/** + * Handle subscription update + */ +const handleSubscriptionUpdated = async (subscription) => { + const user = await User.findOne({ + where: { stripeSubscriptionId: subscription.id } + }); + + if (!user) { + console.error('User not found for subscription:', subscription.id); + return; + } + + // Handle subscription status changes + if (subscription.status === 'active') { + console.log(`Subscription active for user ${user.id}`); + } else if (subscription.status === 'canceled') { + await user.update({ + plan: 'free', + planStartDate: new Date(), + planEndDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) + }); + console.log(`Subscription cancelled for user ${user.id}`); + } +}; + +/** + * Handle subscription deletion + */ +const handleSubscriptionDeleted = async (subscription) => { + const user = await User.findOne({ + where: { stripeSubscriptionId: subscription.id } + }); + + if (!user) { + console.error('User not found for subscription:', subscription.id); + return; + } + + await user.update({ + plan: 'free', + planStartDate: new Date(), + planEndDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + stripeSubscriptionId: null + }); + + console.log(`Subscription deleted for user ${user.id}`); +}; + +/** + * Handle successful payment + */ +const handlePaymentSucceeded = async (invoice) => { + console.log('Payment succeeded:', invoice.id); + + const user = await User.findOne({ + where: { stripeCustomerId: invoice.customer } + }); + + if (user) { + await Payment.create({ + userId: user.id, + plan: user.plan, + amount: invoice.amount_paid / 100, + currency: invoice.currency.toUpperCase(), + status: 'completed', + stripeInvoiceId: invoice.id + }); + } +}; + +/** + * Handle failed payment + */ +const handlePaymentFailed = async (invoice) => { + console.error('Payment failed:', invoice.id); + + const user = await User.findOne({ + where: { stripeCustomerId: invoice.customer } + }); + + if (user) { + await Payment.create({ + userId: user.id, + plan: user.plan, + amount: invoice.amount_due / 100, + currency: invoice.currency.toUpperCase(), + status: 'failed', + stripeInvoiceId: invoice.id + }); + } +}; + +/** + * Get user's billing information + */ +const getBillingInfo = async (req, res) => { + try { + const user = req.user; + + const payments = await Payment.findAll({ + where: { userId: user.id }, + order: [['createdAt', 'DESC']], + limit: 10 + }); + + let subscription = null; + if (user.stripeSubscriptionId) { + try { + subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId); + } catch (error) { + console.error('Error fetching subscription:', error); + } + } + + res.json({ + plan: user.plan, + planStartDate: user.planStartDate, + planEndDate: user.planEndDate, + subscription, + payments, + planLimits: planLimits[user.plan] + }); + } catch (error) { + console.error('Get billing info error:', error); + res.status(500).json({ error: 'Failed to fetch billing information' }); + } +}; + +/** + * Cancel subscription + */ +const cancelSubscription = async (req, res) => { + try { + const user = req.user; + + if (!user.stripeSubscriptionId) { + return res.status(400).json({ error: 'No active subscription found' }); + } + + await stripe.subscriptions.cancel(user.stripeSubscriptionId); + + res.json({ message: 'Subscription cancelled successfully' }); + } catch (error) { + console.error('Cancel subscription error:', error); + res.status(500).json({ error: 'Failed to cancel subscription' }); + } +}; + +module.exports = { + createCheckoutSession, + handleWebhook, + getBillingInfo, + cancelSubscription +}; diff --git a/cleancut-ai/backend/controllers/projectsController.js b/cleancut-ai/backend/controllers/projectsController.js new file mode 100644 index 0000000..5399292 --- /dev/null +++ b/cleancut-ai/backend/controllers/projectsController.js @@ -0,0 +1,356 @@ +const { Project, User } = require('../models'); +const { canUserUpload, getMaxResolution, getProcessingPriority } = require('../utils/planLimits'); +const { addJobToQueue } = require('../utils/jobQueue'); +const fs = require('fs').promises; +const path = require('path'); +const { Op } = require('sequelize'); + +/** + * Create new project (upload file) + */ +const createProject = async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No file uploaded' }); + } + + const user = req.user; + + // Check if user can upload + const uploadCheck = canUserUpload(user); + if (!uploadCheck.allowed) { + // Delete uploaded file + await fs.unlink(req.file.path); + return res.status(403).json({ error: uploadCheck.message }); + } + + // Reset daily counter if needed + if (uploadCheck.resetDaily) { + await user.update({ uploadsToday: 0 }); + } + + // Determine project type + const type = req.file.mimetype.startsWith('video/') ? 'video' : 'image'; + + // Set expiration time (24 hours from now) + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + parseInt(process.env.PROJECT_RETENTION_HOURS || 24)); + + // Create project + const project = await Project.create({ + userId: user.id, + type, + originalFileName: req.file.originalname, + originalFilePath: req.file.path, + status: 'pending', + expiresAt, + fileSize: req.file.size, + metadata: { + mimetype: req.file.mimetype, + uploadedAt: new Date(), + priority: getProcessingPriority(user.plan) + } + }); + + // Update user upload counts + const today = new Date().toISOString().split('T')[0]; + const lastUpload = user.lastUploadDate ? user.lastUploadDate.toISOString().split('T')[0] : null; + + if (lastUpload !== today) { + await user.update({ + uploadsToday: 1, + uploadsThisMonth: user.uploadsThisMonth + 1, + lastUploadDate: new Date() + }); + } else { + await user.update({ + uploadsToday: user.uploadsToday + 1, + uploadsThisMonth: user.uploadsThisMonth + 1, + lastUploadDate: new Date() + }); + } + + // Add job to processing queue + await addJobToQueue({ + projectId: project.id, + type, + filePath: req.file.path, + priority: getProcessingPriority(user.plan), + maxResolution: getMaxResolution(user.plan) + }); + + res.status(201).json({ + message: 'Project created successfully', + project + }); + } catch (error) { + console.error('Create project error:', error); + + // Clean up uploaded file on error + if (req.file) { + try { + await fs.unlink(req.file.path); + } catch (unlinkError) { + console.error('Failed to delete file:', unlinkError); + } + } + + res.status(500).json({ error: 'Failed to create project' }); + } +}; + +/** + * Get all projects for current user + */ +const getProjects = async (req, res) => { + try { + const { status, type, page = 1, limit = 10 } = req.query; + const offset = (page - 1) * limit; + + const where = { userId: req.user.id }; + + if (status) where.status = status; + if (type) where.type = type; + + const { count, rows: projects } = await Project.findAndCountAll({ + where, + order: [['createdAt', 'DESC']], + limit: parseInt(limit), + offset: parseInt(offset) + }); + + res.json({ + projects, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + pages: Math.ceil(count / limit) + } + }); + } catch (error) { + console.error('Get projects error:', error); + res.status(500).json({ error: 'Failed to fetch projects' }); + } +}; + +/** + * Get single project by ID + */ +const getProject = async (req, res) => { + try { + const { id } = req.params; + + const project = await Project.findOne({ + where: { + id, + userId: req.user.id + } + }); + + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + res.json({ project }); + } catch (error) { + console.error('Get project error:', error); + res.status(500).json({ error: 'Failed to fetch project' }); + } +}; + +/** + * Get project status + */ +const getProjectStatus = async (req, res) => { + try { + const { id } = req.params; + + const project = await Project.findOne({ + where: { + id, + userId: req.user.id + }, + attributes: ['id', 'status', 'progress', 'errorMessage', 'createdAt', 'expiresAt'] + }); + + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + res.json({ + status: project.status, + progress: project.progress, + errorMessage: project.errorMessage, + createdAt: project.createdAt, + expiresAt: project.expiresAt, + timeRemaining: Math.max(0, new Date(project.expiresAt) - new Date()) + }); + } catch (error) { + console.error('Get project status error:', error); + res.status(500).json({ error: 'Failed to fetch project status' }); + } +}; + +/** + * Download project result + */ +const downloadProject = async (req, res) => { + try { + const { id } = req.params; + + const project = await Project.findOne({ + where: { + id, + userId: req.user.id + } + }); + + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + if (project.status !== 'completed') { + return res.status(400).json({ error: 'Project is not completed yet' }); + } + + if (!project.resultFilePath) { + return res.status(404).json({ error: 'Result file not found' }); + } + + // Check if file exists + try { + await fs.access(project.resultFilePath); + } catch { + return res.status(404).json({ error: 'Result file not found on server' }); + } + + const fileName = `cleancut_${project.type}_${Date.now()}${path.extname(project.resultFilePath)}`; + res.download(project.resultFilePath, fileName); + } catch (error) { + console.error('Download project error:', error); + res.status(500).json({ error: 'Failed to download project' }); + } +}; + +/** + * Cancel project processing + */ +const cancelProject = async (req, res) => { + try { + const { id } = req.params; + + const project = await Project.findOne({ + where: { + id, + userId: req.user.id + } + }); + + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + if (!['pending', 'processing'].includes(project.status)) { + return res.status(400).json({ error: 'Cannot cancel project in current status' }); + } + + await project.update({ status: 'cancelled' }); + + // TODO: Cancel job in queue + + res.json({ message: 'Project cancelled successfully' }); + } catch (error) { + console.error('Cancel project error:', error); + res.status(500).json({ error: 'Failed to cancel project' }); + } +}; + +/** + * Delete project + */ +const deleteProject = async (req, res) => { + try { + const { id } = req.params; + + const project = await Project.findOne({ + where: { + id, + userId: req.user.id + } + }); + + if (!project) { + return res.status(404).json({ error: 'Project not found' }); + } + + // Delete files + try { + if (project.originalFilePath) { + await fs.unlink(project.originalFilePath); + } + if (project.resultFilePath) { + await fs.unlink(project.resultFilePath); + } + } catch (fileError) { + console.error('Error deleting files:', fileError); + } + + await project.destroy(); + + res.json({ message: 'Project deleted successfully' }); + } catch (error) { + console.error('Delete project error:', error); + res.status(500).json({ error: 'Failed to delete project' }); + } +}; + +/** + * Get dashboard statistics + */ +const getDashboardStats = async (req, res) => { + try { + const userId = req.user.id; + + const totalProjects = await Project.count({ where: { userId } }); + const completedProjects = await Project.count({ where: { userId, status: 'completed' } }); + const processingProjects = await Project.count({ where: { userId, status: 'processing' } }); + const failedProjects = await Project.count({ where: { userId, status: 'failed' } }); + + const recentProjects = await Project.findAll({ + where: { userId }, + order: [['createdAt', 'DESC']], + limit: 5 + }); + + res.json({ + stats: { + total: totalProjects, + completed: completedProjects, + processing: processingProjects, + failed: failedProjects + }, + recentProjects, + user: { + plan: req.user.plan, + uploadsToday: req.user.uploadsToday, + uploadsThisMonth: req.user.uploadsThisMonth, + planEndDate: req.user.planEndDate + } + }); + } catch (error) { + console.error('Get dashboard stats error:', error); + res.status(500).json({ error: 'Failed to fetch dashboard statistics' }); + } +}; + +module.exports = { + createProject, + getProjects, + getProject, + getProjectStatus, + downloadProject, + cancelProject, + deleteProject, + getDashboardStats +}; diff --git a/cleancut-ai/backend/middleware/auth.js b/cleancut-ai/backend/middleware/auth.js new file mode 100644 index 0000000..8735ffa --- /dev/null +++ b/cleancut-ai/backend/middleware/auth.js @@ -0,0 +1,44 @@ +const jwt = require('jsonwebtoken'); +const { User } = require('../models'); + +const authMiddleware = async (req, res, next) => { + try { + const token = req.headers.authorization?.split(' ')[1]; + + if (!token) { + return res.status(401).json({ error: 'No token provided' }); + } + + const decoded = jwt.verify(token, process.env.JWT_SECRET); + const user = await User.findByPk(decoded.userId); + + if (!user) { + return res.status(401).json({ error: 'Invalid token' }); + } + + req.user = user; + next(); + } catch (error) { + if (error.name === 'JsonWebTokenError') { + return res.status(401).json({ error: 'Invalid token' }); + } + if (error.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token expired' }); + } + return res.status(500).json({ error: 'Authentication failed' }); + } +}; + +const checkPlan = (allowedPlans) => { + return (req, res, next) => { + if (!allowedPlans.includes(req.user.plan)) { + return res.status(403).json({ + error: 'Upgrade your plan to access this feature', + requiredPlan: allowedPlans + }); + } + next(); + }; +}; + +module.exports = { authMiddleware, checkPlan }; diff --git a/cleancut-ai/backend/middleware/upload.js b/cleancut-ai/backend/middleware/upload.js new file mode 100644 index 0000000..fd6428f --- /dev/null +++ b/cleancut-ai/backend/middleware/upload.js @@ -0,0 +1,63 @@ +const multer = require('multer'); +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); +const fs = require('fs'); + +// Ensure upload directories exist +const ensureDirectories = () => { + const dirs = ['./uploads/videos', './uploads/images', './uploads/results']; + dirs.forEach(dir => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + }); +}; + +ensureDirectories(); + +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const isVideo = file.mimetype.startsWith('video/'); + const uploadPath = isVideo ? './uploads/videos' : './uploads/images'; + cb(null, uploadPath); + }, + filename: (req, file, cb) => { + const uniqueName = `${uuidv4()}${path.extname(file.originalname)}`; + cb(null, uniqueName); + } +}); + +const fileFilter = (req, file, cb) => { + const allowedVideoTypes = ['video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo']; + const allowedImageTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + + if (allowedVideoTypes.includes(file.mimetype) || allowedImageTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Invalid file type. Only videos (MP4, WebM, MOV, AVI) and images (JPEG, PNG, WebP, GIF) are allowed.'), false); + } +}; + +const upload = multer({ + storage, + fileFilter, + limits: { + fileSize: (process.env.MAX_FILE_SIZE_MB || 500) * 1024 * 1024 // Convert MB to bytes + } +}); + +const handleUploadError = (err, req, res, next) => { + if (err instanceof multer.MulterError) { + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(400).json({ + error: `File too large. Maximum size is ${process.env.MAX_FILE_SIZE_MB || 500}MB` + }); + } + return res.status(400).json({ error: err.message }); + } else if (err) { + return res.status(400).json({ error: err.message }); + } + next(); +}; + +module.exports = { upload, handleUploadError }; diff --git a/cleancut-ai/backend/models/Payment.js b/cleancut-ai/backend/models/Payment.js new file mode 100644 index 0000000..f3a5ec6 --- /dev/null +++ b/cleancut-ai/backend/models/Payment.js @@ -0,0 +1,59 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const Payment = sequelize.define('Payment', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'users', + key: 'id' + } + }, + plan: { + type: DataTypes.ENUM('free', 'intermediate', 'premium'), + allowNull: false + }, + amount: { + type: DataTypes.DECIMAL(10, 2), + allowNull: false + }, + currency: { + type: DataTypes.STRING(3), + defaultValue: 'BRL' + }, + status: { + type: DataTypes.ENUM('pending', 'completed', 'failed', 'refunded', 'cancelled'), + defaultValue: 'pending' + }, + stripePaymentId: { + type: DataTypes.STRING, + allowNull: true + }, + stripeInvoiceId: { + type: DataTypes.STRING, + allowNull: true + }, + metadata: { + type: DataTypes.JSONB, + defaultValue: {} + } +}, { + tableName: 'payments', + timestamps: true, + indexes: [ + { + fields: ['userId'] + }, + { + fields: ['status'] + } + ] +}); + +module.exports = Payment; diff --git a/cleancut-ai/backend/models/Project.js b/cleancut-ai/backend/models/Project.js new file mode 100644 index 0000000..182262a --- /dev/null +++ b/cleancut-ai/backend/models/Project.js @@ -0,0 +1,86 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const Project = sequelize.define('Project', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'users', + key: 'id' + } + }, + type: { + type: DataTypes.ENUM('video', 'image'), + allowNull: false + }, + originalFileName: { + type: DataTypes.STRING, + allowNull: false + }, + originalFilePath: { + type: DataTypes.STRING, + allowNull: false + }, + resultFilePath: { + type: DataTypes.STRING, + allowNull: true + }, + status: { + type: DataTypes.ENUM('pending', 'processing', 'completed', 'failed', 'cancelled'), + defaultValue: 'pending' + }, + progress: { + type: DataTypes.INTEGER, + defaultValue: 0, + validate: { + min: 0, + max: 100 + } + }, + errorMessage: { + type: DataTypes.TEXT, + allowNull: true + }, + metadata: { + type: DataTypes.JSONB, + defaultValue: {} + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: false + }, + fileSize: { + type: DataTypes.BIGINT, + allowNull: true + }, + resolution: { + type: DataTypes.STRING, + allowNull: true + }, + duration: { + type: DataTypes.FLOAT, + allowNull: true + } +}, { + tableName: 'projects', + timestamps: true, + indexes: [ + { + fields: ['userId'] + }, + { + fields: ['status'] + }, + { + fields: ['expiresAt'] + } + ] +}); + +module.exports = Project; diff --git a/cleancut-ai/backend/models/User.js b/cleancut-ai/backend/models/User.js new file mode 100644 index 0000000..a74dec3 --- /dev/null +++ b/cleancut-ai/backend/models/User.js @@ -0,0 +1,103 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); +const bcrypt = require('bcryptjs'); + +const User = sequelize.define('User', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + email: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + validate: { + isEmail: true + } + }, + password: { + type: DataTypes.STRING, + allowNull: false + }, + plan: { + type: DataTypes.ENUM('free', 'intermediate', 'premium'), + defaultValue: 'free' + }, + planStartDate: { + type: DataTypes.DATE, + allowNull: true + }, + planEndDate: { + type: DataTypes.DATE, + allowNull: true + }, + stripeCustomerId: { + type: DataTypes.STRING, + allowNull: true + }, + stripeSubscriptionId: { + type: DataTypes.STRING, + allowNull: true + }, + verified: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + verificationToken: { + type: DataTypes.STRING, + allowNull: true + }, + resetPasswordToken: { + type: DataTypes.STRING, + allowNull: true + }, + resetPasswordExpires: { + type: DataTypes.DATE, + allowNull: true + }, + uploadsThisMonth: { + type: DataTypes.INTEGER, + defaultValue: 0 + }, + uploadsToday: { + type: DataTypes.INTEGER, + defaultValue: 0 + }, + lastUploadDate: { + type: DataTypes.DATEONLY, + allowNull: true + } +}, { + tableName: 'users', + timestamps: true, + hooks: { + beforeCreate: async (user) => { + if (user.password) { + const salt = await bcrypt.genSalt(10); + user.password = await bcrypt.hash(user.password, salt); + } + }, + beforeUpdate: async (user) => { + if (user.changed('password')) { + const salt = await bcrypt.genSalt(10); + user.password = await bcrypt.hash(user.password, salt); + } + } + } +}); + +User.prototype.comparePassword = async function(candidatePassword) { + return await bcrypt.compare(candidatePassword, this.password); +}; + +User.prototype.toJSON = function() { + const values = { ...this.get() }; + delete values.password; + delete values.verificationToken; + delete values.resetPasswordToken; + delete values.resetPasswordExpires; + return values; +}; + +module.exports = User; diff --git a/cleancut-ai/backend/models/index.js b/cleancut-ai/backend/models/index.js new file mode 100644 index 0000000..da648dd --- /dev/null +++ b/cleancut-ai/backend/models/index.js @@ -0,0 +1,29 @@ +const { sequelize } = require('../config/database'); +const User = require('./User'); +const Project = require('./Project'); +const Payment = require('./Payment'); + +// Define associations +User.hasMany(Project, { foreignKey: 'userId', as: 'projects' }); +Project.belongsTo(User, { foreignKey: 'userId', as: 'user' }); + +User.hasMany(Payment, { foreignKey: 'userId', as: 'payments' }); +Payment.belongsTo(User, { foreignKey: 'userId', as: 'user' }); + +const syncDatabase = async (force = false) => { + try { + await sequelize.sync({ force }); + console.log('✓ Database synchronized successfully'); + } catch (error) { + console.error('✗ Database sync failed:', error); + throw error; + } +}; + +module.exports = { + sequelize, + User, + Project, + Payment, + syncDatabase +}; diff --git a/cleancut-ai/backend/routes/auth.js b/cleancut-ai/backend/routes/auth.js new file mode 100644 index 0000000..1088e07 --- /dev/null +++ b/cleancut-ai/backend/routes/auth.js @@ -0,0 +1,24 @@ +const express = require('express'); +const router = express.Router(); +const authController = require('../controllers/authController'); +const { authMiddleware } = require('../middleware/auth'); +const { validateEmailMiddleware } = require('../utils/emailValidator'); +const rateLimit = require('express-rate-limit'); + +// Rate limiting for auth endpoints +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // 5 requests per window + message: 'Too many attempts, please try again later' +}); + +// Public routes +router.post('/register', validateEmailMiddleware, authLimiter, authController.register); +router.post('/login', authLimiter, authController.login); +router.post('/password-reset/request', authLimiter, authController.requestPasswordReset); +router.post('/password-reset/confirm', authLimiter, authController.resetPassword); + +// Protected routes +router.get('/profile', authMiddleware, authController.getProfile); + +module.exports = router; diff --git a/cleancut-ai/backend/routes/payments.js b/cleancut-ai/backend/routes/payments.js new file mode 100644 index 0000000..a98b9ae --- /dev/null +++ b/cleancut-ai/backend/routes/payments.js @@ -0,0 +1,14 @@ +const express = require('express'); +const router = express.Router(); +const paymentsController = require('../controllers/paymentsController'); +const { authMiddleware } = require('../middleware/auth'); + +// Webhook endpoint (no auth, raw body needed) +router.post('/webhook', express.raw({ type: 'application/json' }), paymentsController.handleWebhook); + +// Protected routes +router.post('/checkout', authMiddleware, paymentsController.createCheckoutSession); +router.get('/billing', authMiddleware, paymentsController.getBillingInfo); +router.post('/cancel-subscription', authMiddleware, paymentsController.cancelSubscription); + +module.exports = router; diff --git a/cleancut-ai/backend/routes/projects.js b/cleancut-ai/backend/routes/projects.js new file mode 100644 index 0000000..d8fcbf9 --- /dev/null +++ b/cleancut-ai/backend/routes/projects.js @@ -0,0 +1,22 @@ +const express = require('express'); +const router = express.Router(); +const projectsController = require('../controllers/projectsController'); +const { authMiddleware } = require('../middleware/auth'); +const { upload, handleUploadError } = require('../middleware/upload'); + +// All routes require authentication +router.use(authMiddleware); + +// Dashboard stats +router.get('/dashboard', projectsController.getDashboardStats); + +// Project CRUD +router.post('/', upload.single('file'), handleUploadError, projectsController.createProject); +router.get('/', projectsController.getProjects); +router.get('/:id', projectsController.getProject); +router.get('/:id/status', projectsController.getProjectStatus); +router.get('/:id/download', projectsController.downloadProject); +router.post('/:id/cancel', projectsController.cancelProject); +router.delete('/:id', projectsController.deleteProject); + +module.exports = router; diff --git a/cleancut-ai/backend/server.js b/cleancut-ai/backend/server.js new file mode 100644 index 0000000..75e6438 --- /dev/null +++ b/cleancut-ai/backend/server.js @@ -0,0 +1,180 @@ +const express = require('express'); +const cors = require('cors'); +const helmet = require('helmet'); +const path = require('path'); +require('dotenv').config(); + +const { testConnection, sequelize } = require('./config/database'); +const { connectRedis } = require('./config/redis'); +const { syncDatabase } = require('./models'); +const { initializeCronJobs } = require('./utils/cleanup-cron'); + +// Import routes +const authRoutes = require('./routes/auth'); +const projectsRoutes = require('./routes/projects'); +const paymentsRoutes = require('./routes/payments'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Security middleware +app.use(helmet()); + +// CORS configuration +app.use(cors({ + origin: process.env.FRONTEND_URL || 'http://localhost:3000', + credentials: true +})); + +// Body parsing middleware +// Note: Webhook route needs raw body, so we handle it separately +app.use('/api/payments/webhook', express.raw({ type: 'application/json' })); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// Serve static files from frontend +app.use(express.static(path.join(__dirname, '../frontend'))); + +// API Routes +app.use('/api/auth', authRoutes); +app.use('/api/projects', projectsRoutes); +app.use('/api/payments', paymentsRoutes); + +// Health check endpoint +app.get('/api/health', (req, res) => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + environment: process.env.NODE_ENV + }); +}); + +// API documentation endpoint +app.get('/api', (req, res) => { + res.json({ + name: 'CleanCut IA API', + version: '1.0.0', + endpoints: { + auth: { + register: 'POST /api/auth/register', + login: 'POST /api/auth/login', + profile: 'GET /api/auth/profile', + passwordReset: 'POST /api/auth/password-reset/request' + }, + projects: { + create: 'POST /api/projects', + list: 'GET /api/projects', + get: 'GET /api/projects/:id', + status: 'GET /api/projects/:id/status', + download: 'GET /api/projects/:id/download', + cancel: 'POST /api/projects/:id/cancel', + delete: 'DELETE /api/projects/:id', + dashboard: 'GET /api/projects/dashboard' + }, + payments: { + checkout: 'POST /api/payments/checkout', + billing: 'GET /api/payments/billing', + cancel: 'POST /api/payments/cancel-subscription', + webhook: 'POST /api/payments/webhook' + } + } + }); +}); + +// Serve frontend pages +app.get('/', (req, res) => { + res.sendFile(path.join(__dirname, '../frontend/index.html')); +}); + +app.get('/login', (req, res) => { + res.sendFile(path.join(__dirname, '../frontend/login.html')); +}); + +app.get('/dashboard', (req, res) => { + res.sendFile(path.join(__dirname, '../frontend/dashboard.html')); +}); + +app.get('/video-removal', (req, res) => { + res.sendFile(path.join(__dirname, '../frontend/video-removal.html')); +}); + +app.get('/image-removal', (req, res) => { + res.sendFile(path.join(__dirname, '../frontend/image-removal.html')); +}); + +app.get('/projects', (req, res) => { + res.sendFile(path.join(__dirname, '../frontend/projects.html')); +}); + +app.get('/billing', (req, res) => { + res.sendFile(path.join(__dirname, '../frontend/billing.html')); +}); + +// 404 handler +app.use((req, res) => { + res.status(404).json({ error: 'Endpoint not found' }); +}); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error('Error:', err); + res.status(err.status || 500).json({ + error: err.message || 'Internal server error', + ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) + }); +}); + +// Initialize server +const startServer = async () => { + try { + console.log('🚀 Starting CleanCut IA Server...\n'); + + // Test database connection + const dbConnected = await testConnection(); + if (!dbConnected) { + throw new Error('Database connection failed'); + } + + // Sync database models + await syncDatabase(false); // Set to true to drop and recreate tables + + // Connect to Redis + const redisConnected = await connectRedis(); + if (!redisConnected) { + console.warn('⚠️ Redis connection failed - queue functionality will be limited'); + } + + // Initialize cron jobs for cleanup + initializeCronJobs(); + + // Start server + app.listen(PORT, () => { + console.log(`\n✓ Server running on port ${PORT}`); + console.log(`✓ Environment: ${process.env.NODE_ENV}`); + console.log(`✓ Frontend URL: ${process.env.FRONTEND_URL}`); + console.log(`\n📚 API Documentation: http://localhost:${PORT}/api`); + console.log(`🏠 Frontend: http://localhost:${PORT}\n`); + }); + } catch (error) { + console.error('❌ Failed to start server:', error); + process.exit(1); + } +}; + +// Handle graceful shutdown +process.on('SIGTERM', async () => { + console.log('\n🛑 SIGTERM received, shutting down gracefully...'); + await sequelize.close(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + console.log('\n🛑 SIGINT received, shutting down gracefully...'); + await sequelize.close(); + process.exit(0); +}); + +// Start the server +startServer(); + +module.exports = app; diff --git a/cleancut-ai/backend/utils/cleanup-cron.js b/cleancut-ai/backend/utils/cleanup-cron.js new file mode 100644 index 0000000..c295158 --- /dev/null +++ b/cleancut-ai/backend/utils/cleanup-cron.js @@ -0,0 +1,119 @@ +const cron = require('node-cron'); +const { Project } = require('../models'); +const { Op } = require('sequelize'); +const fs = require('fs').promises; + +/** + * Clean up expired projects (older than 24 hours) + */ +const cleanupExpiredProjects = async () => { + try { + console.log('Running cleanup job...'); + + const expiredProjects = await Project.findAll({ + where: { + expiresAt: { + [Op.lt]: new Date() + } + } + }); + + console.log(`Found ${expiredProjects.length} expired projects`); + + for (const project of expiredProjects) { + try { + // Delete original file + if (project.originalFilePath) { + try { + await fs.unlink(project.originalFilePath); + console.log(`Deleted original file: ${project.originalFilePath}`); + } catch (err) { + console.error(`Failed to delete original file: ${err.message}`); + } + } + + // Delete result file + if (project.resultFilePath) { + try { + await fs.unlink(project.resultFilePath); + console.log(`Deleted result file: ${project.resultFilePath}`); + } catch (err) { + console.error(`Failed to delete result file: ${err.message}`); + } + } + + // Delete project from database + await project.destroy(); + console.log(`Deleted project ${project.id} from database`); + } catch (error) { + console.error(`Error cleaning up project ${project.id}:`, error); + } + } + + console.log('Cleanup job completed'); + } catch (error) { + console.error('Cleanup job error:', error); + } +}; + +/** + * Reset monthly upload counters (runs on 1st of each month) + */ +const resetMonthlyCounters = async () => { + try { + console.log('Resetting monthly upload counters...'); + + const { User } = require('../models'); + await User.update( + { uploadsThisMonth: 0 }, + { where: {} } + ); + + console.log('Monthly counters reset successfully'); + } catch (error) { + console.error('Error resetting monthly counters:', error); + } +}; + +/** + * Initialize cron jobs + */ +const initializeCronJobs = () => { + // Run cleanup every hour + cron.schedule('0 * * * *', cleanupExpiredProjects); + console.log('✓ Cleanup cron job scheduled (runs every hour)'); + + // Reset monthly counters on 1st of each month at midnight + cron.schedule('0 0 1 * *', resetMonthlyCounters); + console.log('✓ Monthly reset cron job scheduled (runs on 1st of each month)'); + + // Run cleanup immediately on startup + cleanupExpiredProjects(); +}; + +// If run directly +if (require.main === module) { + require('dotenv').config(); + const { sequelize } = require('../config/database'); + + sequelize.authenticate() + .then(() => { + console.log('Database connected'); + cleanupExpiredProjects() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); + }) + .catch((err) => { + console.error('Database connection failed:', err); + process.exit(1); + }); +} + +module.exports = { + cleanupExpiredProjects, + resetMonthlyCounters, + initializeCronJobs +}; diff --git a/cleancut-ai/backend/utils/emailValidator.js b/cleancut-ai/backend/utils/emailValidator.js new file mode 100644 index 0000000..b3f11f7 --- /dev/null +++ b/cleancut-ai/backend/utils/emailValidator.js @@ -0,0 +1,116 @@ +const validator = require('validator'); + +// List of known temporary email domains +const temporaryEmailDomains = [ + '10minutemail.com', + 'guerrillamail.com', + 'mailinator.com', + 'tempmail.com', + 'throwaway.email', + 'temp-mail.org', + 'getnada.com', + 'maildrop.cc', + 'trashmail.com', + 'yopmail.com', + 'fakeinbox.com', + 'sharklasers.com', + 'guerrillamail.info', + 'grr.la', + 'guerrillamail.biz', + 'guerrillamail.de', + 'spam4.me', + 'mailnesia.com', + 'emailondeck.com', + 'mintemail.com', + 'mytemp.email', + 'mohmal.com', + 'dispostable.com', + 'tempail.com', + 'throwawaymail.com', + '10mail.org', + 'mailcatch.com', + 'mailtemp.info', + 'getairmail.com', + 'anonbox.net', + 'anonymbox.com' +]; + +/** + * Validates if an email is legitimate and not from a temporary email provider + * @param {string} email - Email address to validate + * @returns {Object} - { valid: boolean, message: string } + */ +const validateEmail = (email) => { + // Basic email format validation + if (!validator.isEmail(email)) { + return { + valid: false, + message: 'Invalid email format' + }; + } + + // Extract domain from email + const domain = email.split('@')[1].toLowerCase(); + + // Check against temporary email domains list + if (temporaryEmailDomains.includes(domain)) { + return { + valid: false, + message: 'Temporary email addresses are not allowed. Please use a permanent email address.' + }; + } + + // Check for suspicious patterns (very short domains, numbers only, etc.) + if (domain.length < 5) { + return { + valid: false, + message: 'Email domain appears to be invalid' + }; + } + + // Check for common patterns in disposable emails + const suspiciousPatterns = [ + /temp/i, + /trash/i, + /disposable/i, + /throwaway/i, + /fake/i, + /spam/i, + /\d{5,}/ // 5 or more consecutive digits + ]; + + for (const pattern of suspiciousPatterns) { + if (pattern.test(domain)) { + return { + valid: false, + message: 'Temporary or disposable email addresses are not allowed' + }; + } + } + + return { + valid: true, + message: 'Email is valid' + }; +}; + +/** + * Middleware to validate email during registration + */ +const validateEmailMiddleware = (req, res, next) => { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ error: 'Email is required' }); + } + + const validation = validateEmail(email); + + if (!validation.valid) { + return res.status(400).json({ error: validation.message }); + } + + next(); +}; + +module.exports = { validateEmail, validateEmailMiddleware, temporaryEmailDomains }; diff --git a/cleancut-ai/backend/utils/jobQueue.js b/cleancut-ai/backend/utils/jobQueue.js new file mode 100644 index 0000000..1e5f51f --- /dev/null +++ b/cleancut-ai/backend/utils/jobQueue.js @@ -0,0 +1,124 @@ +const Queue = require('bull'); +const { Project } = require('../models'); + +// Create processing queue +const processingQueue = new Queue('background-removal', { + redis: { + host: process.env.REDIS_HOST, + port: process.env.REDIS_PORT, + password: process.env.REDIS_PASSWORD || undefined + } +}); + +/** + * Add job to processing queue + */ +const addJobToQueue = async (jobData) => { + try { + const priorityMap = { + 'high': 1, + 'medium': 2, + 'low': 3 + }; + + const job = await processingQueue.add(jobData, { + priority: priorityMap[jobData.priority] || 3, + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000 + }, + removeOnComplete: true, + removeOnFail: false + }); + + console.log(`Job ${job.id} added to queue for project ${jobData.projectId}`); + return job; + } catch (error) { + console.error('Error adding job to queue:', error); + throw error; + } +}; + +/** + * Process jobs from queue + */ +processingQueue.process(async (job) => { + const { projectId, type, filePath, maxResolution } = job.data; + + try { + console.log(`Processing job ${job.id} for project ${projectId}`); + + // Update project status + await Project.update( + { status: 'processing', progress: 0 }, + { where: { id: projectId } } + ); + + // Update progress periodically + const updateProgress = async (progress) => { + await Project.update( + { progress }, + { where: { id: projectId } } + ); + job.progress(progress); + }; + + // Simulate processing (in production, call Python worker) + // This is a placeholder - actual processing will be done by Python workers + await updateProgress(10); + + // TODO: Call Python worker script here + // const result = await processFile(filePath, type, maxResolution); + + await updateProgress(50); + await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate processing + await updateProgress(80); + await new Promise(resolve => setTimeout(resolve, 1000)); + await updateProgress(100); + + // For now, just mark as completed + // In production, update with actual result file path + await Project.update( + { + status: 'completed', + progress: 100, + resultFilePath: filePath // Placeholder - should be actual processed file + }, + { where: { id: projectId } } + ); + + console.log(`Job ${job.id} completed for project ${projectId}`); + return { success: true, projectId }; + } catch (error) { + console.error(`Job ${job.id} failed:`, error); + + await Project.update( + { + status: 'failed', + errorMessage: error.message + }, + { where: { id: projectId } } + ); + + throw error; + } +}); + +// Queue event listeners +processingQueue.on('completed', (job, result) => { + console.log(`Job ${job.id} completed:`, result); +}); + +processingQueue.on('failed', (job, err) => { + console.error(`Job ${job.id} failed:`, err.message); +}); + +processingQueue.on('stalled', (job) => { + console.warn(`Job ${job.id} stalled`); +}); + +module.exports = { + processingQueue, + addJobToQueue +}; diff --git a/cleancut-ai/backend/utils/planLimits.js b/cleancut-ai/backend/utils/planLimits.js new file mode 100644 index 0000000..7e3a675 --- /dev/null +++ b/cleancut-ai/backend/utils/planLimits.js @@ -0,0 +1,131 @@ +/** + * Plan limits and features configuration + */ +const planLimits = { + free: { + name: 'Free Trial', + duration: 7, // days + dailyUploads: parseInt(process.env.FREE_DAILY_UPLOADS) || 3, + monthlyUploads: null, + maxResolution: parseInt(process.env.FREE_MAX_RESOLUTION) || 720, + priority: 'low', + price: 0, + features: [ + '3 uploads per day', + 'Up to 720p resolution', + '7 days trial', + 'Basic support' + ] + }, + intermediate: { + name: 'Intermediate', + duration: 365, // days (12 months) + dailyUploads: null, + monthlyUploads: parseInt(process.env.INTERMEDIATE_MONTHLY_UPLOADS) || 30, + maxResolution: parseInt(process.env.INTERMEDIATE_MAX_RESOLUTION) || 1080, + priority: 'medium', + price: 50, // R$ per month + features: [ + '30 uploads per month', + 'Up to 1080p resolution', + 'Medium priority processing', + 'Email support', + '12-month commitment' + ] + }, + premium: { + name: 'Premium', + duration: 365, // days (12 months) + dailyUploads: null, + monthlyUploads: parseInt(process.env.PREMIUM_MONTHLY_UPLOADS) || 999, + maxResolution: parseInt(process.env.PREMIUM_MAX_RESOLUTION) || 2160, + priority: 'high', + price: 360, // R$ per month (with 40% discount) + originalPrice: 600, + discount: 40, + features: [ + 'Unlimited uploads', + 'Up to 4K resolution', + 'High priority processing', + 'Priority support', + '12-month commitment', + '40% discount' + ] + } +}; + +/** + * Check if user can upload based on their plan limits + * @param {Object} user - User object with plan and upload counts + * @returns {Object} - { allowed: boolean, message: string } + */ +const canUserUpload = (user) => { + const limits = planLimits[user.plan]; + + if (!limits) { + return { allowed: false, message: 'Invalid plan' }; + } + + // Check daily limit for free plan + if (limits.dailyUploads !== null) { + const today = new Date().toISOString().split('T')[0]; + const lastUpload = user.lastUploadDate ? user.lastUploadDate.toISOString().split('T')[0] : null; + + // Reset daily counter if it's a new day + if (lastUpload !== today) { + return { allowed: true, message: 'Upload allowed', resetDaily: true }; + } + + if (user.uploadsToday >= limits.dailyUploads) { + return { + allowed: false, + message: `Daily upload limit reached (${limits.dailyUploads} uploads per day). Upgrade to continue.` + }; + } + } + + // Check monthly limit for paid plans + if (limits.monthlyUploads !== null) { + if (user.uploadsThisMonth >= limits.monthlyUploads) { + return { + allowed: false, + message: `Monthly upload limit reached (${limits.monthlyUploads} uploads per month). Upgrade to continue.` + }; + } + } + + // Check if plan has expired + if (user.planEndDate && new Date(user.planEndDate) < new Date()) { + return { + allowed: false, + message: 'Your plan has expired. Please renew to continue.' + }; + } + + return { allowed: true, message: 'Upload allowed' }; +}; + +/** + * Get resolution limit for user's plan + * @param {string} plan - Plan name + * @returns {number} - Maximum resolution height + */ +const getMaxResolution = (plan) => { + return planLimits[plan]?.maxResolution || 720; +}; + +/** + * Get processing priority for user's plan + * @param {string} plan - Plan name + * @returns {string} - Priority level + */ +const getProcessingPriority = (plan) => { + return planLimits[plan]?.priority || 'low'; +}; + +module.exports = { + planLimits, + canUserUpload, + getMaxResolution, + getProcessingPriority +}; diff --git a/cleancut-ai/frontend/css/style.css b/cleancut-ai/frontend/css/style.css new file mode 100644 index 0000000..709de90 --- /dev/null +++ b/cleancut-ai/frontend/css/style.css @@ -0,0 +1,414 @@ +/* Global Styles for CleanCut IA */ + +:root { + --primary-color: #6366f1; + --primary-dark: #4f46e5; + --primary-light: #818cf8; + --secondary-color: #10b981; + --danger-color: #ef4444; + --warning-color: #f59e0b; + --dark-bg: #1f2937; + --darker-bg: #111827; + --light-bg: #f9fafb; + --border-color: #e5e7eb; + --text-primary: #111827; + --text-secondary: #6b7280; + --text-light: #9ca3af; + --success-color: #10b981; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + line-height: 1.6; + color: var(--text-primary); + background-color: #ffffff; +} + +/* Typography */ +h1, h2, h3, h4, h5, h6 { + font-weight: 700; + line-height: 1.2; + margin-bottom: 1rem; +} + +h1 { font-size: 2.5rem; } +h2 { font-size: 2rem; } +h3 { font-size: 1.75rem; } +h4 { font-size: 1.5rem; } +h5 { font-size: 1.25rem; } +h6 { font-size: 1rem; } + +p { + margin-bottom: 1rem; + color: var(--text-secondary); +} + +a { + color: var(--primary-color); + text-decoration: none; + transition: color 0.3s ease; +} + +a:hover { + color: var(--primary-dark); +} + +/* Container */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 1.5rem; +} + +.container-fluid { + width: 100%; + padding: 0 1.5rem; +} + +/* Buttons */ +.btn { + display: inline-block; + padding: 0.75rem 1.5rem; + font-size: 1rem; + font-weight: 600; + text-align: center; + border: none; + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.3s ease; + text-decoration: none; +} + +.btn-primary { + background-color: var(--primary-color); + color: white; +} + +.btn-primary:hover { + background-color: var(--primary-dark); + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.btn-secondary { + background-color: var(--secondary-color); + color: white; +} + +.btn-secondary:hover { + background-color: #059669; +} + +.btn-outline { + background-color: transparent; + border: 2px solid var(--primary-color); + color: var(--primary-color); +} + +.btn-outline:hover { + background-color: var(--primary-color); + color: white; +} + +.btn-danger { + background-color: var(--danger-color); + color: white; +} + +.btn-danger:hover { + background-color: #dc2626; +} + +.btn-sm { + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.btn-lg { + padding: 1rem 2rem; + font-size: 1.125rem; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Cards */ +.card { + background: white; + border-radius: 0.75rem; + box-shadow: var(--shadow-md); + padding: 1.5rem; + margin-bottom: 1.5rem; + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-xl); +} + +.card-header { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 2px solid var(--border-color); +} + +.card-body { + padding: 1rem 0; +} + +.card-footer { + padding-top: 1rem; + border-top: 1px solid var(--border-color); + margin-top: 1rem; +} + +/* Forms */ +.form-group { + margin-bottom: 1.5rem; +} + +.form-label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: var(--text-primary); +} + +.form-control { + width: 100%; + padding: 0.75rem 1rem; + font-size: 1rem; + border: 2px solid var(--border-color); + border-radius: 0.5rem; + transition: border-color 0.3s ease, box-shadow 0.3s ease; +} + +.form-control:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.form-control.error { + border-color: var(--danger-color); +} + +.form-error { + color: var(--danger-color); + font-size: 0.875rem; + margin-top: 0.25rem; +} + +/* Alerts */ +.alert { + padding: 1rem 1.5rem; + border-radius: 0.5rem; + margin-bottom: 1rem; + font-weight: 500; +} + +.alert-success { + background-color: #d1fae5; + color: #065f46; + border-left: 4px solid var(--success-color); +} + +.alert-error { + background-color: #fee2e2; + color: #991b1b; + border-left: 4px solid var(--danger-color); +} + +.alert-warning { + background-color: #fef3c7; + color: #92400e; + border-left: 4px solid var(--warning-color); +} + +.alert-info { + background-color: #dbeafe; + color: #1e40af; + border-left: 4px solid var(--primary-color); +} + +/* Loading Spinner */ +.spinner { + border: 3px solid var(--border-color); + border-top: 3px solid var(--primary-color); + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + margin: 2rem auto; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Progress Bar */ +.progress { + width: 100%; + height: 1rem; + background-color: var(--border-color); + border-radius: 0.5rem; + overflow: hidden; +} + +.progress-bar { + height: 100%; + background-color: var(--primary-color); + transition: width 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 0.75rem; + font-weight: 600; +} + +/* Badge */ +.badge { + display: inline-block; + padding: 0.25rem 0.75rem; + font-size: 0.75rem; + font-weight: 600; + border-radius: 9999px; + text-transform: uppercase; +} + +.badge-primary { + background-color: var(--primary-light); + color: white; +} + +.badge-success { + background-color: var(--success-color); + color: white; +} + +.badge-warning { + background-color: var(--warning-color); + color: white; +} + +.badge-danger { + background-color: var(--danger-color); + color: white; +} + +/* Utility Classes */ +.text-center { text-align: center; } +.text-left { text-align: left; } +.text-right { text-align: right; } + +.mt-1 { margin-top: 0.5rem; } +.mt-2 { margin-top: 1rem; } +.mt-3 { margin-top: 1.5rem; } +.mt-4 { margin-top: 2rem; } + +.mb-1 { margin-bottom: 0.5rem; } +.mb-2 { margin-bottom: 1rem; } +.mb-3 { margin-bottom: 1.5rem; } +.mb-4 { margin-bottom: 2rem; } + +.p-1 { padding: 0.5rem; } +.p-2 { padding: 1rem; } +.p-3 { padding: 1.5rem; } +.p-4 { padding: 2rem; } + +.d-none { display: none; } +.d-block { display: block; } +.d-flex { display: flex; } +.d-grid { display: grid; } + +.flex-column { flex-direction: column; } +.flex-row { flex-direction: row; } +.justify-center { justify-content: center; } +.justify-between { justify-content: space-between; } +.align-center { align-items: center; } + +.gap-1 { gap: 0.5rem; } +.gap-2 { gap: 1rem; } +.gap-3 { gap: 1.5rem; } + +.w-full { width: 100%; } +.h-full { height: 100%; } + +/* Responsive */ +@media (max-width: 768px) { + h1 { font-size: 2rem; } + h2 { font-size: 1.75rem; } + h3 { font-size: 1.5rem; } + + .container { + padding: 0 1rem; + } + + .btn { + width: 100%; + } +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.fade-in { + animation: fadeIn 0.5s ease-out; +} + +/* Toast Notifications */ +.toast-container { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.toast { + background: white; + padding: 1rem 1.5rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-xl); + min-width: 300px; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + transform: translateX(400px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} diff --git a/cleancut-ai/frontend/index.html b/cleancut-ai/frontend/index.html new file mode 100644 index 0000000..e64d7b4 --- /dev/null +++ b/cleancut-ai/frontend/index.html @@ -0,0 +1,361 @@ + + + + + + CleanCut IA - Remova Fundos de Vídeos e Imagens com IA + + + + + + + + +
+
+

Remova Fundos com IA em Segundos

+

Transforme seus vídeos e imagens com tecnologia de ponta

+ +
+
+ + +
+
+

Recursos Poderosos

+

Tudo que você precisa para criar conteúdo profissional

+ +
+
+
🎥
+

Remoção de Fundo em Vídeos

+

Remova fundos de vídeos com precisão usando IA avançada. Suporte para até 4K.

+
+ +
+
🖼️
+

Remoção de Fundo em Imagens

+

Processe imagens em segundos com resultados profissionais e transparência perfeita.

+
+ +
+
+

Processamento Rápido

+

Tecnologia otimizada para entregar resultados em tempo recorde.

+
+ +
+
🎯
+

Seleção Inteligente

+

Selecione objetos específicos para preservar com ferramentas intuitivas.

+
+ +
+
💾
+

Armazenamento Seguro

+

Seus projetos ficam disponíveis por 24 horas para download.

+
+ +
+
🔒
+

100% Seguro

+

Seus arquivos são protegidos e deletados automaticamente após o período.

+
+
+
+
+ + +
+
+

Planos para Todos

+

Escolha o plano ideal para suas necessidades

+ +
+ +
+

Gratuito

+
R$ 0
+
7 dias de teste
+
    +
  • 3 uploads por dia
  • +
  • Resolução até 720p
  • +
  • Suporte básico
  • +
  • Armazenamento 24h
  • +
+ Começar Grátis +
+ + + + + +
+

Premium

+
R$ 360/mês
+
+ R$ 600 - 40% OFF por 12 meses +
+
    +
  • Uploads ilimitados
  • +
  • Resolução até 4K
  • +
  • Prioridade máxima
  • +
  • Suporte prioritário
  • +
  • Armazenamento 24h
  • +
  • Recursos exclusivos
  • +
+ Assinar Premium +
+
+
+
+ + +
+
+

Pronto para Começar?

+

Experimente grátis por 7 dias. Sem cartão de crédito necessário.

+ + Criar Conta Grátis + +
+
+ + +
+
+

© 2024 CleanCut IA. Todos os direitos reservados.

+

+ Remova fundos de vídeos e imagens com inteligência artificial +

+
+
+ + + + + diff --git a/cleancut-ai/frontend/js/auth.js b/cleancut-ai/frontend/js/auth.js new file mode 100644 index 0000000..a1a3a55 --- /dev/null +++ b/cleancut-ai/frontend/js/auth.js @@ -0,0 +1,178 @@ +// Authentication utilities + +const API_URL = window.location.origin + '/api'; + +// Get token from localStorage +const getToken = () => { + return localStorage.getItem('token'); +}; + +// Set token in localStorage +const setToken = (token) => { + localStorage.setItem('token', token); +}; + +// Remove token from localStorage +const removeToken = () => { + localStorage.removeItem('token'); + localStorage.removeItem('user'); +}; + +// Get user from localStorage +const getUser = () => { + const userStr = localStorage.getItem('user'); + return userStr ? JSON.parse(userStr) : null; +}; + +// Set user in localStorage +const setUser = (user) => { + localStorage.setItem('user', JSON.stringify(user)); +}; + +// Check if user is authenticated +const isAuthenticated = () => { + return !!getToken(); +}; + +// Make authenticated API request +const authFetch = async (url, options = {}) => { + const token = getToken(); + + const headers = { + 'Content-Type': 'application/json', + ...options.headers + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(url, { + ...options, + headers + }); + + // Handle unauthorized + if (response.status === 401) { + removeToken(); + window.location.href = '/login'; + throw new Error('Unauthorized'); + } + + return response; +}; + +// Register user +const register = async (email, password, confirmPassword) => { + const response = await fetch(`${API_URL}/auth/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ email, password, confirmPassword }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Registration failed'); + } + + setToken(data.token); + setUser(data.user); + + return data; +}; + +// Login user +const login = async (email, password) => { + const response = await fetch(`${API_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ email, password }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Login failed'); + } + + setToken(data.token); + setUser(data.user); + + return data; +}; + +// Logout user +const logout = () => { + removeToken(); + window.location.href = '/login'; +}; + +// Get user profile +const getProfile = async () => { + const response = await authFetch(`${API_URL}/auth/profile`); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to fetch profile'); + } + + setUser(data.user); + return data.user; +}; + +// Request password reset +const requestPasswordReset = async (email) => { + const response = await fetch(`${API_URL}/auth/password-reset/request`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ email }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to request password reset'); + } + + return data; +}; + +// Reset password +const resetPassword = async (token, password, confirmPassword) => { + const response = await fetch(`${API_URL}/auth/password-reset/confirm`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ token, password, confirmPassword }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to reset password'); + } + + return data; +}; + +// Protect page (redirect to login if not authenticated) +const protectPage = () => { + if (!isAuthenticated()) { + window.location.href = '/login'; + } +}; + +// Redirect if authenticated (for login/register pages) +const redirectIfAuthenticated = () => { + if (isAuthenticated()) { + window.location.href = '/dashboard'; + } +}; diff --git a/cleancut-ai/frontend/js/utils.js b/cleancut-ai/frontend/js/utils.js new file mode 100644 index 0000000..49feabd --- /dev/null +++ b/cleancut-ai/frontend/js/utils.js @@ -0,0 +1,213 @@ +// Utility functions + +// Show toast notification +const showToast = (message, type = 'info') => { + const container = document.getElementById('toast-container') || createToastContainer(); + + const toast = document.createElement('div'); + toast.className = `toast alert alert-${type}`; + toast.textContent = message; + + container.appendChild(toast); + + setTimeout(() => { + toast.style.animation = 'slideOut 0.3s ease-out'; + setTimeout(() => { + container.removeChild(toast); + }, 300); + }, 3000); +}; + +const createToastContainer = () => { + const container = document.createElement('div'); + container.id = 'toast-container'; + container.className = 'toast-container'; + document.body.appendChild(container); + return container; +}; + +// Format file size +const formatFileSize = (bytes) => { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; +}; + +// Format date +const formatDate = (dateString) => { + const date = new Date(dateString); + return date.toLocaleDateString('pt-BR', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); +}; + +// Format time remaining +const formatTimeRemaining = (milliseconds) => { + const hours = Math.floor(milliseconds / (1000 * 60 * 60)); + const minutes = Math.floor((milliseconds % (1000 * 60 * 60)) / (1000 * 60)); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; +}; + +// Show loading spinner +const showLoading = (elementId) => { + const element = document.getElementById(elementId); + if (element) { + element.innerHTML = '
'; + } +}; + +// Hide loading spinner +const hideLoading = (elementId) => { + const element = document.getElementById(elementId); + if (element) { + element.innerHTML = ''; + } +}; + +// Validate email format +const isValidEmail = (email) => { + const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return re.test(email); +}; + +// Validate password strength +const validatePassword = (password) => { + const errors = []; + + if (password.length < 8) { + errors.push('Password must be at least 8 characters long'); + } + + if (!/[A-Z]/.test(password)) { + errors.push('Password must contain at least one uppercase letter'); + } + + if (!/[a-z]/.test(password)) { + errors.push('Password must contain at least one lowercase letter'); + } + + if (!/[0-9]/.test(password)) { + errors.push('Password must contain at least one number'); + } + + return { + valid: errors.length === 0, + errors + }; +}; + +// Debounce function +const debounce = (func, wait) => { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +}; + +// Get plan badge color +const getPlanBadgeClass = (plan) => { + const classes = { + 'free': 'badge-primary', + 'intermediate': 'badge-warning', + 'premium': 'badge-success' + }; + return classes[plan] || 'badge-primary'; +}; + +// Get status badge color +const getStatusBadgeClass = (status) => { + const classes = { + 'pending': 'badge-warning', + 'processing': 'badge-primary', + 'completed': 'badge-success', + 'failed': 'badge-danger', + 'cancelled': 'badge-secondary' + }; + return classes[status] || 'badge-primary'; +}; + +// Copy to clipboard +const copyToClipboard = (text) => { + navigator.clipboard.writeText(text).then(() => { + showToast('Copied to clipboard!', 'success'); + }).catch(() => { + showToast('Failed to copy', 'error'); + }); +}; + +// Confirm dialog +const confirm = (message) => { + return window.confirm(message); +}; + +// Handle API errors +const handleApiError = (error) => { + console.error('API Error:', error); + showToast(error.message || 'An error occurred', 'error'); +}; + +// Update progress bar +const updateProgressBar = (elementId, progress) => { + const progressBar = document.getElementById(elementId); + if (progressBar) { + progressBar.style.width = `${progress}%`; + progressBar.textContent = `${progress}%`; + } +}; + +// Create modal +const createModal = (title, content, buttons = []) => { + const modal = document.createElement('div'); + modal.className = 'modal'; + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + + // Close modal on X click + modal.querySelector('.modal-close').addEventListener('click', () => { + document.body.removeChild(modal); + }); + + // Close modal on outside click + modal.addEventListener('click', (e) => { + if (e.target === modal) { + document.body.removeChild(modal); + } + }); + + return modal; +}; diff --git a/cleancut-ai/frontend/login.html b/cleancut-ai/frontend/login.html new file mode 100644 index 0000000..78c8639 --- /dev/null +++ b/cleancut-ai/frontend/login.html @@ -0,0 +1,329 @@ + + + + + + Login - CleanCut IA + + + + +
+
+

CleanCut IA

+

Remova fundos com inteligência artificial

+
+ +
+
Entrar
+
Criar Conta
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + + + +
+ + +
+
+ +
+ + + + Não aceitamos emails temporários + +
+ +
+ + +
+
+
+
+
+ +
+ + +
+ + + +

+ Ao criar uma conta, você concorda com nossos Termos de Uso +

+
+ + +
+ + + + + + diff --git a/cleancut-ai/package.json b/cleancut-ai/package.json new file mode 100644 index 0000000..5e8bbc3 --- /dev/null +++ b/cleancut-ai/package.json @@ -0,0 +1,38 @@ +{ + "name": "cleancut-ai", + "version": "1.0.0", + "description": "SaaS for removing backgrounds from videos and images", + "main": "backend/server.js", + "scripts": { + "start": "node backend/server.js", + "dev": "nodemon backend/server.js", + "worker": "node backend/workers/queue-consumer.js", + "cleanup": "node backend/utils/cleanup-cron.js" + }, + "keywords": ["saas", "video", "image", "background-removal", "ai"], + "author": "CleanCut IA", + "license": "MIT", + "dependencies": { + "express": "^4.18.2", + "pg": "^8.11.3", + "pg-hstore": "^2.3.4", + "sequelize": "^6.35.2", + "bcryptjs": "^2.4.3", + "jsonwebtoken": "^9.0.2", + "dotenv": "^16.3.1", + "cors": "^2.8.5", + "multer": "^1.4.5-lts.1", + "bull": "^4.12.0", + "redis": "^4.6.11", + "stripe": "^14.10.0", + "nodemailer": "^6.9.7", + "express-rate-limit": "^7.1.5", + "helmet": "^7.1.0", + "validator": "^13.11.0", + "uuid": "^9.0.1", + "node-cron": "^3.0.3" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } +} diff --git a/docker-compose.yml b/docker-compose.yml index a66e326..8a7486a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,14 @@ version: '3.9' services: evolution-api: - build: . container_name: evolution_api + image: atendai/evolution-api:v2.2.3 restart: always ports: - "8080:8080" environment: - # Configurações do PostgreSQL + # variáveis aqui + # Configurações do PostgreSQL DATABASE_ENABLED: "${DATABASE_ENABLED}" DATABASE_PROVIDER: "${DATABASE_PROVIDER}" DATABASE_CONNECTION_URI: "${DATABASE_CONNECTION_URI}" @@ -20,31 +21,6 @@ services: DATABASE_SAVE_DATA_LABELS: "${DATABASE_SAVE_DATA_LABELS}" DATABASE_SAVE_DATA_HISTORIC: "${DATABASE_SAVE_DATA_HISTORIC}" - # Configurações do Redis - CACHE_REDIS_ENABLED: "${CACHE_REDIS_ENABLED}" - CACHE_REDIS_URI: "${CACHE_REDIS_URI}" - CACHE_REDIS_PREFIX_KEY: "${CACHE_REDIS_PREFIX_KEY}" - CACHE_REDIS_SAVE_INSTANCES: "${CACHE_REDIS_SAVE_INSTANCES}" - CACHE_LOCAL_ENABLED: "${CACHE_LOCAL_ENABLED}" - - # Chave de autenticação da API - AUTHENTICATION_API_KEY: "${AUTHENTICATION_API_KEY}" - - # Resolução problema do QR code - CONFIG_SESSION_PHONE_VERSION: "${CONFIG_SESSION_PHONE_VERSION}" - - # ===== WebSocket / QR Code / RabbitMQ ===== - WEBSOCKET_ENABLED: "${WEBSOCKET_ENABLED}" - WEBSOCKET_GLOBAL_EVENTS: "${WEBSOCKET_GLOBAL_EVENTS}" - - RABBITMQ_ENABLED: "${RABBITMQ_ENABLED}" - RABBITMQ_GLOBAL_ENABLED: "${RABBITMQ_GLOBAL_ENABLED}" - RABBITMQ_URI: "${RABBITMQ_URI}" - RABBITMQ_EXCHANGE_NAME: "${RABBITMQ_EXCHANGE_NAME}" - RABBITMQ_EVENTS_QRCODE_UPDATED: "${RABBITMQ_EVENTS_QRCODE_UPDATED}" - - QRCODE_LIMIT: "${QRCODE_LIMIT}" - QRCODE_COLOR: "${QRCODE_COLOR}" depends_on: - redis - postgres @@ -55,6 +31,8 @@ services: restart: always ports: - "6379:6379" + volumes: + - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "-u", "${CACHE_REDIS_URI}", "ping"] interval: 10s @@ -71,12 +49,14 @@ services: POSTGRES_USER: "${POSTGRES_USER}" POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" POSTGRES_DB: "${POSTGRES_DB}" + volumes: + - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD", "pg_isready", "-U", "${POSTGRES_USER}"] interval: 10s timeout: 5s retries: 5 - volumes: redis_data: postgres_data: +