commit a949eb8e57214b86029fc0dabccdb1a60dc6b3d2 Author: true1ck Date: Sun Feb 8 03:59:53 2026 +0530 Initial commit: Blog Editor application diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59eb5d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules/ +package-lock.json + +# Environment variables +.env +.env.local +.env.*.local + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build outputs +dist/ +build/ +*.local + +# OS files +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Temporary files +*.tmp +.cache/ diff --git a/ENV_EXAMPLES.md b/ENV_EXAMPLES.md new file mode 100644 index 0000000..8fb4554 --- /dev/null +++ b/ENV_EXAMPLES.md @@ -0,0 +1,127 @@ +# Environment Variables Examples + +## Backend Environment Variables + +Create a `.env` file in `blog-editor/backend/` with the following: + +```env +# ===================================================== +# SERVER CONFIGURATION +# ===================================================== +PORT=5000 +NODE_ENV=development + +# ===================================================== +# DATABASE CONFIGURATION (PostgreSQL - Supabase) +# ===================================================== +# Option 1: Use Supabase connection string (recommended) +# Format: postgresql://user:password@host:port/database +DATABASE_URL=postgresql://postgres.ekqfmpvebntssdgwtioj:[YOUR-PASSWORD]@aws-1-ap-south-1.pooler.supabase.com:5432/postgres + +# Option 2: Use individual parameters (for local development) +# Uncomment and use these if not using DATABASE_URL +# DB_HOST=localhost +# DB_PORT=5432 +# DB_NAME=blog_editor +# DB_USER=postgres +# DB_PASSWORD=your_database_password_here + +# ===================================================== +# AUTH SERVICE INTEGRATION +# ===================================================== +# URL of your existing auth service +# The blog editor validates JWT tokens via this service +AUTH_SERVICE_URL=http://localhost:3000 + +# ===================================================== +# AWS S3 CONFIGURATION (for image uploads) +# ===================================================== +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=your_aws_access_key_here +AWS_SECRET_ACCESS_KEY=your_aws_secret_key_here +S3_BUCKET_NAME=blog-editor-images + +# ===================================================== +# CORS CONFIGURATION +# ===================================================== +# Frontend URL that will make requests to this backend +CORS_ORIGIN=http://localhost:4000 + +# Production example: +# CORS_ORIGIN=https://your-frontend-domain.com +``` + +## Frontend Environment Variables + +Create a `.env` file in `blog-editor/frontend/` with the following: + +```env +# ===================================================== +# BLOG EDITOR BACKEND API URL +# ===================================================== +# URL of the blog editor backend API +# This is where posts, uploads, etc. are handled +VITE_API_URL=http://localhost:5001 + +# Production example: +# VITE_API_URL=https://api.yourdomain.com + +# ===================================================== +# AUTH SERVICE API URL +# ===================================================== +# URL of your existing auth service +# This is where authentication (login, OTP, etc.) is handled +VITE_AUTH_API_URL=http://localhost:3000 + +# Production example: +# VITE_AUTH_API_URL=https://auth.yourdomain.com +``` + +## Quick Setup + +### Backend +```bash +cd blog-editor/backend +cp env.example .env +# Edit .env with your actual values +``` + +### Frontend +```bash +cd blog-editor/frontend +cp env.example .env +# Edit .env with your actual values +``` + +## Required Values to Update + +### Backend `.env` +- `DATABASE_URL` - **Supabase connection string** (replace `[YOUR-PASSWORD]` with actual password) + - Format: `postgresql://postgres.ekqfmpvebntssdgwtioj:[YOUR-PASSWORD]@aws-1-ap-south-1.pooler.supabase.com:5432/postgres` + - Or use individual DB_* parameters for local development +- `AUTH_SERVICE_URL` - URL where your auth service is running (default: http://localhost:3000) + - **Note:** Auth service uses its own separate database +- `AWS_ACCESS_KEY_ID` - Your AWS access key +- `AWS_SECRET_ACCESS_KEY` - Your AWS secret key +- `S3_BUCKET_NAME` - Your S3 bucket name +- `CORS_ORIGIN` - Your frontend URL (default: http://localhost:4000) + +### Frontend `.env` +- `VITE_API_URL` - Your blog editor backend URL (default: http://localhost:5001) +- `VITE_AUTH_API_URL` - Your auth service URL (default: http://localhost:3000) + +## Notes + +1. **VITE_ prefix**: Frontend environment variables must start with `VITE_` to be accessible in the code +2. **Database (Supabase)**: + - Replace `[YOUR-PASSWORD]` in `DATABASE_URL` with your actual Supabase password + - Supabase automatically handles SSL connections + - The connection string uses Supabase's connection pooler + - Make sure the database exists in Supabase (or use default `postgres` database) +3. **Auth Service**: + - Ensure your auth service is running on the port specified in `AUTH_SERVICE_URL` + - **Important:** Auth service uses its own separate database (not Supabase) +4. **AWS S3**: + - Create an S3 bucket + - Configure CORS to allow PUT requests from your frontend + - Create IAM user with `s3:PutObject` and `s3:GetObject` permissions diff --git a/INTEGRATION.md b/INTEGRATION.md new file mode 100644 index 0000000..26aa489 --- /dev/null +++ b/INTEGRATION.md @@ -0,0 +1,91 @@ +# Auth Service Integration + +The blog editor is integrated with the existing auth service located at `G:\LivingAi\GITTEA_RPO\auth`. + +## How It Works + +### Backend Integration + +The blog editor backend validates JWT tokens by calling the auth service's `/auth/validate-token` endpoint: + +1. Client sends request with `Authorization: Bearer ` header +2. Blog editor backend middleware (`middleware/auth.js`) extracts the token +3. Middleware calls `POST /auth/validate-token` on the auth service +4. Auth service validates the token and returns user info +5. Blog editor backend sets `req.user` and continues processing + +### Frontend Integration + +The frontend uses the auth service directly for authentication: + +1. **Login Flow:** + - User enters phone number + - Frontend calls `POST /auth/request-otp` on auth service + - User enters OTP + - Frontend calls `POST /auth/verify-otp` on auth service + - Auth service returns `access_token` and `refresh_token` + - Frontend stores tokens in localStorage + +2. **API Requests:** + - Frontend includes `Authorization: Bearer ` header + - Blog editor backend validates token via auth service + - If token expires, frontend automatically refreshes using `refresh_token` + +## Configuration + +### Backend (.env) +```env +AUTH_SERVICE_URL=http://localhost:3000 +``` + +### Frontend (.env) +```env +VITE_AUTH_API_URL=http://localhost:3000 +``` + +## Token Storage + +- `access_token` - Stored in localStorage, used for API requests +- `refresh_token` - Stored in localStorage, used to refresh access token +- `user` - User object stored in localStorage + +## Authentication Flow + +``` +┌─────────┐ ┌──────────────┐ ┌─────────────┐ +│ Client │────────▶│ Auth Service │────────▶│ Blog Editor │ +│ │ │ │ │ Backend │ +└─────────┘ └──────────────┘ └─────────────┘ + │ │ │ + │ 1. Request OTP │ │ + │◀─────────────────────│ │ + │ │ │ + │ 2. Verify OTP │ │ + │─────────────────────▶│ │ + │ 3. Get Tokens │ │ + │◀─────────────────────│ │ + │ │ │ + │ 4. API Request │ │ + │──────────────────────────────────────────────▶│ + │ │ 5. Validate Token │ + │ │◀───────────────────────│ + │ │ 6. User Info │ + │ │───────────────────────▶│ + │ 7. Response │ │ + │◀──────────────────────────────────────────────│ +``` + +## Benefits + +1. **Single Source of Truth:** All authentication handled by one service +2. **Consistent Security:** Same JWT validation across all services +3. **Token Rotation:** Auth service handles token refresh and rotation +4. **User Management:** Centralized user management in auth service +5. **Guest Support:** Auth service supports guest users + +## Notes + +- The blog editor backend does NOT handle user registration/login +- All authentication is delegated to the auth service +- The blog editor only validates tokens, not creates them +- Phone/OTP authentication is used (not email/password) diff --git a/QUICK_START.md b/QUICK_START.md new file mode 100644 index 0000000..579ef23 --- /dev/null +++ b/QUICK_START.md @@ -0,0 +1,66 @@ +# Quick Start Guide + +## Prerequisites Check + +- [ ] Node.js 18+ installed +- [ ] PostgreSQL installed and running +- [ ] AWS account with S3 bucket created +- [ ] AWS IAM user with S3 permissions + +## 5-Minute Setup + +### 1. Backend Setup (2 minutes) + +```bash +cd backend +npm install +cp .env.example .env +# Edit .env with your database and AWS credentials +createdb blog_editor # or use psql to create database +npm run migrate +npm run dev +``` + +### 2. Frontend Setup (2 minutes) + +```bash +cd frontend +npm install +cp .env.example .env +# Edit .env: VITE_API_URL=http://localhost:5001 +npm run dev +``` + +### 3. Test the Application (1 minute) + +1. Open http://localhost:4000 +2. Register a new account +3. Create a new post +4. Add some content with formatting +5. Upload an image +6. Publish the post + +## Common Issues + +### Database Connection Error +- Check PostgreSQL is running: `pg_isready` +- Verify credentials in `.env` +- Ensure database exists: `psql -l | grep blog_editor` + +### S3 Upload Fails +- Verify AWS credentials in `.env` +- Check S3 bucket name is correct +- Ensure bucket CORS is configured +- Verify IAM user has PutObject permission + +### CORS Error +- Check `CORS_ORIGIN` in backend `.env` matches frontend URL +- Default: `http://localhost:4000` + +## Next Steps + +- Customize the editor styling +- Add more TipTap extensions +- Configure production environment variables +- Set up CI/CD pipeline +- Deploy to AWS diff --git a/README.md b/README.md new file mode 100644 index 0000000..489eaa7 --- /dev/null +++ b/README.md @@ -0,0 +1,259 @@ +# Blog Editor - Full Stack Application + +A full-stack blog editor built with TipTap, React, Node.js, PostgreSQL, and AWS S3. + +## Features + +- ✨ Rich text editor with TipTap +- 📝 Auto-save drafts every 10 seconds +- 🖼️ Image upload to AWS S3 with presigned URLs +- 🔐 Phone/OTP authentication (integrated with existing auth service) +- 📱 Mobile responsive UI +- 🎨 Rich formatting options (Bold, Italic, Underline, Headings, Lists, Quotes, Code blocks, Colors, Font sizes) +- 📄 Public blog pages +- 🎯 Dashboard for managing posts + +## Tech Stack + +### Frontend +- React 18 +- Vite +- TipTap Editor +- Tailwind CSS +- React Router +- Axios + +### Backend +- Node.js +- Express +- PostgreSQL +- JWT + bcrypt +- AWS S3 SDK + +## Project Structure + +``` +blog-editor/ +├── frontend/ # React + Vite application +│ ├── src/ +│ │ ├── components/ # React components +│ │ ├── pages/ # Page components +│ │ ├── contexts/ # React contexts +│ │ ├── utils/ # Utility functions +│ │ └── extensions/ # TipTap extensions +│ └── package.json +├── backend/ # Express API +│ ├── config/ # Database and S3 config +│ ├── routes/ # API routes +│ ├── middleware/ # Auth middleware +│ ├── migrations/ # Database migrations +│ └── package.json +└── README.md +``` + +## Setup Instructions + +### Prerequisites + +- Node.js 18+ +- PostgreSQL 12+ +- AWS Account with S3 bucket +- AWS Access Key and Secret Key + +### Backend Setup + +1. Navigate to backend directory: +```bash +cd backend +``` + +2. Install dependencies: +```bash +npm install +``` + +3. Create `.env` file: +```env +PORT=5000 +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=blog_editor +DB_USER=postgres +DB_PASSWORD=your_password +AUTH_SERVICE_URL=http://localhost:3000 +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=your_access_key +AWS_SECRET_ACCESS_KEY=your_secret_key +S3_BUCKET_NAME=blog-editor-images +CORS_ORIGIN=http://localhost:4000 +``` + +**Note:** The blog editor uses the existing auth service at `G:\LivingAi\GITTEA_RPO\auth`. Make sure: +- The auth service is running on port 3000 (or update `AUTH_SERVICE_URL`) +- **Auth service uses its own separate database** (not Supabase) +- Blog editor uses Supabase for storing posts + +5. Configure Supabase database: + - Add your Supabase connection string to `.env` as `DATABASE_URL` + - Format: `postgresql://postgres.ekqfmpvebntssdgwtioj:[YOUR-PASSWORD]@aws-1-ap-south-1.pooler.supabase.com:5432/postgres` + - Replace `[YOUR-PASSWORD]` with your actual Supabase password + +6. Run migrations: +```bash +npm run migrate +``` + +7. Start the server: +```bash +npm run dev +``` + +The backend will run on `http://localhost:5001` + +### Frontend Setup + +1. Navigate to frontend directory: +```bash +cd frontend +``` + +2. Install dependencies: +```bash +npm install +``` + +3. Create `.env` file: +```bash +cp .env.example .env +``` + +4. Update `.env`: +```env +VITE_API_URL=http://localhost:5001 +VITE_AUTH_API_URL=http://localhost:3000 +``` + +**Note:** `VITE_AUTH_API_URL` should point to your existing auth service. + +5. Start the development server: +```bash +npm run dev +``` + +The frontend will run on `http://localhost:4000` + +## AWS S3 Setup + +1. Create an S3 bucket in your AWS account +2. Configure CORS for the bucket: +```json +[ + { + "AllowedHeaders": ["*"], + "AllowedMethods": ["PUT", "POST", "GET"], + "AllowedOrigins": ["http://localhost:4000"], + "ExposeHeaders": [] + } +] +``` + +3. Create an IAM user with S3 permissions: + - `s3:PutObject` on your bucket + - `s3:GetObject` on your bucket + +4. Add the Access Key ID and Secret Access Key to your backend `.env` file + +## Database Schema + +**Note:** Users are managed by the auth service in a separate database. The blog editor only stores `user_id` references. + +### Posts Table +- `id` (UUID, Primary Key) +- `user_id` (UUID) - References user ID from auth service (no foreign key constraint) +- `title` (VARCHAR) +- `content_json` (JSONB) - TipTap editor content stored as JSON +- `slug` (VARCHAR, Unique) - URL-friendly post identifier +- `status` (VARCHAR: 'draft' or 'published') +- `created_at` (TIMESTAMP) +- `updated_at` (TIMESTAMP) - Auto-updated via trigger + +## API Endpoints + +### Authentication (Handled by Existing Auth Service) +The blog editor uses the existing auth service at `G:\LivingAi\GITTEA_RPO\auth`: +- `POST /auth/request-otp` - Request OTP for phone number +- `POST /auth/verify-otp` - Verify OTP and get tokens +- `POST /auth/refresh` - Refresh access token +- `POST /auth/logout` - Logout user +- `POST /auth/validate-token` - Validate JWT token (used by blog editor backend) + +### Posts +- `GET /api/posts` - Get all posts for current user (protected) +- `GET /api/posts/:id` - Get single post (protected) +- `GET /api/posts/slug/:slug` - Get post by slug (public) +- `POST /api/posts` - Create new post (protected) +- `PUT /api/posts/:id` - Update post (protected) +- `DELETE /api/posts/:id` - Delete post (protected) + +### Upload +- `POST /api/upload/presigned-url` - Get presigned URL for image upload (protected) + +## Pages + +- `/login` - Login page (Phone/OTP authentication) +- `/dashboard` - User dashboard (protected) +- `/editor` - Create new post (protected) +- `/editor/:id` - Edit existing post (protected) +- `/blog/:slug` - Public blog post view + +## Deployment + +### Backend (AWS EC2) + +1. Set up EC2 instance +2. Install Node.js and PostgreSQL +3. Clone repository +4. Set environment variables +5. Run migrations +6. Use PM2 or similar to run the server: +```bash +pm2 start server.js --name blog-editor-api +``` + +### Frontend (AWS Amplify or Vercel) + +#### AWS Amplify: +1. Connect your repository +2. Set build settings: + - Build command: `npm run build` + - Output directory: `dist` +3. Add environment variable: `VITE_API_URL` + +#### Vercel: +1. Import your repository +2. Set build command: `npm run build` +3. Add environment variable: `VITE_API_URL` + +## Environment Variables + +### Backend +- `PORT` - Server port (default: 5000) +- `DB_HOST` - PostgreSQL host +- `DB_PORT` - PostgreSQL port +- `DB_NAME` - Database name +- `DB_USER` - Database user +- `DB_PASSWORD` - Database password +- `AUTH_SERVICE_URL` - URL of existing auth service (default: http://localhost:3000) +- `AWS_REGION` - AWS region +- `AWS_ACCESS_KEY_ID` - AWS access key +- `AWS_SECRET_ACCESS_KEY` - AWS secret key +- `S3_BUCKET_NAME` - S3 bucket name +- `CORS_ORIGIN` - CORS allowed origin + +### Frontend +- `VITE_API_URL` - Blog editor backend API URL +- `VITE_AUTH_API_URL` - Auth service API URL (default: http://localhost:3000) + +## License + +MIT diff --git a/RUN_APPLICATION.md b/RUN_APPLICATION.md new file mode 100644 index 0000000..9d9f9fa --- /dev/null +++ b/RUN_APPLICATION.md @@ -0,0 +1,219 @@ +# How to Run the Blog Editor Application + +## Prerequisites + +1. **Node.js 18+** installed +2. **PostgreSQL/Supabase** database configured +3. **Auth service** running (at `G:\LivingAi\GITTEA_RPO\auth`) +4. **AWS S3** configured (for image uploads) + +## Step-by-Step Setup + +### 1. Start the Auth Service (Required First) + +The blog editor depends on your existing auth service. Make sure it's running: + +```bash +cd G:\LivingAi\GITTEA_RPO\auth +npm install # If not already done +npm start # or npm run dev +``` + +The auth service should be running on `http://localhost:3000` (or your configured port). + +### 2. Setup Backend + +#### Install Dependencies +```bash +cd blog-editor/backend +npm install +``` + +#### Configure Environment +Make sure you have a `.env` file in `blog-editor/backend/`: +```bash +# If you haven't created it yet +cp env.example .env +# Then edit .env with your actual values +``` + +Your `.env` should have: +- `DATABASE_URL` - Your Supabase connection string +- `AUTH_SERVICE_URL` - URL of auth service (default: http://localhost:3000) +- AWS credentials for S3 +- Other required variables + +#### Run Database Migrations +```bash +npm run migrate +``` + +This will create the `posts` table and indexes in your Supabase database. + +#### Start Backend Server +```bash +npm run dev +``` + +The backend will start on `http://localhost:5001` (or your configured PORT). + +You should see: +``` +Server running on port 5001 +``` + +### 3. Setup Frontend + +#### Install Dependencies +```bash +cd blog-editor/frontend +npm install +``` + +#### Configure Environment +Make sure you have a `.env` file in `blog-editor/frontend/`: +```bash +# If you haven't created it yet +cp env.example .env +# Then edit .env with your actual values +``` + +Your `.env` should have: +- `VITE_API_URL=http://localhost:5001` - Backend API URL +- `VITE_AUTH_API_URL=http://localhost:3000` - Auth service URL + +#### Start Frontend Dev Server +```bash +npm run dev +``` + +The frontend will start on `http://localhost:4000`. + +You should see: +``` + VITE v5.x.x ready in xxx ms + + ➜ Local: http://localhost:4000/ + ➜ Network: use --host to expose +``` + +## Running Everything Together + +### Option 1: Separate Terminals (Recommended) + +**Terminal 1 - Auth Service:** +```bash +cd G:\LivingAi\GITTEA_RPO\auth +npm start +``` + +**Terminal 2 - Blog Editor Backend:** +```bash +cd blog-editor/backend +npm run dev +``` + +**Terminal 3 - Blog Editor Frontend:** +```bash +cd blog-editor/frontend +npm run dev +``` + +### Option 2: Using npm scripts (if you create them) + +You could create a root `package.json` with scripts to run everything, but separate terminals are easier for debugging. + +## Verify Everything is Working + +### 1. Check Auth Service +```bash +curl http://localhost:3000/health +# Should return: {"ok":true} +``` + +### 2. Check Backend +```bash +curl http://localhost:5000/api/health +# Should return: {"status":"ok"} +``` + +### 3. Check Database Connection +```bash +curl http://localhost:5000/api/test-db +# Should return database connection info +``` + +### 4. Open Frontend +Open your browser to the frontend URL (usually `http://localhost:5173` or `http://localhost:3000`) + +## First Time Usage + +1. **Open the frontend** in your browser +2. **Click Login** (or navigate to `/login`) +3. **Enter your phone number** (e.g., `+919876543210` or `9876543210`) +4. **Request OTP** - You'll receive an OTP via SMS (or console if using test numbers) +5. **Enter OTP** to verify +6. **You'll be logged in** and redirected to the dashboard +7. **Create your first post** by clicking "New Post" + +## Troubleshooting + +### Backend won't start +- Check if port 5001 is already in use +- Verify `.env` file exists and has correct values +- Check database connection string is correct +- Ensure auth service is running + +### Frontend won't start +- Check if port is already in use (Vite will auto-select another port) +- Verify `.env` file exists with `VITE_` prefixed variables +- Check that backend is running + +### Database connection errors +- Verify Supabase connection string is correct +- Check that password doesn't have special characters that need URL encoding +- Ensure Supabase database is accessible +- Check IP whitelist in Supabase settings + +### Auth service connection errors +- Verify auth service is running on the correct port +- Check `AUTH_SERVICE_URL` in backend `.env` +- Check `VITE_AUTH_API_URL` in frontend `.env` + +### CORS errors +- Verify `CORS_ORIGIN` in backend `.env` matches frontend URL +- Check that auth service CORS settings allow your frontend origin + +## Production Build + +### Build Frontend +```bash +cd blog-editor/frontend +npm run build +``` + +The built files will be in `blog-editor/frontend/dist/` + +### Start Backend in Production +```bash +cd blog-editor/backend +NODE_ENV=production npm start +``` + +## Quick Commands Reference + +```bash +# Backend +cd blog-editor/backend +npm install # Install dependencies +npm run migrate # Run database migrations +npm run dev # Start dev server +npm start # Start production server + +# Frontend +cd blog-editor/frontend +npm install # Install dependencies +npm run dev # Start dev server +npm run build # Build for production +npm run preview # Preview production build +``` diff --git a/S3_CORS_SETUP.md b/S3_CORS_SETUP.md new file mode 100644 index 0000000..5344f49 --- /dev/null +++ b/S3_CORS_SETUP.md @@ -0,0 +1,123 @@ +# S3 CORS Configuration Guide + +## Problem +If you're getting "Failed to fetch" error when uploading images, it's likely a CORS (Cross-Origin Resource Sharing) issue with your S3 bucket. + +## Solution: Configure S3 Bucket CORS + +### Step 1: Go to AWS S3 Console +1. Log in to AWS Console +2. Navigate to S3 +3. Click on your bucket (e.g., `livingai-media-bucket`) + +### Step 2: Configure CORS +1. Click on the **Permissions** tab +2. Scroll down to **Cross-origin resource sharing (CORS)** +3. Click **Edit** + +### Step 3: Add CORS Configuration +Paste this CORS configuration: + +```json +[ + { + "AllowedHeaders": [ + "*" + ], + "AllowedMethods": [ + "GET", + "PUT", + "POST", + "HEAD" + ], + "AllowedOrigins": [ + "http://localhost:4000", + "http://localhost:3000", + "http://localhost:5173", + "https://your-production-domain.com" + ], + "ExposeHeaders": [ + "ETag" + ], + "MaxAgeSeconds": 3000 + } +] +``` + +**Important:** +- Replace `https://your-production-domain.com` with your actual production domain +- Add any other origins you need (e.g., staging domains) + +### Step 4: Save Configuration +1. Click **Save changes** +2. Wait a few seconds for the changes to propagate + +### Step 5: Test Again +Try uploading an image again. The CORS error should be resolved. + +## Alternative: Bucket Policy (if CORS doesn't work) + +If CORS still doesn't work, you may also need to configure the bucket policy: + +1. Go to **Permissions** tab +2. Click **Bucket policy** +3. Add this policy (replace `YOUR-BUCKET-NAME` and `YOUR-ACCOUNT-ID`): + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "PublicReadGetObject", + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/*" + }, + { + "Sid": "AllowPutObject", + "Effect": "Allow", + "Principal": "*", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/*" + } + ] +} +``` + +**Note:** This makes your bucket publicly writable. For production, use IAM roles or signed URLs (which you're already using). + +## Verify CORS is Working + +After configuring CORS, check the browser console. You should see: +- No CORS errors +- Successful PUT request to S3 +- Image uploads working + +## Common Issues + +### Issue 1: CORS still not working +- **Solution:** Clear browser cache and try again +- **Solution:** Make sure the origin in CORS matches exactly (including http vs https, port numbers) + +### Issue 2: "Access Denied" error +- **Solution:** Check IAM permissions for your AWS credentials +- **Solution:** Ensure your AWS user has `s3:PutObject` permission + +### Issue 3: Presigned URL expires +- **Solution:** The presigned URL expires in 3600 seconds (1 hour). If you wait too long, generate a new one. + +## Testing CORS Configuration + +You can test if CORS is configured correctly using curl: + +```bash +curl -X OPTIONS \ + -H "Origin: http://localhost:4000" \ + -H "Access-Control-Request-Method: PUT" \ + -H "Access-Control-Request-Headers: Content-Type" \ + https://YOUR-BUCKET-NAME.s3.REGION.amazonaws.com/images/test.jpg \ + -v +``` + +You should see `Access-Control-Allow-Origin` in the response headers. diff --git a/SUPABASE_SETUP.md b/SUPABASE_SETUP.md new file mode 100644 index 0000000..2983333 --- /dev/null +++ b/SUPABASE_SETUP.md @@ -0,0 +1,104 @@ +# Supabase Database Setup + +The blog editor uses Supabase PostgreSQL for storing blog posts. The auth service uses its own separate database. + +## Connection String Format + +Your Supabase connection string should look like: +``` +postgresql://postgres.ekqfmpvebntssdgwtioj:[YOUR-PASSWORD]@aws-1-ap-south-1.pooler.supabase.com:5432/postgres +``` + +## Setup Steps + +### 1. Get Your Supabase Connection String + +1. Go to your Supabase project dashboard +2. Navigate to **Settings** → **Database** +3. Find the **Connection string** section +4. Copy the **Connection pooling** connection string (recommended) +5. Replace `[YOUR-PASSWORD]` with your actual database password + +### 2. Update Backend `.env` + +Add to `blog-editor/backend/.env`: +```env +DATABASE_URL=postgresql://postgres.ekqfmpvebntssdgwtioj:your_actual_password@aws-1-ap-south-1.pooler.supabase.com:5432/postgres +``` + +### 3. Create Database Schema + +Run the migrations to create the required tables: +```bash +cd blog-editor/backend +npm run migrate +``` + +This will create: +- `users` table (if not exists - though auth service has its own users table) +- `posts` table for blog posts +- Required indexes + +### 4. Verify Connection + +Test the database connection: +```bash +# The backend has a test endpoint +curl http://localhost:5001/api/test-db +``` + +## Database Schema + +The blog editor creates these tables in Supabase: + +### `posts` table +- `id` (UUID, Primary Key) +- `user_id` (UUID, Foreign Key - references auth service user ID) +- `title` (VARCHAR) +- `content_json` (JSONB) - TipTap editor content +- `slug` (VARCHAR, Unique) +- `status` (VARCHAR: 'draft' or 'published') +- `created_at` (TIMESTAMP) +- `updated_at` (TIMESTAMP) + +### Indexes +- `idx_posts_user_id` - For fast user queries +- `idx_posts_slug` - For fast slug lookups +- `idx_posts_status` - For filtering by status + +## Important Notes + +1. **Separate Databases**: + - Blog editor uses Supabase PostgreSQL + - Auth service uses its own separate database + - User IDs from auth service are stored as `user_id` in posts table + +2. **Connection Pooling**: + - Supabase connection string uses their pooler + - This is more efficient for serverless/server applications + - SSL is automatically handled + +3. **User IDs**: + - The `user_id` in posts table references the user ID from your auth service + - Make sure the auth service user IDs are UUIDs (which they should be) + +4. **Database Name**: + - Default Supabase database is `postgres` + - You can create a separate database if needed, just update the connection string + +## Troubleshooting + +### Connection Issues +- Verify your password is correct +- Check that your IP is allowed in Supabase (Settings → Database → Connection Pooling) +- Ensure you're using the connection pooling URL (not direct connection) + +### Migration Issues +- Make sure you have proper permissions on the database +- Check that the database exists +- Verify the connection string format is correct + +### SSL Issues +- Supabase requires SSL connections +- The code automatically sets `rejectUnauthorized: false` for Supabase +- This is safe because Supabase uses valid SSL certificates diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md new file mode 100644 index 0000000..c2ecebd --- /dev/null +++ b/TROUBLESHOOTING.md @@ -0,0 +1,117 @@ +# Troubleshooting Guide + +## "Failed to fetch" Error When Uploading Images + +This error means the frontend cannot connect to the backend API. Check the following: + +### 1. Check Backend is Running + +Make sure your backend server is running: +```bash +cd blog-editor/backend +npm run dev +``` + +You should see: +``` +✅ Blog Editor Backend is running! + 🌐 Server: http://localhost:5001 +``` + +### 2. Check Frontend API URL + +In `blog-editor/frontend/.env`, make sure: +```env +VITE_API_URL=http://localhost:5001 +``` + +**Important:** The port must match your backend port (check your backend terminal output). + +### 3. Check Browser Console + +Open browser DevTools (F12) → Console tab and look for: +- Network errors +- CORS errors +- 404 errors +- Connection refused errors + +### 4. Test Backend Manually + +Open in browser or use curl: +```bash +# Health check +curl http://localhost:5001/api/health + +# Should return: {"status":"ok"} +``` + +### 5. Check CORS Configuration + +In `blog-editor/backend/.env`: +```env +CORS_ORIGIN=http://localhost:4000 +``` + +Make sure this matches your frontend URL. + +### 6. Check AWS S3 Configuration + +If you see "AWS S3 is not configured" error: + +In `blog-editor/backend/.env`, add: +```env +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=your_access_key +AWS_SECRET_ACCESS_KEY=your_secret_key +S3_BUCKET_NAME=blog-editor-images +``` + +**Note:** Image uploads won't work without AWS S3 configured. You can: +- Set up AWS S3 (recommended for production) +- Or temporarily disable image uploads for testing + +### 7. Check Authentication Token + +Make sure you're logged in. The upload endpoint requires authentication. + +Check browser console → Application → Local Storage: +- Should have `access_token` +- Should have `refresh_token` + +### 8. Common Issues + +**Issue:** Backend on different port +- **Fix:** Update `VITE_API_URL` in frontend `.env` to match backend port + +**Issue:** CORS blocking requests +- **Fix:** Update `CORS_ORIGIN` in backend `.env` to match frontend URL + +**Issue:** Backend not running +- **Fix:** Start backend: `cd blog-editor/backend && npm run dev` + +**Issue:** Network error +- **Fix:** Check firewall, VPN, or proxy settings + +### 9. Test Upload Endpoint Directly + +```bash +# Get your access token from browser localStorage +# Then test: +curl -X POST http://localhost:5001/api/upload/presigned-url \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"filename":"test.jpg","contentType":"image/jpeg"}' +``` + +### 10. Enable Detailed Logging + +Check backend terminal for error messages when you try to upload. + +## Quick Fix Checklist + +- [ ] Backend is running (check terminal) +- [ ] Frontend `.env` has correct `VITE_API_URL` +- [ ] Backend `.env` has correct `CORS_ORIGIN` +- [ ] You're logged in (check localStorage for tokens) +- [ ] Browser console shows no CORS errors +- [ ] AWS S3 is configured (if using image uploads) diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..4afa02f --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +.env +*.log +.DS_Store +dist/ +build/ +# TEMPORARY: Local images folder (remove in production) +images/* +!images/.gitkeep diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..21d5da9 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,39 @@ +# Blog Editor Backend + +Express.js API server for the blog editor application. + +## Setup + +1. Install dependencies: +```bash +npm install +``` + +2. Create `.env` file (see `.env.example`) + +3. Create PostgreSQL database: +```bash +createdb blog_editor +``` + +4. Run migrations: +```bash +npm run migrate +``` + +5. Start server: +```bash +npm run dev +``` + +## API Documentation + +See main README.md for API endpoints. + +## Database + +PostgreSQL database with two main tables: +- `users` - User accounts +- `posts` - Blog posts + +Run migrations to set up the schema. diff --git a/backend/UPDATE_ENV.md b/backend/UPDATE_ENV.md new file mode 100644 index 0000000..89b54d9 --- /dev/null +++ b/backend/UPDATE_ENV.md @@ -0,0 +1,38 @@ +# Update Your .env File + +Your backend is running but showing old port values. Update your `.env` file in `blog-editor/backend/`: + +## Required Changes + +Change these values in your `.env` file: + +```env +# Change from 3200 (or whatever you have) to: +PORT=5001 + +# Change from http://localhost:3000 to: +CORS_ORIGIN=http://localhost:4000 + +# Keep auth service URL as is (it's correct): +AUTH_SERVICE_URL=http://localhost:3000 +``` + +## Complete .env Example + +```env +PORT=5001 +NODE_ENV=development + +DATABASE_URL=postgresql://postgres.ekqfmpvebntssdgwtioj:[YOUR-PASSWORD]@aws-1-ap-south-1.pooler.supabase.com:5432/postgres + +AUTH_SERVICE_URL=http://localhost:3000 + +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=your_aws_access_key_here +AWS_SECRET_ACCESS_KEY=your_aws_secret_key_here +S3_BUCKET_NAME=blog-editor-images + +CORS_ORIGIN=http://localhost:4000 +``` + +After updating, restart your backend server. diff --git a/backend/config/database.js b/backend/config/database.js new file mode 100644 index 0000000..158f823 --- /dev/null +++ b/backend/config/database.js @@ -0,0 +1,40 @@ +import pkg from 'pg' +import dotenv from 'dotenv' + +dotenv.config() + +const { Pool } = pkg + +// Support both connection string (Supabase) and individual parameters +let poolConfig + +if (process.env.DATABASE_URL) { + // Use connection string (Supabase format) + poolConfig = { + connectionString: process.env.DATABASE_URL, + ssl: { + rejectUnauthorized: false // Supabase requires SSL + }, + // Connection pool settings for Supabase + max: 20, // Maximum number of clients in the pool + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, + } +} else { + // Use individual parameters (local development) + poolConfig = { + host: process.env.DB_HOST || 'localhost', + port: process.env.DB_PORT || 5432, + database: process.env.DB_NAME || 'blog_editor', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD, + ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, + } +} + +export const pool = new Pool(poolConfig) + +pool.on('error', (err) => { + console.error('Unexpected error on idle client', err) + process.exit(-1) +}) diff --git a/backend/config/s3.js b/backend/config/s3.js new file mode 100644 index 0000000..5ce9d2a --- /dev/null +++ b/backend/config/s3.js @@ -0,0 +1,84 @@ +import { S3Client } from '@aws-sdk/client-s3' +import { getSignedUrl } from '@aws-sdk/s3-request-presigner' +import { PutObjectCommand, HeadBucketCommand } from '@aws-sdk/client-s3' +import { v4 as uuid } from 'uuid' +import dotenv from 'dotenv' +import logger from '../utils/logger.js' + +dotenv.config() + +// Check if S3 is configured (support both S3_BUCKET_NAME and AWS_BUCKET_NAME for compatibility) +export const isS3Configured = () => { + return !!( + process.env.AWS_ACCESS_KEY_ID && + process.env.AWS_SECRET_ACCESS_KEY && + (process.env.S3_BUCKET_NAME || process.env.AWS_BUCKET_NAME) + ) +} + +// Get bucket name (support both env var names) +export const BUCKET_NAME = process.env.S3_BUCKET_NAME || process.env.AWS_BUCKET_NAME + +// Only create S3 client if credentials are available +export const s3Client = isS3Configured() + ? new S3Client({ + region: process.env.AWS_REGION || 'us-east-1', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, + }) + : null + +// Export HeadBucketCommand for health checks +export { HeadBucketCommand } + +export async function getPresignedUploadUrl(filename, contentType) { + logger.s3('PRESIGNED_URL_REQUEST', { filename, contentType }) + + if (!isS3Configured()) { + logger.error('S3', 'S3 not configured', null) + throw new Error('AWS S3 is not configured. Please set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and S3_BUCKET_NAME (or AWS_BUCKET_NAME) in .env file.') + } + + if (!s3Client) { + logger.error('S3', 'S3 client not initialized', null) + throw new Error('S3 client is not initialized. Check your AWS credentials.') + } + + if (!BUCKET_NAME) { + logger.error('S3', 'Bucket name not configured', null) + throw new Error('S3 bucket name is not configured. Please set S3_BUCKET_NAME or AWS_BUCKET_NAME in .env file.') + } + + // Extract file extension from filename or content type + const ext = filename.split('.').pop() || contentType.split('/')[1] || 'jpg' + // Use UUID for unique file names (matching api-v1 pattern) + const key = `images/${uuid()}.${ext}` + + logger.s3('GENERATING_PRESIGNED_URL', { + bucket: BUCKET_NAME, + key, + contentType + }) + + const command = new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + ContentType: contentType, + }) + + const startTime = Date.now() + const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 }) + const duration = Date.now() - startTime + const imageUrl = `https://${BUCKET_NAME}.s3.${process.env.AWS_REGION || 'us-east-1'}.amazonaws.com/${key}` + + logger.s3('PRESIGNED_URL_CREATED', { + key, + bucket: BUCKET_NAME, + duration: `${duration}ms`, + expiresIn: '3600s' + }) + + return { uploadUrl, imageUrl, key } +} diff --git a/backend/env.example b/backend/env.example new file mode 100644 index 0000000..45a423c --- /dev/null +++ b/backend/env.example @@ -0,0 +1,74 @@ +# ===================================================== +# BLOG EDITOR BACKEND - ENVIRONMENT CONFIGURATION +# ===================================================== +# Copy this file to .env and update with your actual values +# DO NOT commit .env file to git (it's in .gitignore) +# ===================================================== + +# ===================================================== +# SERVER CONFIGURATION +# ===================================================== +PORT=5001 +NODE_ENV=development + +# ===================================================== +# DATABASE CONFIGURATION (PostgreSQL) +# ===================================================== +# Option 1: Use Supabase connection string (recommended) +# Format: postgresql://user:password@host:port/database +DATABASE_URL=postgresql://postgres.ekqfmpvebntssdgwtioj:[YOUR-PASSWORD]@aws-1-ap-south-1.pooler.supabase.com:5432/postgres + +# Option 2: Use individual parameters (for local development) +# Uncomment and use these if not using DATABASE_URL +# DB_HOST=localhost +# DB_PORT=5432 +# DB_NAME=blog_editor +# DB_USER=postgres +# DB_PASSWORD=your_database_password_here + +# ===================================================== +# AUTH SERVICE INTEGRATION +# ===================================================== +# URL of your existing auth service +# The blog editor validates JWT tokens via this service +AUTH_SERVICE_URL=http://localhost:3000 + +# ===================================================== +# AWS S3 CONFIGURATION (for image uploads) +# ===================================================== +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=your_aws_access_key_here +AWS_SECRET_ACCESS_KEY=your_aws_secret_key_here +# Use either S3_BUCKET_NAME or AWS_BUCKET_NAME (both work) +S3_BUCKET_NAME=blog-editor-images +# AWS_BUCKET_NAME=blog-editor-images # Alternative (matches api-v1 pattern) + +# ===================================================== +# CORS CONFIGURATION +# ===================================================== +# Frontend URL that will make requests to this backend +CORS_ORIGIN=http://localhost:4000 + +# Production example: +# CORS_ORIGIN=https://your-frontend-domain.com + +# ===================================================== +# NOTES +# ===================================================== +# 1. DATABASE_URL (Supabase): +# - Replace [YOUR-PASSWORD] with your actual Supabase password +# - Supabase connection string includes SSL automatically +# - Make sure to create the 'blog_editor' database in Supabase +# OR use the default 'postgres' database and update connection string +# +# 2. Run migrations to create tables: +# npm run migrate +# +# 3. Ensure your auth service is running on the port specified in AUTH_SERVICE_URL +# Note: Auth service uses its own separate database +# +# 4. For AWS S3: +# - Create an S3 bucket +# - Configure CORS on the bucket to allow PUT requests from your frontend +# - Create an IAM user with s3:PutObject and s3:GetObject permissions +# - Use the IAM user's access key and secret key above diff --git a/backend/images/.gitkeep b/backend/images/.gitkeep new file mode 100644 index 0000000..be166d5 --- /dev/null +++ b/backend/images/.gitkeep @@ -0,0 +1,2 @@ +# This file keeps the images directory in git +# Images uploaded here are temporary for testing only diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js new file mode 100644 index 0000000..799c483 --- /dev/null +++ b/backend/middleware/auth.js @@ -0,0 +1,100 @@ +import axios from 'axios' +import logger from '../utils/logger.js' + +const AUTH_SERVICE_URL = process.env.AUTH_SERVICE_URL || 'http://localhost:3000' + +/** + * Auth middleware that validates JWT tokens using the existing auth service + * Calls /auth/validate-token endpoint to verify tokens + */ +export const authenticateToken = async (req, res, next) => { + try { + const authHeader = req.headers['authorization'] + const token = authHeader && authHeader.startsWith('Bearer ') + ? authHeader.slice(7) + : null + + if (!token) { + logger.auth('TOKEN_MISSING', { path: req.path, method: req.method }) + return res.status(401).json({ message: 'Access token required' }) + } + + logger.auth('TOKEN_VALIDATION_START', { + path: req.path, + method: req.method, + tokenPrefix: token.substring(0, 10) + '...' + }) + + // Validate token with auth service + try { + const startTime = Date.now() + const response = await axios.post( + `${AUTH_SERVICE_URL}/auth/validate-token`, + { token }, + { + headers: { + 'Content-Type': 'application/json', + }, + timeout: 5000, // 5 second timeout + } + ) + const duration = Date.now() - startTime + + if (!response.data.valid) { + logger.auth('TOKEN_INVALID', { + path: req.path, + error: response.data.error, + duration: `${duration}ms` + }) + return res.status(401).json({ + message: response.data.error || 'Invalid token' + }) + } + + // Extract user info from validated token payload + // The auth service returns payload with user info + const payload = response.data.payload || {} + + req.user = { + id: payload.sub, // sub is the user ID in JWT + phone_number: payload.phone_number, + role: payload.role || 'user', + user_type: payload.user_type, + is_guest: payload.is_guest || false, + } + + logger.auth('TOKEN_VALIDATED', { + userId: req.user.id, + phone: req.user.phone_number, + role: req.user.role, + duration: `${duration}ms` + }) + + next() + } catch (error) { + // If auth service is unavailable, return error + if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') { + logger.error('AUTH', 'Auth service unavailable', error) + return res.status(503).json({ + message: 'Authentication service unavailable' + }) + } + + // If auth service returns error, forward it + if (error.response) { + logger.auth('TOKEN_VALIDATION_FAILED', { + status: error.response.status, + error: error.response.data?.error + }) + return res.status(error.response.status).json({ + message: error.response.data?.error || 'Token validation failed' + }) + } + + throw error + } + } catch (error) { + logger.error('AUTH', 'Auth middleware error', error) + return res.status(500).json({ message: 'Authentication error' }) + } +} diff --git a/backend/migrations/migrate.js b/backend/migrations/migrate.js new file mode 100644 index 0000000..38cc5cd --- /dev/null +++ b/backend/migrations/migrate.js @@ -0,0 +1,70 @@ +import { pool } from '../config/database.js' +import dotenv from 'dotenv' + +dotenv.config() + +async function migrate() { + try { + console.log('Running migrations...') + + // Note: Users table is managed by the auth service in a separate database + // We only store user_id (UUID) references here, no foreign key constraint + + // Create posts table + await pool.query(` + CREATE TABLE IF NOT EXISTS posts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + title VARCHAR(500) NOT NULL, + content_json JSONB NOT NULL, + slug VARCHAR(500) UNIQUE NOT NULL, + status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'published')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `) + console.log('✓ Posts table created') + console.log(' Note: user_id references users from auth service (separate database)') + + // Create indexes + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id) + `) + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug) + `) + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status) + `) + console.log('✓ Indexes created') + + // Create function to update updated_at timestamp + await pool.query(` + CREATE OR REPLACE FUNCTION update_updated_at_column() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; + END; + $$ language 'plpgsql'; + `) + + // Create trigger + await pool.query(` + DROP TRIGGER IF EXISTS update_posts_updated_at ON posts; + CREATE TRIGGER update_posts_updated_at + BEFORE UPDATE ON posts + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + `) + console.log('✓ Triggers created') + + console.log('Migration completed successfully!') + process.exit(0) + } catch (error) { + console.error('Migration failed:', error) + process.exit(1) + } +} + +migrate() diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..7c3125a --- /dev/null +++ b/backend/package.json @@ -0,0 +1,27 @@ +{ + "name": "blog-editor-backend", + "version": "1.0.0", + "description": "Blog Editor Backend API", + "main": "server.js", + "type": "module", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js", + "migrate": "node migrations/migrate.js" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.490.0", + "@aws-sdk/s3-request-presigner": "^3.490.0", + "axios": "^1.6.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "multer": "^2.0.2", + "pg": "^8.11.3", + "slugify": "^1.6.6", + "uuid": "^9.0.1" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } +} diff --git a/backend/routes/posts.js b/backend/routes/posts.js new file mode 100644 index 0000000..81fa085 --- /dev/null +++ b/backend/routes/posts.js @@ -0,0 +1,239 @@ +import express from 'express' +import { pool } from '../config/database.js' +import slugify from 'slugify' +import logger from '../utils/logger.js' + +const router = express.Router() + +// Get all posts for current user +// Note: authenticateToken middleware is applied at server level, so req.user is available +router.get('/', async (req, res) => { + try { + logger.transaction('FETCH_POSTS', { userId: req.user.id }) + const query = 'SELECT id, title, slug, status, created_at, updated_at FROM posts WHERE user_id = $1 ORDER BY updated_at DESC' + logger.db('SELECT', query, [req.user.id]) + + const result = await pool.query(query, [req.user.id]) + + logger.transaction('FETCH_POSTS_SUCCESS', { + userId: req.user.id, + count: result.rows.length + }) + res.json(result.rows) + } catch (error) { + logger.error('POSTS', 'Error fetching posts', error) + res.status(500).json({ message: 'Failed to fetch posts', error: error.message }) + } +}) + +// Get single post by ID +router.get('/:id', async (req, res) => { + try { + logger.transaction('FETCH_POST_BY_ID', { + postId: req.params.id, + userId: req.user.id + }) + const query = 'SELECT * FROM posts WHERE id = $1 AND user_id = $2' + logger.db('SELECT', query, [req.params.id, req.user.id]) + + const result = await pool.query(query, [req.params.id, req.user.id]) + + if (result.rows.length === 0) { + logger.warn('POSTS', 'Post not found', { + postId: req.params.id, + userId: req.user.id + }) + return res.status(404).json({ message: 'Post not found' }) + } + + logger.transaction('FETCH_POST_BY_ID_SUCCESS', { + postId: req.params.id, + userId: req.user.id + }) + res.json(result.rows[0]) + } catch (error) { + logger.error('POSTS', 'Error fetching post', error) + res.status(500).json({ message: 'Failed to fetch post', error: error.message }) + } +}) + +// Get post by slug (public) +router.get('/slug/:slug', async (req, res) => { + try { + logger.transaction('FETCH_POST_BY_SLUG', { slug: req.params.slug }) + const query = 'SELECT * FROM posts WHERE slug = $1 AND status = $2' + logger.db('SELECT', query, [req.params.slug, 'published']) + + const result = await pool.query(query, [req.params.slug, 'published']) + + if (result.rows.length === 0) { + logger.warn('POSTS', 'Post not found by slug', { slug: req.params.slug }) + return res.status(404).json({ message: 'Post not found' }) + } + + logger.transaction('FETCH_POST_BY_SLUG_SUCCESS', { + slug: req.params.slug, + postId: result.rows[0].id + }) + res.json(result.rows[0]) + } catch (error) { + logger.error('POSTS', 'Error fetching post by slug', error) + res.status(500).json({ message: 'Failed to fetch post', error: error.message }) + } +}) + +// Create post +router.post('/', async (req, res) => { + try { + const { title, content_json, status } = req.body + + logger.transaction('CREATE_POST', { + userId: req.user.id, + title: title?.substring(0, 50), + status: status || 'draft' + }) + + if (!title || !content_json) { + logger.warn('POSTS', 'Missing required fields', { + hasTitle: !!title, + hasContent: !!content_json + }) + return res.status(400).json({ message: 'Title and content are required' }) + } + + const slug = slugify(title, { lower: true, strict: true }) + '-' + Date.now() + const postStatus = status || 'draft' + + const query = `INSERT INTO posts (user_id, title, content_json, slug, status) + VALUES ($1, $2, $3, $4, $5) + RETURNING *` + logger.db('INSERT', query, [req.user.id, title, '[content_json]', slug, postStatus]) + + const result = await pool.query(query, [ + req.user.id, + title, + JSON.stringify(content_json), + slug, + postStatus + ]) + + logger.transaction('CREATE_POST_SUCCESS', { + postId: result.rows[0].id, + userId: req.user.id, + slug: result.rows[0].slug + }) + res.status(201).json(result.rows[0]) + } catch (error) { + logger.error('POSTS', 'Error creating post', error) + res.status(500).json({ message: 'Failed to create post', error: error.message }) + } +}) + +// Update post +router.put('/:id', async (req, res) => { + try { + const { title, content_json, status } = req.body + + logger.transaction('UPDATE_POST', { + postId: req.params.id, + userId: req.user.id, + updates: { + title: title !== undefined, + content: content_json !== undefined, + status: status !== undefined + } + }) + + // Check if post exists and belongs to user + const checkQuery = 'SELECT id FROM posts WHERE id = $1 AND user_id = $2' + logger.db('SELECT', checkQuery, [req.params.id, req.user.id]) + + const existingPost = await pool.query(checkQuery, [req.params.id, req.user.id]) + + if (existingPost.rows.length === 0) { + logger.warn('POSTS', 'Post not found for update', { + postId: req.params.id, + userId: req.user.id + }) + return res.status(404).json({ message: 'Post not found' }) + } + + // Build update query dynamically + const updates = [] + const values = [] + let paramCount = 1 + + if (title !== undefined) { + updates.push(`title = $${paramCount++}`) + values.push(title) + } + + if (content_json !== undefined) { + updates.push(`content_json = $${paramCount++}`) + values.push(JSON.stringify(content_json)) + } + + if (status !== undefined) { + updates.push(`status = $${paramCount++}`) + values.push(status) + } + + // Update slug if title changed + if (title !== undefined) { + const slug = slugify(title, { lower: true, strict: true }) + '-' + Date.now() + updates.push(`slug = $${paramCount++}`) + values.push(slug) + } + + updates.push(`updated_at = NOW()`) + values.push(req.params.id, req.user.id) + + const updateQuery = `UPDATE posts SET ${updates.join(', ')} WHERE id = $${paramCount} AND user_id = $${paramCount + 1} RETURNING *` + logger.db('UPDATE', updateQuery, values) + + const result = await pool.query(updateQuery, values) + + logger.transaction('UPDATE_POST_SUCCESS', { + postId: req.params.id, + userId: req.user.id + }) + res.json(result.rows[0]) + } catch (error) { + logger.error('POSTS', 'Error updating post', error) + res.status(500).json({ message: 'Failed to update post', error: error.message }) + } +}) + +// Delete post +router.delete('/:id', async (req, res) => { + try { + logger.transaction('DELETE_POST', { + postId: req.params.id, + userId: req.user.id + }) + + const query = 'DELETE FROM posts WHERE id = $1 AND user_id = $2 RETURNING id' + logger.db('DELETE', query, [req.params.id, req.user.id]) + + const result = await pool.query(query, [req.params.id, req.user.id]) + + if (result.rows.length === 0) { + logger.warn('POSTS', 'Post not found for deletion', { + postId: req.params.id, + userId: req.user.id + }) + return res.status(404).json({ message: 'Post not found' }) + } + + logger.transaction('DELETE_POST_SUCCESS', { + postId: req.params.id, + userId: req.user.id + }) + res.json({ message: 'Post deleted successfully' }) + } catch (error) { + logger.error('POSTS', 'Error deleting post', error) + res.status(500).json({ message: 'Failed to delete post', error: error.message }) + } +}) + +export default router diff --git a/backend/routes/upload.js b/backend/routes/upload.js new file mode 100644 index 0000000..41fe912 --- /dev/null +++ b/backend/routes/upload.js @@ -0,0 +1,169 @@ +import express from 'express' +import multer from 'multer' +import path from 'path' +import { fileURLToPath } from 'url' +import fs from 'fs' +import { getPresignedUploadUrl } from '../config/s3.js' +import logger from '../utils/logger.js' +import { v4 as uuid } from 'uuid' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const router = express.Router() + +// Configure multer for local file storage (TEMPORARY - FOR TESTING ONLY) +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const uploadDir = path.join(__dirname, '..', 'images') + // Ensure directory exists + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }) + } + cb(null, uploadDir) + }, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname) + const filename = `${uuid()}${ext}` + cb(null, filename) + } +}) + +const upload = multer({ + storage: storage, + limits: { + fileSize: 10 * 1024 * 1024 // 10MB limit + }, + fileFilter: (req, file, cb) => { + if (file.mimetype.startsWith('image/')) { + cb(null, true) + } else { + cb(new Error('Only image files are allowed'), false) + } + } +}) + +// Get presigned URL for image upload +// Note: authenticateToken middleware is applied at server level +router.post('/presigned-url', async (req, res) => { + try { + const { filename, contentType } = req.body + + logger.transaction('GENERATE_PRESIGNED_URL', { + userId: req.user.id, + filename, + contentType + }) + + if (!filename || !contentType) { + logger.warn('UPLOAD', 'Missing required fields', { + hasFilename: !!filename, + hasContentType: !!contentType + }) + return res.status(400).json({ message: 'Filename and content type are required' }) + } + + // Validate content type + if (!contentType.startsWith('image/')) { + logger.warn('UPLOAD', 'Invalid content type', { contentType }) + return res.status(400).json({ message: 'File must be an image' }) + } + + // Check if AWS credentials are configured + if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { + logger.error('UPLOAD', 'AWS credentials not configured', null) + return res.status(500).json({ + message: 'AWS S3 is not configured. Please set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in backend .env file.' + }) + } + + // Support both S3_BUCKET_NAME and AWS_BUCKET_NAME (for compatibility with api-v1) + const bucketName = process.env.S3_BUCKET_NAME || process.env.AWS_BUCKET_NAME + if (!bucketName) { + logger.error('UPLOAD', 'S3 bucket name not configured', null) + return res.status(500).json({ + message: 'S3 bucket name is not configured. Please set S3_BUCKET_NAME or AWS_BUCKET_NAME in backend .env file.' + }) + } + + const startTime = Date.now() + const { uploadUrl, imageUrl, key } = await getPresignedUploadUrl(filename, contentType) + const duration = Date.now() - startTime + + logger.s3('PRESIGNED_URL_GENERATED', { + userId: req.user.id, + filename, + key, + duration: `${duration}ms` + }) + + logger.transaction('GENERATE_PRESIGNED_URL_SUCCESS', { + userId: req.user.id, + key, + duration: `${duration}ms` + }) + + res.json({ + uploadUrl, + imageUrl, + }) + } catch (error) { + logger.error('UPLOAD', 'Error generating presigned URL', error) + + // Provide more specific error messages + let errorMessage = 'Failed to generate upload URL' + if (error.name === 'InvalidAccessKeyId' || error.name === 'SignatureDoesNotMatch') { + errorMessage = 'Invalid AWS credentials. Please check your AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.' + } else if (error.name === 'NoSuchBucket') { + const bucketName = process.env.S3_BUCKET_NAME || process.env.AWS_BUCKET_NAME + errorMessage = `S3 bucket '${bucketName}' does not exist. Please create it or update S3_BUCKET_NAME/AWS_BUCKET_NAME.` + } else if (error.message) { + errorMessage = error.message + } + + res.status(500).json({ + message: errorMessage, + error: error.name || 'Unknown error' + }) + } +}) + +// TEMPORARY: Local file upload endpoint (FOR TESTING ONLY - REMOVE IN PRODUCTION) +router.post('/local', upload.single('image'), async (req, res) => { + try { + if (!req.file) { + logger.warn('UPLOAD', 'No file uploaded', null) + return res.status(400).json({ message: 'No image file provided' }) + } + + logger.transaction('LOCAL_IMAGE_UPLOAD', { + userId: req.user.id, + filename: req.file.filename, + originalName: req.file.originalname, + size: req.file.size + }) + + // Return the image URL (served statically) + const imageUrl = `/api/images/${req.file.filename}` + + logger.transaction('LOCAL_IMAGE_UPLOAD_SUCCESS', { + userId: req.user.id, + filename: req.file.filename, + imageUrl + }) + + res.json({ + imageUrl, + filename: req.file.filename, + size: req.file.size + }) + } catch (error) { + logger.error('UPLOAD', 'Error uploading local image', error) + res.status(500).json({ + message: 'Failed to upload image', + error: error.message + }) + } +}) + +export default router diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..918045c --- /dev/null +++ b/backend/server.js @@ -0,0 +1,294 @@ +import express from 'express' +import cors from 'cors' +import dotenv from 'dotenv' +import axios from 'axios' +import path from 'path' +import { fileURLToPath } from 'url' +import { pool } from './config/database.js' +import { authenticateToken } from './middleware/auth.js' +import { s3Client, BUCKET_NAME, HeadBucketCommand, isS3Configured } from './config/s3.js' +import postRoutes from './routes/posts.js' +import uploadRoutes from './routes/upload.js' +import logger from './utils/logger.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +dotenv.config() + +const app = express() +const PORT = process.env.PORT || 5001 + +// Startup logging +console.log('\n🚀 Starting Blog Editor Backend...\n') +console.log('📋 Configuration:') +console.log(` Port: ${PORT}`) +console.log(` Environment: ${process.env.NODE_ENV || 'development'}`) +console.log(` CORS Origin: ${process.env.CORS_ORIGIN || 'http://localhost:4000'}`) + +// Middleware - CORS Configuration +const corsOptions = { + origin: function (origin, callback) { + // Allow requests with no origin (mobile apps, Postman, etc.) + if (!origin) { + return callback(null, true) + } + + const allowedOrigins = [ + 'http://localhost:4000', + 'http://localhost:3000', + 'http://localhost:5173', + process.env.CORS_ORIGIN + ].filter(Boolean) // Remove undefined values + + if (allowedOrigins.includes(origin)) { + callback(null, true) + } else { + console.warn(`⚠️ CORS: Blocked origin: ${origin}`) + console.warn(` Allowed origins: ${allowedOrigins.join(', ')}`) + callback(new Error('Not allowed by CORS')) + } + }, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] +} + +app.use(cors(corsOptions)) + +// Handle CORS errors +app.use((err, req, res, next) => { + if (err && err.message === 'Not allowed by CORS') { + return res.status(403).json({ + error: 'Origin not allowed by CORS', + message: `Origin ${req.headers.origin} is not allowed. Allowed origins: http://localhost:4000, http://localhost:3000, http://localhost:5173` + }) + } + next(err) +}) +app.use(express.json()) +app.use(express.urlencoded({ extended: true })) + +// Request logging middleware +app.use((req, res, next) => { + const startTime = Date.now() + const originalSend = res.send + + res.send = function (body) { + const duration = Date.now() - startTime + const userId = req.user?.id || 'anonymous' + logger.api(req.method, req.path, res.statusCode, duration, userId) + return originalSend.call(this, body) + } + + logger.debug('REQUEST', `${req.method} ${req.path}`, { + query: req.query, + body: req.method === 'POST' || req.method === 'PUT' ? '***' : undefined, + ip: req.ip, + userAgent: req.get('user-agent') + }) + + next() +}) + +// Routes - Auth is handled by existing auth service +// Blog editor backend validates tokens via auth service +app.use('/api/posts', authenticateToken, postRoutes) +app.use('/api/upload', authenticateToken, uploadRoutes) + +// TEMPORARY: Serve static images (FOR TESTING ONLY - REMOVE IN PRODUCTION) +app.use('/api/images', express.static(path.join(__dirname, 'images'))) + +// Health check +app.get('/api/health', (req, res) => { + res.json({ status: 'ok' }) +}) + +// Test database connection +app.get('/api/test-db', async (req, res) => { + try { + logger.db('SELECT', 'SELECT NOW()', []) + const result = await pool.query('SELECT NOW()') + logger.info('DATABASE', 'Test query successful', { time: result.rows[0].now }) + res.json({ message: 'Database connected', time: result.rows[0].now }) + } catch (error) { + logger.error('DATABASE', 'Test query failed', error) + res.status(500).json({ error: 'Database connection failed', message: error.message }) + } +}) + +// General error handling middleware (after CORS error handler) +app.use((err, req, res, next) => { + // Skip if already handled by CORS error handler + if (err && err.message === 'Not allowed by CORS') { + return next(err) // This should have been handled above, but just in case + } + logger.error('SERVER', 'Unhandled error', err) + console.error(err.stack) + res.status(500).json({ message: 'Something went wrong!', error: err.message }) +}) + +// Startup health checks +async function performStartupChecks() { + console.log('\n🔍 Performing startup health checks...\n') + + // 1. Check Database Connection + console.log('📊 Checking Database Connection...') + try { + logger.db('SELECT', 'SELECT NOW(), version()', []) + const dbResult = await pool.query('SELECT NOW(), version()') + const dbTime = dbResult.rows[0].now + const dbVersion = dbResult.rows[0].version.split(' ')[0] + ' ' + dbResult.rows[0].version.split(' ')[1] + logger.info('DATABASE', 'Database connection successful', { time: dbTime, version: dbVersion }) + console.log(` ✅ Database connected successfully`) + console.log(` 📅 Database time: ${dbTime}`) + console.log(` 🗄️ Database version: ${dbVersion}`) + + // Check if posts table exists + logger.db('SELECT', 'Check posts table exists', []) + const tableCheck = await pool.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'posts' + ) + `) + if (tableCheck.rows[0].exists) { + logger.info('DATABASE', 'Posts table exists', null) + console.log(` ✅ Posts table exists`) + } else { + logger.warn('DATABASE', 'Posts table not found', null) + console.log(` ⚠️ Posts table not found - run 'npm run migrate'`) + } + } catch (error) { + logger.error('DATABASE', 'Database connection failed', error) + console.error(` ❌ Database connection failed: ${error.message}`) + console.error(` 💡 Check your DATABASE_URL in .env file`) + return false + } + + // 2. Check AWS S3 Configuration + console.log('\n☁️ Checking AWS S3 Configuration...') + try { + if (!isS3Configured()) { + console.log(` ⚠️ AWS S3 not configured`) + console.log(` 💡 Image uploads will not work without AWS S3`) + console.log(` 💡 To enable: Set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and S3_BUCKET_NAME in .env`) + } else { + console.log(` ✅ AWS credentials configured`) + console.log(` 🪣 S3 Bucket: ${BUCKET_NAME}`) + console.log(` 🌍 AWS Region: ${process.env.AWS_REGION || 'us-east-1'}`) + + // Try to check bucket access (this might fail if bucket doesn't exist, but that's okay) + if (s3Client) { + try { + await s3Client.send(new HeadBucketCommand({ Bucket: BUCKET_NAME })) + console.log(` ✅ S3 bucket is accessible`) + } catch (s3Error) { + if (s3Error.name === 'NotFound' || s3Error.$metadata?.httpStatusCode === 404) { + console.log(` ⚠️ S3 bucket '${BUCKET_NAME}' not found`) + console.log(` 💡 Create the bucket in AWS S3 or check the bucket name`) + } else if (s3Error.name === 'Forbidden' || s3Error.$metadata?.httpStatusCode === 403) { + console.log(` ⚠️ S3 bucket access denied`) + console.log(` 💡 Check IAM permissions for bucket: ${BUCKET_NAME}`) + } else { + console.log(` ⚠️ S3 bucket check failed: ${s3Error.message}`) + } + } + } + } + } catch (error) { + console.error(` ❌ AWS S3 check failed: ${error.message}`) + } + + // 3. Check Auth Service Connection + console.log('\n🔐 Checking Auth Service Connection...') + const authServiceUrl = process.env.AUTH_SERVICE_URL || 'http://localhost:3000' + try { + logger.auth('HEALTH_CHECK', { url: authServiceUrl }) + const startTime = Date.now() + const healthResponse = await axios.get(`${authServiceUrl}/health`, { + timeout: 5000, + }) + const duration = Date.now() - startTime + if (healthResponse.data?.ok || healthResponse.status === 200) { + logger.auth('HEALTH_CHECK_SUCCESS', { url: authServiceUrl, duration: `${duration}ms` }) + console.log(` ✅ Auth service is reachable`) + console.log(` 🔗 Auth service URL: ${authServiceUrl}`) + } else { + console.log(` ⚠️ Auth service responded but status unclear`) + } + } catch (error) { + if (error.code === 'ECONNREFUSED') { + console.error(` ❌ Auth service connection refused`) + console.error(` 💡 Make sure auth service is running on ${authServiceUrl}`) + console.error(` 💡 Start it with: cd ../auth && npm start`) + } else if (error.code === 'ETIMEDOUT') { + console.error(` ❌ Auth service connection timeout`) + console.error(` 💡 Check if auth service is running and accessible`) + } else { + console.error(` ⚠️ Auth service check failed: ${error.message}`) + console.error(` 💡 Auth service might not be running or URL is incorrect`) + } + } + + // 4. Environment Variables Check + console.log('\n📝 Checking Environment Variables...') + const requiredVars = ['DATABASE_URL'] + const optionalVars = ['AUTH_SERVICE_URL', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'S3_BUCKET_NAME'] + + let missingRequired = [] + requiredVars.forEach(varName => { + if (!process.env[varName]) { + missingRequired.push(varName) + } + }) + + if (missingRequired.length > 0) { + console.error(` ❌ Missing required variables: ${missingRequired.join(', ')}`) + console.error(` 💡 Check your .env file`) + return false + } else { + console.log(` ✅ All required environment variables are set`) + } + + const missingOptional = optionalVars.filter(varName => !process.env[varName]) + if (missingOptional.length > 0) { + console.log(` ⚠️ Optional variables not set: ${missingOptional.join(', ')}`) + console.log(` 💡 Some features may not work without these`) + } + + console.log('\n✅ Startup checks completed!\n') + return true +} + +// Start server with health checks +async function startServer() { + const checksPassed = await performStartupChecks() + + if (!checksPassed) { + console.error('\n❌ Startup checks failed. Please fix the issues above.\n') + process.exit(1) + } + + app.listen(PORT, () => { + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') + console.log(`✅ Blog Editor Backend is running!`) + console.log(` 🌐 Server: http://localhost:${PORT}`) + console.log(` 💚 Health: http://localhost:${PORT}/api/health`) + console.log(` 🗄️ DB Test: http://localhost:${PORT}/api/test-db`) + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n') + }) +} + +startServer().catch((error) => { + console.error('❌ Failed to start server:', error) + process.exit(1) +}) + +// Graceful shutdown +process.on('SIGTERM', async () => { + console.log('SIGTERM signal received: closing HTTP server') + await pool.end() + process.exit(0) +}) diff --git a/backend/utils/logger.js b/backend/utils/logger.js new file mode 100644 index 0000000..b9cdb64 --- /dev/null +++ b/backend/utils/logger.js @@ -0,0 +1,80 @@ +/** + * Simple logging utility for the blog editor backend + * Provides consistent log formatting with timestamps + */ + +const getTimestamp = () => { + return new Date().toISOString() +} + +const formatLog = (level, category, message, data = null) => { + const timestamp = getTimestamp() + const logEntry = { + timestamp, + level, + category, + message, + ...(data && { data }) + } + return JSON.stringify(logEntry) +} + +export const logger = { + info: (category, message, data = null) => { + console.log(`[INFO] ${formatLog('INFO', category, message, data)}`) + }, + + error: (category, message, error = null) => { + const errorData = error ? { + message: error.message, + stack: error.stack, + name: error.name, + ...(error.response && { response: { status: error.response.status, data: error.response.data } }) + } : null + console.error(`[ERROR] ${formatLog('ERROR', category, message, errorData)}`) + }, + + warn: (category, message, data = null) => { + console.warn(`[WARN] ${formatLog('WARN', category, message, data)}`) + }, + + debug: (category, message, data = null) => { + if (process.env.NODE_ENV === 'development') { + console.log(`[DEBUG] ${formatLog('DEBUG', category, message, data)}`) + } + }, + + // Transaction-specific logging + transaction: (operation, details) => { + logger.info('TRANSACTION', `${operation}`, details) + }, + + // Database-specific logging + db: (operation, query, params = null) => { + logger.debug('DATABASE', `${operation}`, { + query: query.substring(0, 100) + (query.length > 100 ? '...' : ''), + params: params ? (Array.isArray(params) ? `[${params.length} params]` : params) : null + }) + }, + + // API request/response logging + api: (method, path, statusCode, duration = null, userId = null) => { + logger.info('API', `${method} ${path}`, { + statusCode, + ...(duration && { duration: `${duration}ms` }), + ...(userId && { userId }) + }) + }, + + // S3 operation logging + s3: (operation, details) => { + logger.info('S3', `${operation}`, details) + }, + + // Auth operation logging + auth: (operation, details) => { + logger.info('AUTH', `${operation}`, details) + } +} + +export default logger diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..c1b15e3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,34 @@ +# Blog Editor Frontend + +React + Vite frontend for the blog editor application. + +## Setup + +1. Install dependencies: +```bash +npm install +``` + +2. Create `.env` file: +```env +VITE_API_URL=http://localhost:5000 +``` + +3. Start development server: +```bash +npm run dev +``` + +## Build + +```bash +npm run build +``` + +## Features + +- TipTap rich text editor +- Auto-save functionality +- Image upload to S3 +- JWT authentication +- Responsive design diff --git a/frontend/env.example b/frontend/env.example new file mode 100644 index 0000000..4db484b --- /dev/null +++ b/frontend/env.example @@ -0,0 +1,40 @@ +# ===================================================== +# BLOG EDITOR FRONTEND - ENVIRONMENT CONFIGURATION +# ===================================================== +# Copy this file to .env and update with your actual values +# DO NOT commit .env file to git (it's in .gitignore) +# ===================================================== + +# ===================================================== +# BLOG EDITOR BACKEND API URL +# ===================================================== +# URL of the blog editor backend API +# This is where posts, uploads, etc. are handled +VITE_API_URL=http://localhost:5001 + +# Production example: +# VITE_API_URL=https://api.yourdomain.com + +# ===================================================== +# AUTH SERVICE API URL +# ===================================================== +# URL of your existing auth service +# This is where authentication (login, OTP, etc.) is handled +VITE_AUTH_API_URL=http://localhost:3000 + +# Production example: +# VITE_AUTH_API_URL=https://auth.yourdomain.com + +# ===================================================== +# NOTES +# ===================================================== +# 1. VITE_ prefix is required for Vite to expose these variables +# Access in code: import.meta.env.VITE_API_URL +# +# 2. Make sure both services are running: +# - Auth service on port 3000 (or your configured port) +# - Blog editor backend on port 5001 (or your configured port) +# - Blog editor frontend on port 4000 (or your configured port) +# +# 3. For production builds, these values are baked into the build +# Make sure to set correct production URLs before building diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0bd798b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Blog Editor + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0f7ffdd --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "blog-editor-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tiptap/extension-color": "^2.1.13", + "@tiptap/extension-image": "^2.1.13", + "@tiptap/extension-text-style": "^2.1.13", + "@tiptap/extension-underline": "^2.1.13", + "@tiptap/react": "^2.1.13", + "@tiptap/starter-kit": "^2.1.13", + "axios": "^1.6.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hot-toast": "^2.4.1", + "react-router-dom": "^6.20.0" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "vite": "^7.3.1" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..75369b7 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,56 @@ +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom' +import { Toaster } from 'react-hot-toast' +import { AuthProvider, useAuth } from './contexts/AuthContext' +import Login from './pages/Login' +import Dashboard from './pages/Dashboard' +import Editor from './pages/Editor' +import BlogPost from './pages/BlogPost' + +function PrivateRoute({ children }) { + const { user, loading } = useAuth() + + if (loading) { + return
Loading...
+ } + + return user ? children : +} + +function AppRoutes() { + return ( + + } /> + } /> + + + + } + /> + + + + } + /> + } /> + + ) +} + +function App() { + return ( + + + + + + + ) +} + +export default App diff --git a/frontend/src/components/Editor.jsx b/frontend/src/components/Editor.jsx new file mode 100644 index 0000000..9e83be5 --- /dev/null +++ b/frontend/src/components/Editor.jsx @@ -0,0 +1,235 @@ +import { useEditor, EditorContent } from '@tiptap/react' +import { useRef, useEffect } from 'react' +import StarterKit from '@tiptap/starter-kit' +import TextStyle from '@tiptap/extension-text-style' +import Color from '@tiptap/extension-color' +import Underline from '@tiptap/extension-underline' +import { FontSize } from '../extensions/FontSize' +import { ImageResize } from '../extensions/ImageResize' +import Toolbar from './Toolbar' +import api from '../utils/api' +import toast from 'react-hot-toast' + +export default function Editor({ content, onChange, onImageUpload }) { + const editorRef = useRef(null) + + const handleImageUpload = async (file) => { + const editor = editorRef.current + if (!editor) { + toast.error('Editor not ready') + return + } + + try { + // Validate file type + if (!file.type.startsWith('image/')) { + toast.error('Please select an image file') + return + } + + // Validate file size (max 10MB) + if (file.size > 10 * 1024 * 1024) { + toast.error('Image size must be less than 10MB') + return + } + + toast.loading('Uploading image...', { id: 'image-upload' }) + + // TEMPORARY: Use local upload for testing (REMOVE IN PRODUCTION) + // TODO: Remove this and use S3 upload instead + let imageUrl + try { + const formData = new FormData() + formData.append('image', file) + + console.log('Uploading image locally (TEMPORARY):', { + filename: file.name, + size: file.size, + type: file.type + }) + + const response = await api.post('/upload/local', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + + // Get full URL (backend serves images at /api/images/) + const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:5001' + imageUrl = `${baseUrl}${response.data.imageUrl}` + + console.log('Local upload successful:', { + imageUrl, + filename: response.data.filename + }) + } catch (error) { + console.error('Local upload failed:', error) + if (error.code === 'ERR_NETWORK' || error.message === 'Network Error') { + throw new Error('Cannot connect to server. Make sure the backend is running.') + } + if (error.response?.status === 401) { + throw new Error('Authentication failed. Please login again.') + } + throw new Error(error.response?.data?.message || error.message || 'Failed to upload image') + } + + /* ORIGINAL S3 UPLOAD CODE (COMMENTED OUT FOR TESTING) + // Get presigned URL + let data + try { + const response = await api.post('/upload/presigned-url', { + filename: file.name, + contentType: file.type, + }) + data = response.data + } catch (error) { + console.error('Presigned URL request failed:', error) + if (error.code === 'ERR_NETWORK' || error.message === 'Network Error') { + throw new Error('Cannot connect to server. Make sure the backend is running.') + } + if (error.response?.status === 401) { + throw new Error('Authentication failed. Please login again.') + } + if (error.response?.status === 500) { + throw new Error('Server error. Check if AWS S3 is configured correctly.') + } + throw error + } + + // Upload to S3 + console.log('Uploading to S3:', { + uploadUrl: data.uploadUrl.substring(0, 100) + '...', + imageUrl: data.imageUrl, + fileSize: file.size, + contentType: file.type + }) + + let uploadResponse + try { + uploadResponse = await fetch(data.uploadUrl, { + method: 'PUT', + body: file, + headers: { + 'Content-Type': file.type, + }, + }) + } catch (fetchError) { + console.error('S3 upload fetch error:', fetchError) + if (fetchError.message === 'Failed to fetch' || fetchError.name === 'TypeError') { + throw new Error('Failed to connect to S3. This might be a CORS issue. Check your S3 bucket CORS configuration.') + } + throw fetchError + } + + if (!uploadResponse.ok) { + const errorText = await uploadResponse.text().catch(() => 'Unknown error') + console.error('S3 upload failed:', { + status: uploadResponse.status, + statusText: uploadResponse.statusText, + error: errorText + }) + throw new Error(`S3 upload failed: ${uploadResponse.status} ${uploadResponse.statusText}. ${errorText}`) + } + + console.log('S3 upload successful:', { + status: uploadResponse.status, + imageUrl: data.imageUrl + }) + + // Insert image in editor + const imageUrl = data.imageUrl + */ + editor.chain().focus().setImage({ + src: imageUrl, + alt: file.name, + }).run() + toast.success('Image uploaded successfully!', { id: 'image-upload' }) + + if (onImageUpload) { + onImageUpload(imageUrl) + } + } catch (error) { + console.error('Image upload failed:', error) + const errorMessage = error.response?.data?.message || + error.message || + 'Failed to upload image. Please try again.' + + toast.error(errorMessage, { + id: 'image-upload', + duration: 5000, + }) + + // Log detailed error for debugging + if (error.response) { + console.error('Error response:', error.response.data) + console.error('Error status:', error.response.status) + } + if (error.request) { + console.error('Request made but no response:', error.request) + } + } + } + + const editor = useEditor({ + extensions: [ + StarterKit, + ImageResize, + TextStyle, + Color, + Underline, + FontSize, + ], + content: content || '', + onUpdate: ({ editor }) => { + onChange(editor.getJSON()) + }, + editorProps: { + attributes: { + class: 'ProseMirror', + }, + handleDrop: (view, event, slice, moved) => { + if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) { + const file = event.dataTransfer.files[0] + if (file.type.startsWith('image/')) { + event.preventDefault() + handleImageUpload(file) + return true + } + } + return false + }, + handlePaste: (view, event, slice) => { + const items = Array.from(event.clipboardData?.items || []) + for (const item of items) { + if (item.type.startsWith('image/')) { + event.preventDefault() + const file = item.getAsFile() + if (file) { + handleImageUpload(file) + } + return true + } + } + return false + }, + }, + }) + + // Store editor reference + useEffect(() => { + if (editor) { + editorRef.current = editor + } + }, [editor]) + + if (!editor) { + return null + } + + return ( +
+ + +
+ ) +} diff --git a/frontend/src/components/Toolbar.jsx b/frontend/src/components/Toolbar.jsx new file mode 100644 index 0000000..2adba15 --- /dev/null +++ b/frontend/src/components/Toolbar.jsx @@ -0,0 +1,221 @@ +import { useState } from 'react' + +const Toolbar = ({ editor, onImageUpload }) => { + const [showColorPicker, setShowColorPicker] = useState(false) + const [showFontSize, setShowFontSize] = useState(false) + + if (!editor) { + return null + } + + const colors = [ + '#000000', '#374151', '#6B7280', '#9CA3AF', '#D1D5DB', + '#EF4444', '#F59E0B', '#10B981', '#3B82F6', '#8B5CF6', + ] + + const fontSizes = ['12px', '14px', '16px', '18px', '20px', '24px', '32px'] + + const handleImageInput = (e) => { + const file = e.target.files?.[0] + if (file) { + onImageUpload(file) + } + e.target.value = '' + } + + return ( +
+ {/* Text Formatting */} +
+ + + +
+ + {/* Headings */} +
+ + + +
+ + {/* Lists */} +
+ + +
+ + {/* Quote & Code */} +
+ + +
+ + {/* Color Picker */} +
+ + {showColorPicker && ( +
+
+ {colors.map((color) => ( +
+
+ )} +
+ + {/* Font Size */} +
+ + {showFontSize && ( +
+ {fontSizes.map((size) => ( + + ))} +
+ )} +
+ + {/* Image Upload */} +
+ + +
+
+ ) +} + +export default Toolbar diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..e192acb --- /dev/null +++ b/frontend/src/contexts/AuthContext.jsx @@ -0,0 +1,135 @@ +import { createContext, useContext, useState, useEffect } from 'react' +import authApi from '../utils/authApi' +import api from '../utils/api' + +const AuthContext = createContext() + +export function useAuth() { + const context = useContext(AuthContext) + if (!context) { + throw new Error('useAuth must be used within AuthProvider') + } + return context +} + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const accessToken = localStorage.getItem('access_token') + const savedUser = localStorage.getItem('user') + + if (accessToken && savedUser) { + setUser(JSON.parse(savedUser)) + // Verify token is still valid by validating with auth service + authApi.post('/auth/validate-token', { token: accessToken }) + .then((res) => { + if (res.data.valid && res.data.payload) { + const payload = res.data.payload + const userData = { + id: payload.sub, + phone_number: payload.phone_number, + role: payload.role || 'user', + user_type: payload.user_type, + is_guest: payload.is_guest || false, + } + setUser(userData) + localStorage.setItem('user', JSON.stringify(userData)) + } else { + // Token invalid, try refresh + refreshToken() + } + }) + .catch(() => { + // Try refresh token + refreshToken() + }) + .finally(() => setLoading(false)) + } else { + setLoading(false) + } + }, []) + + const refreshToken = async () => { + const refreshToken = localStorage.getItem('refresh_token') + if (!refreshToken) { + logout() + return + } + + try { + const res = await authApi.post('/auth/refresh', { refresh_token: refreshToken }) + const { access_token, refresh_token: newRefreshToken } = res.data + localStorage.setItem('access_token', access_token) + if (newRefreshToken) { + localStorage.setItem('refresh_token', newRefreshToken) + } + // Update user from token payload + const savedUser = localStorage.getItem('user') + if (savedUser) { + setUser(JSON.parse(savedUser)) + } + } catch (error) { + logout() + } + } + + const requestOtp = async (phoneNumber) => { + const res = await authApi.post('/auth/request-otp', { phone_number: phoneNumber }) + return res.data + } + + const verifyOtp = async (phoneNumber, otp) => { + const res = await authApi.post('/auth/verify-otp', { + phone_number: phoneNumber, + code: otp, // Auth service expects 'code' not 'otp' + }) + const { access_token, refresh_token, user: userData } = res.data + localStorage.setItem('access_token', access_token) + if (refresh_token) { + localStorage.setItem('refresh_token', refresh_token) + } + localStorage.setItem('user', JSON.stringify(userData)) + setUser(userData) + return res.data + } + + const guestLogin = async (deviceId) => { + const res = await authApi.post('/auth/guest-login', { device_id: deviceId }) + const { access_token, user: userData } = res.data + localStorage.setItem('access_token', access_token) + localStorage.setItem('user', JSON.stringify(userData)) + setUser(userData) + return res.data + } + + const logout = async () => { + const refreshToken = localStorage.getItem('refresh_token') + if (refreshToken) { + try { + await authApi.post('/auth/logout', { refresh_token: refreshToken }) + } catch (error) { + console.error('Logout error:', error) + } + } + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + localStorage.removeItem('user') + setUser(null) + } + + return ( + + {children} + + ) +} diff --git a/frontend/src/extensions/FontSize.js b/frontend/src/extensions/FontSize.js new file mode 100644 index 0000000..1681460 --- /dev/null +++ b/frontend/src/extensions/FontSize.js @@ -0,0 +1,53 @@ +import { Extension } from '@tiptap/core' +import { TextStyle } from '@tiptap/extension-text-style' + +export const FontSize = Extension.create({ + name: 'fontSize', + + addOptions() { + return { + types: ['textStyle'], + } + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + fontSize: { + default: null, + parseHTML: (element) => { + const fontSize = element.style.fontSize + if (!fontSize) return null + return fontSize.replace('px', '') + }, + renderHTML: (attributes) => { + if (!attributes.fontSize) { + return {} + } + return { + style: `font-size: ${attributes.fontSize}px`, + } + }, + }, + }, + }, + ] + }, + + addCommands() { + return { + setFontSize: + (fontSize) => + ({ chain }) => { + return chain().setMark('textStyle', { fontSize }).run() + }, + unsetFontSize: + () => + ({ chain }) => { + return chain().setMark('textStyle', { fontSize: null }).removeEmptyTextStyle().run() + }, + } + }, +}) diff --git a/frontend/src/extensions/ImageResize.js b/frontend/src/extensions/ImageResize.js new file mode 100644 index 0000000..cd9b223 --- /dev/null +++ b/frontend/src/extensions/ImageResize.js @@ -0,0 +1,185 @@ +import { Extension } from '@tiptap/core' +import { Image } from '@tiptap/extension-image' + +export const ImageResize = Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + width: { + default: null, + parseHTML: (element) => { + const width = element.getAttribute('width') + return width ? parseInt(width, 10) : null + }, + renderHTML: (attributes) => { + if (!attributes.width) { + return {} + } + return { + width: attributes.width, + } + }, + }, + height: { + default: null, + parseHTML: (element) => { + const height = element.getAttribute('height') + return height ? parseInt(height, 10) : null + }, + renderHTML: (attributes) => { + if (!attributes.height) { + return {} + } + return { + height: attributes.height, + } + }, + }, + } + }, + + addNodeView() { + return ({ node, HTMLAttributes, getPos, editor, selected }) => { + const dom = document.createElement('div') + dom.className = 'image-resize-wrapper' + if (selected) { + dom.classList.add('selected') + } + + const img = document.createElement('img') + img.src = node.attrs.src + img.alt = node.attrs.alt || '' + img.draggable = false + img.style.display = 'block' + img.style.maxWidth = '100%' + img.style.height = 'auto' + + if (node.attrs.width) { + img.style.width = `${node.attrs.width}px` + } + if (node.attrs.height) { + img.style.height = `${node.attrs.height}px` + } + + // Resize handle + const resizeHandle = document.createElement('div') + resizeHandle.className = 'resize-handle' + resizeHandle.innerHTML = '↘' + resizeHandle.style.display = selected ? 'flex' : 'none' + + let isResizing = false + let startX = 0 + let startY = 0 + let startWidth = 0 + let startHeight = 0 + + const updateResizeHandle = () => { + if (selected && !dom.contains(resizeHandle)) { + dom.appendChild(resizeHandle) + resizeHandle.style.display = 'flex' + } else if (!selected && dom.contains(resizeHandle)) { + resizeHandle.style.display = 'none' + } + if (selected) { + dom.classList.add('selected') + } else { + dom.classList.remove('selected') + } + } + + resizeHandle.addEventListener('mousedown', (e) => { + isResizing = true + startX = e.clientX + startY = e.clientY + const rect = img.getBoundingClientRect() + startWidth = rect.width + startHeight = rect.height + e.preventDefault() + e.stopPropagation() + }) + + const handleMouseMove = (e) => { + if (!isResizing) return + + const deltaX = e.clientX - startX + const deltaY = e.clientY - startY + const aspectRatio = startHeight / startWidth + const newWidth = Math.max(100, Math.min(1200, startWidth + deltaX)) + const newHeight = newWidth * aspectRatio + + img.style.width = `${newWidth}px` + img.style.height = `${newHeight}px` + } + + const handleMouseUp = () => { + if (!isResizing) return + + isResizing = false + const width = parseInt(img.style.width, 10) + const height = parseInt(img.style.height, 10) + + if (typeof getPos === 'function' && editor) { + editor.chain().setImage({ width, height }).run() + } + + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + + dom.appendChild(img) + updateResizeHandle() + + // Update on selection change + const updateSelection = () => { + const { selection } = editor.state + const pos = typeof getPos === 'function' ? getPos() : null + if (pos !== null && pos !== undefined) { + const isSelected = selection.from <= pos && selection.to >= pos + node.nodeSize + if (isSelected !== selected) { + if (isSelected) { + dom.classList.add('selected') + if (!dom.contains(resizeHandle)) { + dom.appendChild(resizeHandle) + } + resizeHandle.style.display = 'flex' + } else { + dom.classList.remove('selected') + resizeHandle.style.display = 'none' + } + } + } + } + + editor.on('selectionUpdate', updateSelection) + + return { + dom, + update: (updatedNode) => { + if (updatedNode.type.name !== 'image') return false + if (updatedNode.attrs.src !== node.attrs.src) { + img.src = updatedNode.attrs.src + } + if (updatedNode.attrs.width !== node.attrs.width) { + img.style.width = updatedNode.attrs.width ? `${updatedNode.attrs.width}px` : 'auto' + } + if (updatedNode.attrs.height !== node.attrs.height) { + img.style.height = updatedNode.attrs.height ? `${updatedNode.attrs.height}px` : 'auto' + } + node = updatedNode + return true + }, + destroy: () => { + editor.off('selectionUpdate', updateSelection) + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + }, + } + } + }, +}).configure({ + inline: false, + allowBase64: false, +}) diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..9af612b --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,122 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.ProseMirror { + outline: none; + min-height: 400px; + padding: 1rem; +} + +.ProseMirror p { + margin: 0.5rem 0; +} + +.ProseMirror h1 { + font-size: 2rem; + font-weight: bold; + margin: 1rem 0; +} + +.ProseMirror h2 { + font-size: 1.5rem; + font-weight: bold; + margin: 0.75rem 0; +} + +.ProseMirror h3 { + font-size: 1.25rem; + font-weight: bold; + margin: 0.5rem 0; +} + +.ProseMirror ul, +.ProseMirror ol { + padding-left: 1.5rem; + margin: 0.5rem 0; +} + +.ProseMirror blockquote { + border-left: 4px solid #ddd; + padding-left: 1rem; + margin: 1rem 0; + font-style: italic; +} + +.ProseMirror pre { + background: #f5f5f5; + padding: 1rem; + border-radius: 4px; + overflow-x: auto; + margin: 1rem 0; +} + +.ProseMirror code { + background: #f5f5f5; + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-family: 'Courier New', monospace; +} + +.ProseMirror img { + max-width: 100%; + height: auto; + border-radius: 4px; + margin: 1rem 0; + display: block; +} + +.ProseMirror .image-resize-wrapper { + position: relative; + display: inline-block; + margin: 1rem 0; + max-width: 100%; +} + +.ProseMirror .image-resize-wrapper.selected { + outline: 2px solid #3b82f6; + outline-offset: 2px; + border-radius: 4px; +} + +.ProseMirror .image-resize-wrapper img { + display: block; + max-width: 100%; + height: auto; + border-radius: 4px; + user-select: none; + cursor: default; +} + +.ProseMirror .image-resize-wrapper .resize-handle { + position: absolute; + bottom: -8px; + right: -8px; + width: 24px; + height: 24px; + background-color: #3b82f6; + border: 2px solid white; + border-radius: 50%; + cursor: nwse-resize; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 12px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.ProseMirror .image-resize-wrapper .resize-handle:hover { + background-color: #2563eb; + transform: scale(1.1); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..54b39dd --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) diff --git a/frontend/src/pages/BlogPost.jsx b/frontend/src/pages/BlogPost.jsx new file mode 100644 index 0000000..6a345dd --- /dev/null +++ b/frontend/src/pages/BlogPost.jsx @@ -0,0 +1,91 @@ +import { useState, useEffect } from 'react' +import { useParams } from 'react-router-dom' +import { useEditor, EditorContent } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import Image from '@tiptap/extension-image' +import TextStyle from '@tiptap/extension-text-style' +import Color from '@tiptap/extension-color' +import Underline from '@tiptap/extension-underline' +import { FontSize } from '../extensions/FontSize' +import api from '../utils/api' + +export default function BlogPost() { + const { slug } = useParams() + const [post, setPost] = useState(null) + const [loading, setLoading] = useState(true) + + const editor = useEditor({ + extensions: [ + StarterKit, + Image, + TextStyle, + Color, + Underline, + FontSize, + ], + content: null, + editable: false, + editorProps: { + attributes: { + class: 'ProseMirror', + }, + }, + }) + + useEffect(() => { + fetchPost() + }, [slug]) + + useEffect(() => { + if (post && editor) { + editor.commands.setContent(post.content_json || {}) + } + }, [post, editor]) + + const fetchPost = async () => { + try { + const res = await api.get(`/posts/slug/${slug}`) + setPost(res.data) + } catch (error) { + console.error('Failed to load post:', error) + } finally { + setLoading(false) + } + } + + if (loading) { + return
Loading...
+ } + + if (!post) { + return ( +
+
+

Post not found

+

The post you're looking for doesn't exist.

+
+
+ ) + } + + return ( +
+
+
+

{post.title}

+
+ Published on {new Date(post.created_at).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} +
+
+ +
+ {editor && } +
+
+
+ ) +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..4ff062f --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,154 @@ +import { useState, useEffect } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' +import api from '../utils/api' +import toast from 'react-hot-toast' + +export default function Dashboard() { + const [posts, setPosts] = useState([]) + const [loading, setLoading] = useState(true) + const { user, logout } = useAuth() + const navigate = useNavigate() + + useEffect(() => { + fetchPosts() + }, []) + + const fetchPosts = async () => { + try { + const res = await api.get('/posts') + setPosts(res.data) + } catch (error) { + toast.error('Failed to load posts') + } finally { + setLoading(false) + } + } + + const handleDelete = async (id) => { + if (!window.confirm('Are you sure you want to delete this post?')) return + + try { + await api.delete(`/posts/${id}`) + toast.success('Post deleted') + fetchPosts() + } catch (error) { + toast.error('Failed to delete post') + } + } + + const handlePublish = async (post) => { + try { + await api.put(`/posts/${post.id}`, { + ...post, + status: post.status === 'published' ? 'draft' : 'published' + }) + toast.success(`Post ${post.status === 'published' ? 'unpublished' : 'published'}`) + fetchPosts() + } catch (error) { + toast.error('Failed to update post') + } + } + + if (loading) { + return
Loading...
+ } + + return ( +
+ + +
+

Your Posts

+ + {posts.length === 0 ? ( +
+

No posts yet. Create your first post!

+ + Create Post + +
+ ) : ( +
+ {posts.map((post) => ( +
+
+

+ {post.title || 'Untitled'} +

+ + {post.status} + +
+

+ {new Date(post.updated_at).toLocaleDateString()} +

+
+ + Edit + + {post.status === 'published' && ( + + View + + )} + + +
+
+ ))} +
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/Editor.jsx b/frontend/src/pages/Editor.jsx new file mode 100644 index 0000000..c71481f --- /dev/null +++ b/frontend/src/pages/Editor.jsx @@ -0,0 +1,198 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import Editor from '../components/Editor' +import api from '../utils/api' +import toast from 'react-hot-toast' + +export default function EditorPage() { + const { id } = useParams() + const navigate = useNavigate() + const [title, setTitle] = useState('') + const [content, setContent] = useState(null) + const [loading, setLoading] = useState(!!id) + const [saving, setSaving] = useState(false) + const autoSaveTimeoutRef = useRef(null) + const isInitialLoadRef = useRef(true) + const currentPostIdRef = useRef(id) + + useEffect(() => { + currentPostIdRef.current = id + if (id) { + fetchPost() + } else { + setLoading(false) + } + }, [id]) + + // Debounced auto-save function + const handleAutoSave = useCallback(async () => { + // Don't save if nothing has changed + if (!title && !content) return + // Don't save during initial load + if (isInitialLoadRef.current) return + + try { + setSaving(true) + const postData = { + title: title || 'Untitled', + content_json: content || {}, + status: 'draft', + } + + let postId = currentPostIdRef.current + if (postId) { + // Update existing post + await api.put(`/posts/${postId}`, postData) + } else { + // Create new post + const res = await api.post('/posts', postData) + postId = res.data.id + currentPostIdRef.current = postId + // Update URL without reload + window.history.replaceState({}, '', `/editor/${postId}`) + } + } catch (error) { + console.error('Auto-save failed:', error) + // Don't show error toast for auto-save failures to avoid annoying user + } finally { + setSaving(false) + } + }, [title, content]) + + // Debounced save on content change + useEffect(() => { + // Skip on initial load + if (isInitialLoadRef.current) { + isInitialLoadRef.current = false + return + } + + // Clear existing timeout + if (autoSaveTimeoutRef.current) { + clearTimeout(autoSaveTimeoutRef.current) + } + + // Set new timeout for auto-save (2 seconds after last change) + autoSaveTimeoutRef.current = setTimeout(() => { + handleAutoSave() + }, 2000) + + return () => { + if (autoSaveTimeoutRef.current) { + clearTimeout(autoSaveTimeoutRef.current) + } + } + }, [title, content, handleAutoSave]) + + const fetchPost = async () => { + try { + const res = await api.get(`/posts/${id}`) + const post = res.data + setTitle(post.title || '') + setContent(post.content_json || null) + isInitialLoadRef.current = true // Reset after loading + } catch (error) { + toast.error('Failed to load post') + navigate('/dashboard') + } finally { + setLoading(false) + // Allow saving after a short delay + setTimeout(() => { + isInitialLoadRef.current = false + }, 500) + } + } + + const handlePublish = async () => { + if (!title.trim()) { + toast.error('Please enter a title') + return + } + + if (!content) { + toast.error('Please add some content') + return + } + + try { + setSaving(true) + const postData = { + title: title.trim(), + content_json: content, + status: 'published', + } + + let postId = currentPostIdRef.current || id + if (postId) { + await api.put(`/posts/${postId}`, postData) + } else { + const res = await api.post('/posts', postData) + postId = res.data.id + } + + toast.success('Post published!') + navigate('/dashboard') + } catch (error) { + toast.error(error.response?.data?.message || 'Failed to publish post') + } finally { + setSaving(false) + } + } + + // Cleanup on unmount + useEffect(() => { + return () => { + if (autoSaveTimeoutRef.current) { + clearTimeout(autoSaveTimeoutRef.current) + } + } + }, []) + + if (loading) { + return
Loading...
+ } + + return ( +
+ + +
+ setTitle(e.target.value)} + className="w-full text-3xl font-bold mb-6 p-2 border-0 border-b-2 border-gray-300 focus:outline-none focus:border-indigo-600 bg-transparent" + /> + + +
+
+ ) +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..39e693c --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,137 @@ +import { useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' +import toast from 'react-hot-toast' + +export default function Login() { + const [phoneNumber, setPhoneNumber] = useState('') + const [otp, setOtp] = useState('') + const [step, setStep] = useState('phone') // 'phone' or 'otp' + const [loading, setLoading] = useState(false) + const { requestOtp, verifyOtp } = useAuth() + const navigate = useNavigate() + + const handleRequestOtp = async (e) => { + e.preventDefault() + setLoading(true) + + try { + await requestOtp(phoneNumber) + toast.success('OTP sent to your phone!') + setStep('otp') + } catch (error) { + toast.error(error.response?.data?.error || 'Failed to send OTP') + } finally { + setLoading(false) + } + } + + const handleVerifyOtp = async (e) => { + e.preventDefault() + setLoading(true) + + try { + await verifyOtp(phoneNumber, otp) + toast.success('Logged in successfully!') + navigate('/dashboard') + } catch (error) { + toast.error(error.response?.data?.error || 'Invalid OTP') + } finally { + setLoading(false) + } + } + + const normalizePhone = (phone) => { + const p = phone.trim().replace(/\s+/g, '') + if (p.startsWith('+')) return p + if (p.length === 10) return '+91' + p + return p + } + + return ( +
+
+
+

+ {step === 'phone' ? 'Sign in to your account' : 'Enter OTP'} +

+
+ + {step === 'phone' ? ( +
+
+ + setPhoneNumber(normalizePhone(e.target.value))} + /> +

+ We'll send you an OTP to verify your phone number +

+
+ +
+ +
+
+ ) : ( +
+
+ + setOtp(e.target.value.replace(/\D/g, ''))} + /> +

+ Enter the 4-digit OTP sent to {phoneNumber} +

+
+ +
+ + +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js new file mode 100644 index 0000000..2d24845 --- /dev/null +++ b/frontend/src/utils/api.js @@ -0,0 +1,59 @@ +import axios from 'axios' + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000' + +const api = axios.create({ + baseURL: `${API_URL}/api`, + headers: { + 'Content-Type': 'application/json', + }, + timeout: 30000, // 30 second timeout +}) + +// Add token to requests +api.interceptors.request.use((config) => { + const token = localStorage.getItem('access_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +// Handle token expiration +api.interceptors.response.use( + (response) => response, + async (error) => { + if (error.response?.status === 401) { + // Try to refresh token + const refreshToken = localStorage.getItem('refresh_token') + if (refreshToken) { + try { + const authApi = (await import('./authApi')).default + const res = await authApi.post('/auth/refresh', { refresh_token: refreshToken }) + const { access_token, refresh_token: newRefreshToken } = res.data + localStorage.setItem('access_token', access_token) + if (newRefreshToken) { + localStorage.setItem('refresh_token', newRefreshToken) + } + // Retry original request + error.config.headers.Authorization = `Bearer ${access_token}` + return api.request(error.config) + } catch (refreshError) { + // Refresh failed, logout + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + localStorage.removeItem('user') + window.location.href = '/login' + } + } else { + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + localStorage.removeItem('user') + window.location.href = '/login' + } + } + return Promise.reject(error) + } +) + +export default api diff --git a/frontend/src/utils/authApi.js b/frontend/src/utils/authApi.js new file mode 100644 index 0000000..811bd9c --- /dev/null +++ b/frontend/src/utils/authApi.js @@ -0,0 +1,13 @@ +import axios from 'axios' + +// Auth service API (separate from blog editor API) +const AUTH_API_URL = import.meta.env.VITE_AUTH_API_URL || 'http://localhost:3000' + +const authApi = axios.create({ + baseURL: AUTH_API_URL, + headers: { + 'Content-Type': 'application/json', + }, +}) + +export default authApi diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..dca8ba0 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..4e39307 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 4000, + proxy: { + '/api': { + target: 'http://localhost:5001', + changeOrigin: true + } + } + } +})