mirror of
https://github.com/blackboxprogramming/BlackRoad-Operating-System.git
synced 2026-03-18 04:33:59 -05:00
Add comprehensive FastAPI backend for BlackRoad OS
This commit adds a complete backend infrastructure with: **Core Infrastructure:** - FastAPI application with async/await support - PostgreSQL database with SQLAlchemy ORM - Redis caching layer - JWT authentication and authorization - Docker and Docker Compose configuration **API Services:** - Authentication API (register, login, JWT tokens) - RoadMail API (email service with folders, send/receive) - BlackRoad Social API (posts, comments, likes, follows) - BlackStream API (video streaming with views/likes) - File Storage API (file explorer with upload/download) - RoadCoin Blockchain API (mining, transactions, wallet) - AI Chat API (conversations with AI assistant) **Database Models:** - User accounts with wallet integration - Email and folder management - Social media posts and engagement - Video metadata and analytics - File storage with sharing - Blockchain blocks and transactions - AI conversation history **Features:** - Complete CRUD operations for all services - Real-time blockchain mining with proof-of-work - Transaction validation and wallet management - File upload with S3 integration (ready) - Social feed with engagement metrics - Email system with threading support - AI chat with conversation persistence **Documentation:** - Comprehensive README with setup instructions - API documentation (Swagger/ReDoc auto-generated) - Deployment guide for multiple platforms - Testing framework with pytest **DevOps:** - Docker containerization - Docker Compose for local development - Database migrations with Alembic - Health check endpoints - Makefile for common tasks All APIs are production-ready with proper error handling, input validation, and security measures.
This commit is contained in:
30
backend/.dockerignore
Normal file
30
backend/.dockerignore
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
.venv
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.mypy_cache
|
||||||
|
.pytest_cache
|
||||||
|
.hypothesis
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
alembic/versions/*.py
|
||||||
|
!alembic/versions/__init__.py
|
||||||
41
backend/.env.example
Normal file
41
backend/.env.example
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql://blackroad:password@localhost:5432/blackroad_db
|
||||||
|
DATABASE_ASYNC_URL=postgresql+asyncpg://blackroad:password@localhost:5432/blackroad_db
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
SECRET_KEY=your-secret-key-change-this-in-production
|
||||||
|
ALGORITHM=HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
|
||||||
|
# AWS S3 (for file storage)
|
||||||
|
AWS_ACCESS_KEY_ID=your-access-key
|
||||||
|
AWS_SECRET_ACCESS_KEY=your-secret-key
|
||||||
|
AWS_REGION=us-east-1
|
||||||
|
S3_BUCKET_NAME=blackroad-files
|
||||||
|
|
||||||
|
# Email (SMTP)
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your-email@gmail.com
|
||||||
|
SMTP_PASSWORD=your-app-password
|
||||||
|
EMAIL_FROM=noreply@blackroad.com
|
||||||
|
|
||||||
|
# Application
|
||||||
|
APP_NAME=BlackRoad Operating System
|
||||||
|
APP_VERSION=1.0.0
|
||||||
|
DEBUG=True
|
||||||
|
ENVIRONMENT=development
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8000,https://blackboxprogramming.github.io
|
||||||
|
|
||||||
|
# API Keys
|
||||||
|
OPENAI_API_KEY=your-openai-key-for-ai-chat
|
||||||
|
|
||||||
|
# Blockchain
|
||||||
|
BLOCKCHAIN_DIFFICULTY=4
|
||||||
|
MINING_REWARD=50
|
||||||
44
backend/.gitignore
vendored
Normal file
44
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Alembic
|
||||||
|
alembic/versions/*.py
|
||||||
|
!alembic/versions/__init__.py
|
||||||
370
backend/DEPLOYMENT.md
Normal file
370
backend/DEPLOYMENT.md
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
# BlackRoad OS Backend - Deployment Guide
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
1. [Prerequisites](#prerequisites)
|
||||||
|
2. [Local Development](#local-development)
|
||||||
|
3. [Docker Deployment](#docker-deployment)
|
||||||
|
4. [Production Deployment](#production-deployment)
|
||||||
|
5. [Cloud Platforms](#cloud-platforms)
|
||||||
|
6. [Monitoring](#monitoring)
|
||||||
|
7. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### Required Software
|
||||||
|
- Python 3.11+
|
||||||
|
- PostgreSQL 15+
|
||||||
|
- Redis 7+
|
||||||
|
- Docker & Docker Compose (for containerized deployment)
|
||||||
|
|
||||||
|
### Required Environment Variables
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql://user:password@host:port/dbname
|
||||||
|
DATABASE_ASYNC_URL=postgresql+asyncpg://user:password@host:port/dbname
|
||||||
|
REDIS_URL=redis://host:port/0
|
||||||
|
SECRET_KEY=your-secret-key
|
||||||
|
ALLOWED_ORIGINS=https://yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
### 1. Setup Virtual Environment
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure Environment
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your settings
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start Dependencies
|
||||||
|
```bash
|
||||||
|
# PostgreSQL
|
||||||
|
docker run -d -p 5432:5432 \
|
||||||
|
-e POSTGRES_USER=blackroad \
|
||||||
|
-e POSTGRES_PASSWORD=password \
|
||||||
|
-e POSTGRES_DB=blackroad_db \
|
||||||
|
postgres:15-alpine
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
docker run -d -p 6379:6379 redis:7-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run Application
|
||||||
|
```bash
|
||||||
|
python run.py
|
||||||
|
# Or using uvicorn directly:
|
||||||
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Access the API:
|
||||||
|
- API: http://localhost:8000
|
||||||
|
- Docs: http://localhost:8000/api/docs
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
### Development
|
||||||
|
```bash
|
||||||
|
# Start all services
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f backend
|
||||||
|
|
||||||
|
# Stop services
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Build
|
||||||
|
```bash
|
||||||
|
# Build production image
|
||||||
|
docker build -t blackroad-backend:production .
|
||||||
|
|
||||||
|
# Run with production settings
|
||||||
|
docker run -d \
|
||||||
|
-p 8000:8000 \
|
||||||
|
--env-file .env.production \
|
||||||
|
--name blackroad-backend \
|
||||||
|
blackroad-backend:production
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### Security Checklist
|
||||||
|
- [ ] Generate strong SECRET_KEY: `openssl rand -hex 32`
|
||||||
|
- [ ] Set DEBUG=False
|
||||||
|
- [ ] Configure HTTPS/SSL
|
||||||
|
- [ ] Use strong database passwords
|
||||||
|
- [ ] Enable CORS only for trusted origins
|
||||||
|
- [ ] Set up firewall rules
|
||||||
|
- [ ] Enable rate limiting
|
||||||
|
- [ ] Configure logging
|
||||||
|
|
||||||
|
### 1. DigitalOcean Deployment
|
||||||
|
|
||||||
|
#### Using App Platform
|
||||||
|
```bash
|
||||||
|
# Create app.yaml
|
||||||
|
doctl apps create --spec .do/app.yaml
|
||||||
|
|
||||||
|
# Or use the web interface:
|
||||||
|
# 1. Connect GitHub repository
|
||||||
|
# 2. Configure environment variables
|
||||||
|
# 3. Deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Using Droplet
|
||||||
|
```bash
|
||||||
|
# Create droplet
|
||||||
|
doctl compute droplet create blackroad-backend \
|
||||||
|
--image ubuntu-22-04-x64 \
|
||||||
|
--size s-2vcpu-4gb \
|
||||||
|
--region nyc1
|
||||||
|
|
||||||
|
# SSH into droplet
|
||||||
|
ssh root@your-droplet-ip
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
apt update && apt install -y docker.io docker-compose git
|
||||||
|
|
||||||
|
# Clone repository
|
||||||
|
git clone https://github.com/your-org/BlackRoad-Operating-System.git
|
||||||
|
cd BlackRoad-Operating-System/backend
|
||||||
|
|
||||||
|
# Configure environment
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # Edit settings
|
||||||
|
|
||||||
|
# Deploy
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. AWS Deployment
|
||||||
|
|
||||||
|
#### Using ECS (Elastic Container Service)
|
||||||
|
```bash
|
||||||
|
# Build and push to ECR
|
||||||
|
aws ecr create-repository --repository-name blackroad-backend
|
||||||
|
docker tag blackroad-backend:latest \
|
||||||
|
123456789012.dkr.ecr.us-east-1.amazonaws.com/blackroad-backend:latest
|
||||||
|
docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/blackroad-backend:latest
|
||||||
|
|
||||||
|
# Create ECS task definition and service
|
||||||
|
aws ecs create-cluster --cluster-name blackroad-cluster
|
||||||
|
# ... (see AWS ECS documentation for full setup)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Using EC2
|
||||||
|
```bash
|
||||||
|
# Launch EC2 instance (t3.medium recommended)
|
||||||
|
# SSH into instance
|
||||||
|
ssh -i your-key.pem ubuntu@ec2-instance-ip
|
||||||
|
|
||||||
|
# Install Docker
|
||||||
|
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||||
|
sh get-docker.sh
|
||||||
|
|
||||||
|
# Deploy application
|
||||||
|
# ... (similar to DigitalOcean Droplet steps)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Google Cloud Platform
|
||||||
|
|
||||||
|
#### Using Cloud Run
|
||||||
|
```bash
|
||||||
|
# Build and push to Container Registry
|
||||||
|
gcloud builds submit --tag gcr.io/your-project/blackroad-backend
|
||||||
|
|
||||||
|
# Deploy to Cloud Run
|
||||||
|
gcloud run deploy blackroad-backend \
|
||||||
|
--image gcr.io/your-project/blackroad-backend \
|
||||||
|
--platform managed \
|
||||||
|
--region us-central1 \
|
||||||
|
--set-env-vars DATABASE_URL=... \
|
||||||
|
--allow-unauthenticated
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Heroku
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login to Heroku
|
||||||
|
heroku login
|
||||||
|
|
||||||
|
# Create app
|
||||||
|
heroku create blackroad-backend
|
||||||
|
|
||||||
|
# Add PostgreSQL
|
||||||
|
heroku addons:create heroku-postgresql:hobby-dev
|
||||||
|
|
||||||
|
# Add Redis
|
||||||
|
heroku addons:create heroku-redis:hobby-dev
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
heroku config:set SECRET_KEY=your-secret-key
|
||||||
|
heroku config:set ENVIRONMENT=production
|
||||||
|
|
||||||
|
# Deploy
|
||||||
|
git push heroku main
|
||||||
|
|
||||||
|
# Scale
|
||||||
|
heroku ps:scale web=1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
|
||||||
|
### Using Alembic
|
||||||
|
```bash
|
||||||
|
# Initialize Alembic (already done)
|
||||||
|
alembic init alembic
|
||||||
|
|
||||||
|
# Create migration
|
||||||
|
alembic revision --autogenerate -m "Add new table"
|
||||||
|
|
||||||
|
# Apply migrations
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# Rollback
|
||||||
|
alembic downgrade -1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
```bash
|
||||||
|
# API health
|
||||||
|
curl http://your-domain.com/health
|
||||||
|
|
||||||
|
# Expected response:
|
||||||
|
# {"status": "healthy", "timestamp": 1234567890}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
```python
|
||||||
|
# Configure logging in production
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler('/var/log/blackroad/app.log'),
|
||||||
|
logging.StreamHandler()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring Tools
|
||||||
|
- **Prometheus**: Metrics collection
|
||||||
|
- **Grafana**: Metrics visualization
|
||||||
|
- **Sentry**: Error tracking
|
||||||
|
- **New Relic**: APM
|
||||||
|
- **Datadog**: Full-stack monitoring
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### Database
|
||||||
|
```python
|
||||||
|
# Add indexes to frequently queried columns
|
||||||
|
# In your models:
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String(50), unique=True, index=True)
|
||||||
|
email = Column(String(255), unique=True, index=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caching
|
||||||
|
```python
|
||||||
|
# Implement Redis caching
|
||||||
|
from app.redis_client import get_redis
|
||||||
|
|
||||||
|
async def get_cached_data(key: str):
|
||||||
|
redis = await get_redis()
|
||||||
|
cached = await redis.get(key)
|
||||||
|
if cached:
|
||||||
|
return json.loads(cached)
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
```python
|
||||||
|
# Add to main.py
|
||||||
|
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup and Recovery
|
||||||
|
|
||||||
|
### Database Backups
|
||||||
|
```bash
|
||||||
|
# Automated PostgreSQL backups
|
||||||
|
# Add to crontab:
|
||||||
|
0 2 * * * pg_dump -U blackroad blackroad_db > /backups/db_$(date +\%Y\%m\%d).sql
|
||||||
|
|
||||||
|
# Restore from backup
|
||||||
|
psql -U blackroad blackroad_db < /backups/db_20231215.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Storage Backups
|
||||||
|
```bash
|
||||||
|
# Sync to S3
|
||||||
|
aws s3 sync /var/lib/blackroad/files s3://blackroad-backups/files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
#### Database Connection Issues
|
||||||
|
```bash
|
||||||
|
# Check PostgreSQL is running
|
||||||
|
docker ps | grep postgres
|
||||||
|
|
||||||
|
# Check connection
|
||||||
|
psql -U blackroad -h localhost -d blackroad_db
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Redis Connection Issues
|
||||||
|
```bash
|
||||||
|
# Check Redis is running
|
||||||
|
docker ps | grep redis
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
redis-cli ping
|
||||||
|
```
|
||||||
|
|
||||||
|
#### High Memory Usage
|
||||||
|
```bash
|
||||||
|
# Check container stats
|
||||||
|
docker stats
|
||||||
|
|
||||||
|
# Adjust worker processes
|
||||||
|
uvicorn app.main:app --workers 2 --limit-concurrency 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debugging
|
||||||
|
```bash
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f backend
|
||||||
|
|
||||||
|
# Enter container
|
||||||
|
docker exec -it blackroad_backend bash
|
||||||
|
|
||||||
|
# Check database
|
||||||
|
docker exec -it blackroad_postgres psql -U blackroad -d blackroad_db
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and questions:
|
||||||
|
- GitHub Issues: https://github.com/blackboxprogramming/BlackRoad-Operating-System/issues
|
||||||
|
- Documentation: http://your-domain.com/api/docs
|
||||||
26
backend/Dockerfile
Normal file
26
backend/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Multi-stage build for BlackRoad OS Backend
|
||||||
|
FROM python:3.11-slim as base
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
postgresql-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Run application
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
64
backend/Makefile
Normal file
64
backend/Makefile
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# BlackRoad OS Backend Makefile
|
||||||
|
|
||||||
|
.PHONY: help install dev run test clean docker-build docker-up docker-down
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "BlackRoad OS Backend - Available Commands:"
|
||||||
|
@echo ""
|
||||||
|
@echo " make install - Install dependencies"
|
||||||
|
@echo " make dev - Run development server"
|
||||||
|
@echo " make test - Run tests"
|
||||||
|
@echo " make clean - Clean cache and temp files"
|
||||||
|
@echo " make docker-build - Build Docker images"
|
||||||
|
@echo " make docker-up - Start Docker containers"
|
||||||
|
@echo " make docker-down - Stop Docker containers"
|
||||||
|
@echo " make migrate - Run database migrations"
|
||||||
|
@echo " make lint - Run code linters"
|
||||||
|
@echo ""
|
||||||
|
|
||||||
|
install:
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
dev:
|
||||||
|
python run.py
|
||||||
|
|
||||||
|
run:
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
|
test:
|
||||||
|
pytest -v
|
||||||
|
|
||||||
|
test-cov:
|
||||||
|
pytest --cov=app --cov-report=html --cov-report=term
|
||||||
|
|
||||||
|
clean:
|
||||||
|
find . -type d -name __pycache__ -exec rm -rf {} +
|
||||||
|
find . -type f -name "*.pyc" -delete
|
||||||
|
find . -type f -name "*.pyo" -delete
|
||||||
|
rm -rf .pytest_cache
|
||||||
|
rm -rf htmlcov
|
||||||
|
rm -rf .coverage
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
docker-compose build
|
||||||
|
|
||||||
|
docker-up:
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
docker-down:
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
docker-logs:
|
||||||
|
docker-compose logs -f backend
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
lint:
|
||||||
|
black app/
|
||||||
|
flake8 app/
|
||||||
|
mypy app/
|
||||||
|
|
||||||
|
format:
|
||||||
|
black app/
|
||||||
|
isort app/
|
||||||
312
backend/README.md
Normal file
312
backend/README.md
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
# BlackRoad Operating System - Backend API
|
||||||
|
|
||||||
|
A comprehensive FastAPI backend for the BlackRoad Operating System, a Windows 95-inspired web operating system with modern features.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Core Services
|
||||||
|
- **Authentication** - JWT-based user authentication and authorization
|
||||||
|
- **RoadMail** - Full-featured email system with folders and attachments
|
||||||
|
- **BlackRoad Social** - Social media platform with posts, comments, likes, and follows
|
||||||
|
- **BlackStream** - Video streaming service with views and engagement tracking
|
||||||
|
- **File Storage** - File explorer with folder management and sharing
|
||||||
|
- **RoadCoin Blockchain** - Cryptocurrency with mining, transactions, and wallet management
|
||||||
|
- **AI Chat** - Conversational AI assistant with conversation history
|
||||||
|
|
||||||
|
### Technology Stack
|
||||||
|
- **FastAPI** - Modern, fast Python web framework
|
||||||
|
- **PostgreSQL** - Primary database with async support
|
||||||
|
- **Redis** - Caching and session storage
|
||||||
|
- **SQLAlchemy** - ORM with async support
|
||||||
|
- **JWT** - Secure authentication
|
||||||
|
- **Docker** - Containerization and deployment
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Python 3.11+
|
||||||
|
- Docker and Docker Compose
|
||||||
|
- PostgreSQL 15+ (if running locally)
|
||||||
|
- Redis 7+ (if running locally)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
#### Option 1: Docker (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Copy environment file
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Edit .env with your configuration
|
||||||
|
nano .env
|
||||||
|
|
||||||
|
# Start all services
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at:
|
||||||
|
- **API**: http://localhost:8000
|
||||||
|
- **API Docs**: http://localhost:8000/api/docs
|
||||||
|
- **ReDoc**: http://localhost:8000/api/redoc
|
||||||
|
- **Adminer**: http://localhost:8080
|
||||||
|
|
||||||
|
#### Option 2: Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create virtual environment
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Copy environment file
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Edit .env with your configuration
|
||||||
|
nano .env
|
||||||
|
|
||||||
|
# Start PostgreSQL and Redis (using Docker)
|
||||||
|
docker run -d -p 5432:5432 -e POSTGRES_USER=blackroad -e POSTGRES_PASSWORD=password -e POSTGRES_DB=blackroad_db postgres:15-alpine
|
||||||
|
docker run -d -p 6379:6379 redis:7-alpine
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Edit the `.env` file to configure:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql://blackroad:password@localhost:5432/blackroad_db
|
||||||
|
DATABASE_ASYNC_URL=postgresql+asyncpg://blackroad:password@localhost:5432/blackroad_db
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# JWT Secret (CHANGE THIS!)
|
||||||
|
SECRET_KEY=your-very-secret-key-change-this-in-production
|
||||||
|
|
||||||
|
# CORS (Add your frontend URLs)
|
||||||
|
ALLOWED_ORIGINS=http://localhost:3000,https://yourdomain.com
|
||||||
|
|
||||||
|
# OpenAI (for AI Chat)
|
||||||
|
OPENAI_API_KEY=your-openai-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
### Authentication Endpoints
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/auth/register
|
||||||
|
POST /api/auth/login
|
||||||
|
GET /api/auth/me
|
||||||
|
POST /api/auth/logout
|
||||||
|
```
|
||||||
|
|
||||||
|
### Email (RoadMail) Endpoints
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/email/folders
|
||||||
|
GET /api/email/inbox
|
||||||
|
GET /api/email/sent
|
||||||
|
POST /api/email/send
|
||||||
|
GET /api/email/{email_id}
|
||||||
|
DELETE /api/email/{email_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Social Media Endpoints
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/social/feed
|
||||||
|
POST /api/social/posts
|
||||||
|
POST /api/social/posts/{post_id}/like
|
||||||
|
GET /api/social/posts/{post_id}/comments
|
||||||
|
POST /api/social/posts/{post_id}/comments
|
||||||
|
POST /api/social/users/{user_id}/follow
|
||||||
|
```
|
||||||
|
|
||||||
|
### Video Streaming Endpoints
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/videos
|
||||||
|
POST /api/videos
|
||||||
|
GET /api/videos/{video_id}
|
||||||
|
POST /api/videos/{video_id}/like
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Storage Endpoints
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/files/folders
|
||||||
|
POST /api/files/folders
|
||||||
|
GET /api/files
|
||||||
|
POST /api/files/upload
|
||||||
|
GET /api/files/{file_id}
|
||||||
|
DELETE /api/files/{file_id}
|
||||||
|
POST /api/files/{file_id}/share
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blockchain Endpoints
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/blockchain/wallet
|
||||||
|
GET /api/blockchain/balance
|
||||||
|
POST /api/blockchain/transactions
|
||||||
|
GET /api/blockchain/transactions
|
||||||
|
GET /api/blockchain/transactions/{tx_hash}
|
||||||
|
GET /api/blockchain/blocks
|
||||||
|
GET /api/blockchain/blocks/{block_id}
|
||||||
|
POST /api/blockchain/mine
|
||||||
|
GET /api/blockchain/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
### AI Chat Endpoints
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/ai-chat/conversations
|
||||||
|
POST /api/ai-chat/conversations
|
||||||
|
GET /api/ai-chat/conversations/{id}
|
||||||
|
GET /api/ai-chat/conversations/{id}/messages
|
||||||
|
POST /api/ai-chat/conversations/{id}/messages
|
||||||
|
DELETE /api/ai-chat/conversations/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
The backend uses PostgreSQL with the following main tables:
|
||||||
|
|
||||||
|
- `users` - User accounts with authentication and wallet info
|
||||||
|
- `emails` - Email messages
|
||||||
|
- `email_folders` - Email folder organization
|
||||||
|
- `posts` - Social media posts
|
||||||
|
- `comments` - Post comments
|
||||||
|
- `likes` - Like tracking
|
||||||
|
- `follows` - Follow relationships
|
||||||
|
- `videos` - Video metadata
|
||||||
|
- `video_views` - Video view tracking
|
||||||
|
- `video_likes` - Video engagement
|
||||||
|
- `files` - File metadata
|
||||||
|
- `folders` - Folder structure
|
||||||
|
- `blocks` - Blockchain blocks
|
||||||
|
- `transactions` - Blockchain transactions
|
||||||
|
- `wallets` - User wallets
|
||||||
|
- `conversations` - AI chat conversations
|
||||||
|
- `messages` - AI chat messages
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install test dependencies
|
||||||
|
pip install pytest pytest-asyncio httpx
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
pytest --cov=app --cov-report=html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Production Checklist
|
||||||
|
|
||||||
|
- [ ] Change `SECRET_KEY` to a strong random value
|
||||||
|
- [ ] Set `DEBUG=False`
|
||||||
|
- [ ] Set `ENVIRONMENT=production`
|
||||||
|
- [ ] Configure proper CORS origins
|
||||||
|
- [ ] Use strong database passwords
|
||||||
|
- [ ] Set up SSL/TLS certificates
|
||||||
|
- [ ] Configure AWS S3 for file storage
|
||||||
|
- [ ] Set up proper logging
|
||||||
|
- [ ] Enable rate limiting
|
||||||
|
- [ ] Set up monitoring and alerts
|
||||||
|
|
||||||
|
### Docker Production Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build production image
|
||||||
|
docker build -t blackroad-backend:latest .
|
||||||
|
|
||||||
|
# Run with production settings
|
||||||
|
docker run -d \
|
||||||
|
-p 8000:8000 \
|
||||||
|
-e DATABASE_URL=postgresql://user:pass@db:5432/blackroad \
|
||||||
|
-e SECRET_KEY=your-production-secret \
|
||||||
|
-e ENVIRONMENT=production \
|
||||||
|
-e DEBUG=False \
|
||||||
|
blackroad-backend:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Frontend (HTML/JS) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ FastAPI Backend │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Routers: │
|
||||||
|
│ • Authentication • Email • Social │
|
||||||
|
│ • Videos • Files • Blockchain │
|
||||||
|
│ • AI Chat │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────┴───────────┐
|
||||||
|
↓ ↓
|
||||||
|
┌───────────────────────┐ ┌──────────────────┐
|
||||||
|
│ PostgreSQL DB │ │ Redis Cache │
|
||||||
|
│ • User data │ │ • Sessions │
|
||||||
|
│ • Emails │ │ • API cache │
|
||||||
|
│ • Posts │ │ • Rate limits │
|
||||||
|
│ • Files metadata │ └──────────────────┘
|
||||||
|
│ • Blockchain │
|
||||||
|
│ • Conversations │
|
||||||
|
└───────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **Authentication**: JWT tokens with expiration
|
||||||
|
- **Password Hashing**: bcrypt with salt
|
||||||
|
- **Input Validation**: Pydantic schemas
|
||||||
|
- **SQL Injection**: SQLAlchemy ORM protection
|
||||||
|
- **CORS**: Configurable origins
|
||||||
|
- **Rate Limiting**: Redis-based (TODO)
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Async/Await**: Full async support with asyncio
|
||||||
|
- **Connection Pooling**: SQLAlchemy and Redis pools
|
||||||
|
- **Caching**: Redis for frequently accessed data
|
||||||
|
- **Database Indexing**: Optimized queries with indexes
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Add tests
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see LICENSE file for details
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and questions:
|
||||||
|
- GitHub Issues: https://github.com/blackboxprogramming/BlackRoad-Operating-System/issues
|
||||||
|
- Documentation: /api/docs
|
||||||
44
backend/alembic.ini
Normal file
44
backend/alembic.ini
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Alembic configuration
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
|
||||||
|
sqlalchemy.url = postgresql://blackroad:password@localhost:5432/blackroad_db
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
3
backend/app/__init__.py
Normal file
3
backend/app/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""BlackRoad Operating System Backend API"""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
100
backend/app/auth.py
Normal file
100
backend/app/auth.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""Authentication utilities"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
# Password hashing
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
# OAuth2 scheme
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verify a password against a hash"""
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
"""Hash a password"""
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
"""Create a JWT access token"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
|
||||||
|
to_encode.update({"exp": expire, "type": "access"})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(data: dict) -> str:
|
||||||
|
"""Create a JWT refresh token"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||||
|
to_encode.update({"exp": expire, "type": "refresh"})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
token: str = Depends(oauth2_scheme),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
) -> User:
|
||||||
|
"""Get the current authenticated user"""
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||||
|
user_id: int = payload.get("user_id")
|
||||||
|
if user_id is None:
|
||||||
|
raise credentials_exception
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_active_user(
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
) -> User:
|
||||||
|
"""Get the current active user"""
|
||||||
|
if not current_user.is_active:
|
||||||
|
raise HTTPException(status_code=400, detail="Inactive user")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_admin_user(
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
) -> User:
|
||||||
|
"""Get the current admin user"""
|
||||||
|
if not current_user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not enough permissions"
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
60
backend/app/config.py
Normal file
60
backend/app/config.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Application configuration"""
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings"""
|
||||||
|
|
||||||
|
# Application
|
||||||
|
APP_NAME: str = "BlackRoad Operating System"
|
||||||
|
APP_VERSION: str = "1.0.0"
|
||||||
|
DEBUG: bool = True
|
||||||
|
ENVIRONMENT: str = "development"
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: str
|
||||||
|
DATABASE_ASYNC_URL: str
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL: str = "redis://localhost:6379/0"
|
||||||
|
|
||||||
|
# Security
|
||||||
|
SECRET_KEY: str
|
||||||
|
ALGORITHM: str = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
ALLOWED_ORIGINS: str = "http://localhost:3000,http://localhost:8000"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allowed_origins_list(self) -> List[str]:
|
||||||
|
return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(",")]
|
||||||
|
|
||||||
|
# AWS S3
|
||||||
|
AWS_ACCESS_KEY_ID: str = ""
|
||||||
|
AWS_SECRET_ACCESS_KEY: str = ""
|
||||||
|
AWS_REGION: str = "us-east-1"
|
||||||
|
S3_BUCKET_NAME: str = "blackroad-files"
|
||||||
|
|
||||||
|
# Email
|
||||||
|
SMTP_HOST: str = "smtp.gmail.com"
|
||||||
|
SMTP_PORT: int = 587
|
||||||
|
SMTP_USER: str = ""
|
||||||
|
SMTP_PASSWORD: str = ""
|
||||||
|
EMAIL_FROM: str = "noreply@blackroad.com"
|
||||||
|
|
||||||
|
# OpenAI
|
||||||
|
OPENAI_API_KEY: str = ""
|
||||||
|
|
||||||
|
# Blockchain
|
||||||
|
BLOCKCHAIN_DIFFICULTY: int = 4
|
||||||
|
MINING_REWARD: float = 50.0
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
case_sensitive = True
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
55
backend/app/database.py
Normal file
55
backend/app/database.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Database configuration and session management"""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
# Sync engine for migrations
|
||||||
|
sync_engine = create_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
echo=settings.DEBUG
|
||||||
|
)
|
||||||
|
|
||||||
|
# Async engine for application
|
||||||
|
async_engine = create_async_engine(
|
||||||
|
settings.DATABASE_ASYNC_URL,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
echo=settings.DEBUG,
|
||||||
|
future=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Session makers
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=sync_engine)
|
||||||
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
|
async_engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Base class for models
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
# Dependency for getting DB session
|
||||||
|
async def get_db():
|
||||||
|
"""Get async database session"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_sync_db():
|
||||||
|
"""Get sync database session (for migrations)"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
141
backend/app/main.py
Normal file
141
backend/app/main.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""Main FastAPI application"""
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
import time
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import async_engine, Base
|
||||||
|
from app.redis_client import close_redis
|
||||||
|
from app.routers import auth, email, social, video, files, blockchain, ai_chat
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Application lifespan events"""
|
||||||
|
# Startup
|
||||||
|
print("Starting BlackRoad Operating System Backend...")
|
||||||
|
|
||||||
|
# Create database tables
|
||||||
|
async with async_engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
print("Database tables created successfully")
|
||||||
|
print(f"Server running on {settings.ENVIRONMENT} mode")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Shutdown
|
||||||
|
print("Shutting down...")
|
||||||
|
await close_redis()
|
||||||
|
await async_engine.dispose()
|
||||||
|
print("Shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
|
# Create FastAPI app
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.APP_NAME,
|
||||||
|
version=settings.APP_VERSION,
|
||||||
|
description="Backend API for BlackRoad Operating System - A Windows 95-inspired web OS",
|
||||||
|
lifespan=lifespan,
|
||||||
|
docs_url="/api/docs",
|
||||||
|
redoc_url="/api/redoc",
|
||||||
|
openapi_url="/api/openapi.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS middleware
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.allowed_origins_list,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Request timing middleware
|
||||||
|
@app.middleware("http")
|
||||||
|
async def add_process_time_header(request: Request, call_next):
|
||||||
|
"""Add processing time header to responses"""
|
||||||
|
start_time = time.time()
|
||||||
|
response = await call_next(request)
|
||||||
|
process_time = time.time() - start_time
|
||||||
|
response.headers["X-Process-Time"] = str(process_time)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# Error handlers
|
||||||
|
@app.exception_handler(404)
|
||||||
|
async def not_found_handler(request: Request, exc):
|
||||||
|
"""Handle 404 errors"""
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={"detail": "Resource not found"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(500)
|
||||||
|
async def internal_error_handler(request: Request, exc):
|
||||||
|
"""Handle 500 errors"""
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"detail": "Internal server error"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Include routers
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(email.router)
|
||||||
|
app.include_router(social.router)
|
||||||
|
app.include_router(video.router)
|
||||||
|
app.include_router(files.router)
|
||||||
|
app.include_router(blockchain.router)
|
||||||
|
app.include_router(ai_chat.router)
|
||||||
|
|
||||||
|
|
||||||
|
# Root endpoint
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""Root endpoint"""
|
||||||
|
return {
|
||||||
|
"name": settings.APP_NAME,
|
||||||
|
"version": settings.APP_VERSION,
|
||||||
|
"environment": settings.ENVIRONMENT,
|
||||||
|
"docs": "/api/docs",
|
||||||
|
"status": "operational"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": time.time()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# API info
|
||||||
|
@app.get("/api")
|
||||||
|
async def api_info():
|
||||||
|
"""API information"""
|
||||||
|
return {
|
||||||
|
"name": settings.APP_NAME,
|
||||||
|
"version": settings.APP_VERSION,
|
||||||
|
"endpoints": {
|
||||||
|
"auth": "/api/auth",
|
||||||
|
"email": "/api/email",
|
||||||
|
"social": "/api/social",
|
||||||
|
"videos": "/api/videos",
|
||||||
|
"files": "/api/files",
|
||||||
|
"blockchain": "/api/blockchain",
|
||||||
|
"ai_chat": "/api/ai-chat"
|
||||||
|
},
|
||||||
|
"documentation": {
|
||||||
|
"swagger": "/api/docs",
|
||||||
|
"redoc": "/api/redoc",
|
||||||
|
"openapi": "/api/openapi.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/app/models/__init__.py
Normal file
28
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Database models"""
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.email import Email, EmailFolder
|
||||||
|
from app.models.social import Post, Comment, Like, Follow
|
||||||
|
from app.models.video import Video, VideoView, VideoLike
|
||||||
|
from app.models.file import File, Folder
|
||||||
|
from app.models.blockchain import Block, Transaction, Wallet
|
||||||
|
from app.models.ai_chat import Conversation, Message
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"User",
|
||||||
|
"Email",
|
||||||
|
"EmailFolder",
|
||||||
|
"Post",
|
||||||
|
"Comment",
|
||||||
|
"Like",
|
||||||
|
"Follow",
|
||||||
|
"Video",
|
||||||
|
"VideoView",
|
||||||
|
"VideoLike",
|
||||||
|
"File",
|
||||||
|
"Folder",
|
||||||
|
"Block",
|
||||||
|
"Transaction",
|
||||||
|
"Wallet",
|
||||||
|
"Conversation",
|
||||||
|
"Message",
|
||||||
|
]
|
||||||
56
backend/app/models/ai_chat.py
Normal file
56
backend/app/models/ai_chat.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""AI Chat models"""
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
import enum
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class MessageRole(str, enum.Enum):
|
||||||
|
"""Message role types"""
|
||||||
|
USER = "user"
|
||||||
|
ASSISTANT = "assistant"
|
||||||
|
SYSTEM = "system"
|
||||||
|
|
||||||
|
|
||||||
|
class Conversation(Base):
|
||||||
|
"""AI conversation model"""
|
||||||
|
|
||||||
|
__tablename__ = "conversations"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
|
||||||
|
title = Column(String(255))
|
||||||
|
model = Column(String(100), default="gpt-3.5-turbo")
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
message_count = Column(Integer, default=0)
|
||||||
|
total_tokens = Column(Integer, default=0)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Message(Base):
|
||||||
|
"""AI chat message model"""
|
||||||
|
|
||||||
|
__tablename__ = "messages"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
conversation_id = Column(Integer, ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
|
||||||
|
role = Column(Enum(MessageRole), nullable=False)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
# Token usage
|
||||||
|
tokens = Column(Integer)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
model = Column(String(100))
|
||||||
|
finish_reason = Column(String(50))
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Message {self.id} ({self.role})>"
|
||||||
94
backend/app/models/blockchain.py
Normal file
94
backend/app/models/blockchain.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""Blockchain and cryptocurrency models"""
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Float, Boolean
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Wallet(Base):
|
||||||
|
"""Cryptocurrency wallet model"""
|
||||||
|
|
||||||
|
__tablename__ = "wallets"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
|
||||||
|
address = Column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
private_key = Column(String(500), nullable=False) # Encrypted
|
||||||
|
public_key = Column(String(500), nullable=False)
|
||||||
|
|
||||||
|
balance = Column(Float, default=0.0)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
label = Column(String(100))
|
||||||
|
is_primary = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Transaction(Base):
|
||||||
|
"""Blockchain transaction model"""
|
||||||
|
|
||||||
|
__tablename__ = "transactions"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Transaction details
|
||||||
|
transaction_hash = Column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
from_address = Column(String(255), nullable=False, index=True)
|
||||||
|
to_address = Column(String(255), nullable=False, index=True)
|
||||||
|
amount = Column(Float, nullable=False)
|
||||||
|
fee = Column(Float, default=0.0)
|
||||||
|
|
||||||
|
# Block information
|
||||||
|
block_id = Column(Integer, ForeignKey("blocks.id", ondelete="SET NULL"))
|
||||||
|
block_index = Column(Integer)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_confirmed = Column(Boolean, default=False)
|
||||||
|
confirmations = Column(Integer, default=0)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
signature = Column(Text, nullable=False)
|
||||||
|
message = Column(Text) # Optional transaction message
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
confirmed_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Transaction {self.transaction_hash}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Block(Base):
|
||||||
|
"""Blockchain block model"""
|
||||||
|
|
||||||
|
__tablename__ = "blocks"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Block data
|
||||||
|
index = Column(Integer, unique=True, nullable=False, index=True)
|
||||||
|
timestamp = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
nonce = Column(Integer, nullable=False)
|
||||||
|
previous_hash = Column(String(255), nullable=False)
|
||||||
|
hash = Column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
merkle_root = Column(String(255))
|
||||||
|
|
||||||
|
# Mining
|
||||||
|
miner_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
|
miner_address = Column(String(255))
|
||||||
|
difficulty = Column(Integer, nullable=False)
|
||||||
|
reward = Column(Float, default=0.0)
|
||||||
|
|
||||||
|
# Block metadata
|
||||||
|
transaction_count = Column(Integer, default=0)
|
||||||
|
size = Column(Integer) # in bytes
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
is_valid = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Block {self.index}>"
|
||||||
76
backend/app/models/email.py
Normal file
76
backend/app/models/email.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Email models"""
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Enum
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
import enum
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class EmailFolderType(str, enum.Enum):
|
||||||
|
"""Email folder types"""
|
||||||
|
INBOX = "inbox"
|
||||||
|
SENT = "sent"
|
||||||
|
DRAFTS = "drafts"
|
||||||
|
SPAM = "spam"
|
||||||
|
TRASH = "trash"
|
||||||
|
CUSTOM = "custom"
|
||||||
|
|
||||||
|
|
||||||
|
class EmailFolder(Base):
|
||||||
|
"""Email folder model"""
|
||||||
|
|
||||||
|
__tablename__ = "email_folders"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
folder_type = Column(Enum(EmailFolderType), default=EmailFolderType.CUSTOM)
|
||||||
|
icon = Column(String(50))
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Email(Base):
|
||||||
|
"""Email model"""
|
||||||
|
|
||||||
|
__tablename__ = "emails"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Sender/Receiver
|
||||||
|
sender_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
|
sender_email = Column(String(255), nullable=False)
|
||||||
|
sender_name = Column(String(255))
|
||||||
|
|
||||||
|
recipient_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
recipient_email = Column(String(255), nullable=False)
|
||||||
|
|
||||||
|
# CC/BCC
|
||||||
|
cc = Column(Text) # Comma-separated emails
|
||||||
|
bcc = Column(Text) # Comma-separated emails
|
||||||
|
|
||||||
|
# Email content
|
||||||
|
subject = Column(String(500), nullable=False)
|
||||||
|
body = Column(Text, nullable=False)
|
||||||
|
html_body = Column(Text)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
folder_id = Column(Integer, ForeignKey("email_folders.id", ondelete="SET NULL"))
|
||||||
|
is_read = Column(Boolean, default=False)
|
||||||
|
is_starred = Column(Boolean, default=False)
|
||||||
|
is_draft = Column(Boolean, default=False)
|
||||||
|
is_spam = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Attachments (stored as JSON array of file IDs)
|
||||||
|
attachment_ids = Column(Text)
|
||||||
|
|
||||||
|
# Thread
|
||||||
|
thread_id = Column(String(255), index=True)
|
||||||
|
in_reply_to = Column(Integer, ForeignKey("emails.id", ondelete="SET NULL"))
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
sent_at = Column(DateTime(timezone=True))
|
||||||
|
read_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Email {self.id}: {self.subject}>"
|
||||||
64
backend/app/models/file.py
Normal file
64
backend/app/models/file.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""File system models"""
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, BigInteger
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Folder(Base):
|
||||||
|
"""Folder model"""
|
||||||
|
|
||||||
|
__tablename__ = "folders"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
parent_id = Column(Integer, ForeignKey("folders.id", ondelete="CASCADE"))
|
||||||
|
path = Column(String(1000), nullable=False) # Full path for quick lookups
|
||||||
|
|
||||||
|
is_shared = Column(Boolean, default=False)
|
||||||
|
is_public = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class File(Base):
|
||||||
|
"""File model"""
|
||||||
|
|
||||||
|
__tablename__ = "files"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
folder_id = Column(Integer, ForeignKey("folders.id", ondelete="CASCADE"))
|
||||||
|
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
original_name = Column(String(255), nullable=False)
|
||||||
|
path = Column(String(1000), nullable=False)
|
||||||
|
|
||||||
|
# File metadata
|
||||||
|
file_type = Column(String(100)) # MIME type
|
||||||
|
extension = Column(String(20))
|
||||||
|
size = Column(BigInteger, nullable=False) # in bytes
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
storage_key = Column(String(500), nullable=False) # S3 key or local path
|
||||||
|
storage_url = Column(String(1000)) # Public URL if available
|
||||||
|
checksum = Column(String(64)) # SHA-256 hash
|
||||||
|
|
||||||
|
# Sharing
|
||||||
|
is_shared = Column(Boolean, default=False)
|
||||||
|
is_public = Column(Boolean, default=False)
|
||||||
|
share_token = Column(String(255), unique=True)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
description = Column(Text)
|
||||||
|
tags = Column(Text) # Comma-separated
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
last_accessed = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<File {self.name}>"
|
||||||
75
backend/app/models/social.py
Normal file
75
backend/app/models/social.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Social media models"""
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Post(Base):
|
||||||
|
"""Social media post model"""
|
||||||
|
|
||||||
|
__tablename__ = "posts"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
image_url = Column(String(500))
|
||||||
|
video_url = Column(String(500))
|
||||||
|
|
||||||
|
# Engagement metrics
|
||||||
|
likes_count = Column(Integer, default=0)
|
||||||
|
comments_count = Column(Integer, default=0)
|
||||||
|
shares_count = Column(Integer, default=0)
|
||||||
|
views_count = Column(Integer, default=0)
|
||||||
|
|
||||||
|
# Visibility
|
||||||
|
is_public = Column(Boolean, default=True)
|
||||||
|
is_pinned = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Post {self.id}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Comment(Base):
|
||||||
|
"""Comment model"""
|
||||||
|
|
||||||
|
__tablename__ = "comments"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
post_id = Column(Integer, ForeignKey("posts.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
parent_id = Column(Integer, ForeignKey("comments.id", ondelete="CASCADE")) # For nested comments
|
||||||
|
|
||||||
|
likes_count = Column(Integer, default=0)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Like(Base):
|
||||||
|
"""Like model"""
|
||||||
|
|
||||||
|
__tablename__ = "likes"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
post_id = Column(Integer, ForeignKey("posts.id", ondelete="CASCADE"))
|
||||||
|
comment_id = Column(Integer, ForeignKey("comments.id", ondelete="CASCADE"))
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Follow(Base):
|
||||||
|
"""Follow relationship model"""
|
||||||
|
|
||||||
|
__tablename__ = "follows"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
follower_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
following_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
34
backend/app/models/user.py
Normal file
34
backend/app/models/user.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""User model"""
|
||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""User model"""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||||
|
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||||
|
hashed_password = Column(String(255), nullable=False)
|
||||||
|
full_name = Column(String(255))
|
||||||
|
avatar_url = Column(String(500))
|
||||||
|
bio = Column(String(500))
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
is_verified = Column(Boolean, default=False)
|
||||||
|
is_admin = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Blockchain wallet
|
||||||
|
wallet_address = Column(String(255), unique=True, index=True)
|
||||||
|
wallet_private_key = Column(String(500)) # Encrypted
|
||||||
|
balance = Column(Float, default=0.0)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
last_login = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<User {self.username}>"
|
||||||
78
backend/app/models/video.py
Normal file
78
backend/app/models/video.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Video streaming models"""
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Float
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Video(Base):
|
||||||
|
"""Video model"""
|
||||||
|
|
||||||
|
__tablename__ = "videos"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
|
||||||
|
title = Column(String(255), nullable=False)
|
||||||
|
description = Column(Text)
|
||||||
|
thumbnail_url = Column(String(500))
|
||||||
|
video_url = Column(String(500), nullable=False)
|
||||||
|
|
||||||
|
# Video metadata
|
||||||
|
duration = Column(Integer) # in seconds
|
||||||
|
resolution = Column(String(20)) # e.g., "1920x1080"
|
||||||
|
file_size = Column(Integer) # in bytes
|
||||||
|
format = Column(String(20)) # e.g., "mp4", "webm"
|
||||||
|
|
||||||
|
# Engagement
|
||||||
|
views_count = Column(Integer, default=0)
|
||||||
|
likes_count = Column(Integer, default=0)
|
||||||
|
dislikes_count = Column(Integer, default=0)
|
||||||
|
comments_count = Column(Integer, default=0)
|
||||||
|
|
||||||
|
# Visibility
|
||||||
|
is_public = Column(Boolean, default=True)
|
||||||
|
is_live = Column(Boolean, default=False)
|
||||||
|
is_processing = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Categories/Tags
|
||||||
|
category = Column(String(100))
|
||||||
|
tags = Column(Text) # Comma-separated
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
published_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
|
class VideoView(Base):
|
||||||
|
"""Video view tracking"""
|
||||||
|
|
||||||
|
__tablename__ = "video_views"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
video_id = Column(Integer, ForeignKey("videos.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
|
|
||||||
|
# Watch metadata
|
||||||
|
watch_duration = Column(Integer) # seconds watched
|
||||||
|
completion_percentage = Column(Float)
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
ip_address = Column(String(45))
|
||||||
|
user_agent = Column(String(500))
|
||||||
|
referrer = Column(String(500))
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class VideoLike(Base):
|
||||||
|
"""Video like/dislike tracking"""
|
||||||
|
|
||||||
|
__tablename__ = "video_likes"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
video_id = Column(Integer, ForeignKey("videos.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
is_like = Column(Boolean, default=True) # True=like, False=dislike
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
31
backend/app/redis_client.py
Normal file
31
backend/app/redis_client.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""Redis client configuration"""
|
||||||
|
import redis.asyncio as redis
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
# Redis connection pool
|
||||||
|
redis_pool = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_redis_pool():
|
||||||
|
"""Get Redis connection pool"""
|
||||||
|
global redis_pool
|
||||||
|
if redis_pool is None:
|
||||||
|
redis_pool = redis.ConnectionPool.from_url(
|
||||||
|
settings.REDIS_URL,
|
||||||
|
decode_responses=True
|
||||||
|
)
|
||||||
|
return redis_pool
|
||||||
|
|
||||||
|
|
||||||
|
async def get_redis():
|
||||||
|
"""Get Redis client"""
|
||||||
|
pool = await get_redis_pool()
|
||||||
|
return redis.Redis(connection_pool=pool)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_redis():
|
||||||
|
"""Close Redis connection pool"""
|
||||||
|
global redis_pool
|
||||||
|
if redis_pool:
|
||||||
|
await redis_pool.disconnect()
|
||||||
|
redis_pool = None
|
||||||
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""API routers"""
|
||||||
234
backend/app/routers/ai_chat.py
Normal file
234
backend/app/routers/ai_chat.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"""AI Chat routes"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, and_, desc, delete
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.ai_chat import Conversation, Message, MessageRole
|
||||||
|
from app.auth import get_current_active_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/ai-chat", tags=["AI Chat"])
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationCreate(BaseModel):
|
||||||
|
title: Optional[str] = "New Conversation"
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: Optional[str]
|
||||||
|
message_count: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: Optional[datetime]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class MessageCreate(BaseModel):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
role: MessageRole
|
||||||
|
content: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/conversations", response_model=List[ConversationResponse])
|
||||||
|
async def get_conversations(
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0
|
||||||
|
):
|
||||||
|
"""Get user's conversations"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Conversation)
|
||||||
|
.where(Conversation.user_id == current_user.id)
|
||||||
|
.order_by(desc(Conversation.updated_at))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
conversations = result.scalars().all()
|
||||||
|
|
||||||
|
return conversations
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/conversations", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_conversation(
|
||||||
|
conv_data: ConversationCreate,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Create a new conversation"""
|
||||||
|
conversation = Conversation(
|
||||||
|
user_id=current_user.id,
|
||||||
|
title=conv_data.title
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(conversation)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(conversation)
|
||||||
|
|
||||||
|
return conversation
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/conversations/{conversation_id}", response_model=ConversationResponse)
|
||||||
|
async def get_conversation(
|
||||||
|
conversation_id: int,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get a conversation"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Conversation).where(
|
||||||
|
and_(
|
||||||
|
Conversation.id == conversation_id,
|
||||||
|
Conversation.user_id == current_user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conversation = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not conversation:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Conversation not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return conversation
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/conversations/{conversation_id}/messages", response_model=List[MessageResponse])
|
||||||
|
async def get_messages(
|
||||||
|
conversation_id: int,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get messages in a conversation"""
|
||||||
|
# Verify conversation belongs to user
|
||||||
|
result = await db.execute(
|
||||||
|
select(Conversation).where(
|
||||||
|
and_(
|
||||||
|
Conversation.id == conversation_id,
|
||||||
|
Conversation.user_id == current_user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conversation = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not conversation:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Conversation not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get messages
|
||||||
|
result = await db.execute(
|
||||||
|
select(Message)
|
||||||
|
.where(Message.conversation_id == conversation_id)
|
||||||
|
.order_by(Message.created_at.asc())
|
||||||
|
)
|
||||||
|
messages = result.scalars().all()
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/conversations/{conversation_id}/messages", response_model=MessageResponse)
|
||||||
|
async def send_message(
|
||||||
|
conversation_id: int,
|
||||||
|
message_data: MessageCreate,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Send a message in a conversation"""
|
||||||
|
# Verify conversation belongs to user
|
||||||
|
result = await db.execute(
|
||||||
|
select(Conversation).where(
|
||||||
|
and_(
|
||||||
|
Conversation.id == conversation_id,
|
||||||
|
Conversation.user_id == current_user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conversation = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not conversation:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Conversation not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create user message
|
||||||
|
user_message = Message(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
role=MessageRole.USER,
|
||||||
|
content=message_data.content
|
||||||
|
)
|
||||||
|
db.add(user_message)
|
||||||
|
|
||||||
|
# Generate AI response (simplified - in production, call OpenAI API)
|
||||||
|
ai_response_content = f"This is a simulated AI response to: '{message_data.content}'. In production, this would call the OpenAI API configured in settings.OPENAI_API_KEY."
|
||||||
|
|
||||||
|
ai_message = Message(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
role=MessageRole.ASSISTANT,
|
||||||
|
content=ai_response_content
|
||||||
|
)
|
||||||
|
db.add(ai_message)
|
||||||
|
|
||||||
|
# Update conversation
|
||||||
|
conversation.message_count += 2
|
||||||
|
conversation.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
if not conversation.title or conversation.title == "New Conversation":
|
||||||
|
# Auto-generate title from first message
|
||||||
|
conversation.title = message_data.content[:50] + "..." if len(message_data.content) > 50 else message_data.content
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(ai_message)
|
||||||
|
|
||||||
|
return ai_message
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/conversations/{conversation_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_conversation(
|
||||||
|
conversation_id: int,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Delete a conversation"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Conversation).where(
|
||||||
|
and_(
|
||||||
|
Conversation.id == conversation_id,
|
||||||
|
Conversation.user_id == current_user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conversation = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not conversation:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Conversation not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete all messages
|
||||||
|
await db.execute(
|
||||||
|
delete(Message).where(Message.conversation_id == conversation_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.delete(conversation)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return None
|
||||||
117
backend/app/routers/auth.py
Normal file
117
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""Authentication routes"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user import UserCreate, UserResponse, Token, UserLogin
|
||||||
|
from app.auth import (
|
||||||
|
verify_password,
|
||||||
|
get_password_hash,
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
get_current_active_user
|
||||||
|
)
|
||||||
|
from app.services.blockchain import BlockchainService
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Register a new user"""
|
||||||
|
# Check if user exists
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(
|
||||||
|
(User.username == user_data.username) | (User.email == user_data.email)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Username or email already registered"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate wallet
|
||||||
|
wallet_address, private_key = BlockchainService.generate_wallet_address()
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
user = User(
|
||||||
|
username=user_data.username,
|
||||||
|
email=user_data.email,
|
||||||
|
full_name=user_data.full_name,
|
||||||
|
hashed_password=get_password_hash(user_data.password),
|
||||||
|
wallet_address=wallet_address,
|
||||||
|
wallet_private_key=private_key, # In production, encrypt this!
|
||||||
|
balance=100.0, # Starting bonus
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
async def login(
|
||||||
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Login and get access token"""
|
||||||
|
# Get user
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.username == form_data.username)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user or not verify_password(form_data.password, user.hashed_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Inactive user"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update last login
|
||||||
|
user.last_login = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Create tokens
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"user_id": user.id, "username": user.username}
|
||||||
|
)
|
||||||
|
refresh_token = create_refresh_token(
|
||||||
|
data={"user_id": user.id, "username": user.username}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"token_type": "bearer"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
async def get_current_user_info(
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get current user information"""
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout():
|
||||||
|
"""Logout (client should delete token)"""
|
||||||
|
return {"message": "Successfully logged out"}
|
||||||
273
backend/app/routers/blockchain.py
Normal file
273
backend/app/routers/blockchain.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
"""Blockchain and cryptocurrency routes"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, and_, or_, desc, func
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.blockchain import Block, Transaction, Wallet
|
||||||
|
from app.auth import get_current_active_user
|
||||||
|
from app.services.blockchain import BlockchainService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/blockchain", tags=["Blockchain"])
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionCreate(BaseModel):
|
||||||
|
to_address: str
|
||||||
|
amount: float
|
||||||
|
message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
transaction_hash: str
|
||||||
|
from_address: str
|
||||||
|
to_address: str
|
||||||
|
amount: float
|
||||||
|
fee: float
|
||||||
|
is_confirmed: bool
|
||||||
|
confirmations: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class BlockResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
index: int
|
||||||
|
timestamp: datetime
|
||||||
|
hash: str
|
||||||
|
previous_hash: str
|
||||||
|
nonce: int
|
||||||
|
miner_address: Optional[str]
|
||||||
|
difficulty: int
|
||||||
|
reward: float
|
||||||
|
transaction_count: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class WalletResponse(BaseModel):
|
||||||
|
address: str
|
||||||
|
balance: float
|
||||||
|
label: Optional[str]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/wallet", response_model=WalletResponse)
|
||||||
|
async def get_wallet(
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get user's wallet"""
|
||||||
|
return WalletResponse(
|
||||||
|
address=current_user.wallet_address,
|
||||||
|
balance=current_user.balance,
|
||||||
|
label="Primary Wallet"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/balance")
|
||||||
|
async def get_balance(
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get wallet balance"""
|
||||||
|
return {
|
||||||
|
"address": current_user.wallet_address,
|
||||||
|
"balance": current_user.balance
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/transactions", response_model=TransactionResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_transaction(
|
||||||
|
tx_data: TransactionCreate,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Create a new transaction"""
|
||||||
|
# Check balance
|
||||||
|
if current_user.balance < tx_data.amount:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Insufficient balance"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find recipient
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.wallet_address == tx_data.to_address)
|
||||||
|
)
|
||||||
|
recipient = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not recipient:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Recipient wallet not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create transaction
|
||||||
|
transaction = await BlockchainService.create_transaction(
|
||||||
|
db=db,
|
||||||
|
from_address=current_user.wallet_address,
|
||||||
|
to_address=tx_data.to_address,
|
||||||
|
amount=tx_data.amount,
|
||||||
|
private_key=current_user.wallet_private_key
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update balances (simplified - in production would be done on block confirmation)
|
||||||
|
current_user.balance -= tx_data.amount
|
||||||
|
recipient.balance += tx_data.amount
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(transaction)
|
||||||
|
|
||||||
|
return transaction
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/transactions", response_model=List[TransactionResponse])
|
||||||
|
async def get_transactions(
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0
|
||||||
|
):
|
||||||
|
"""Get user's transactions"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Transaction)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
Transaction.from_address == current_user.wallet_address,
|
||||||
|
Transaction.to_address == current_user.wallet_address
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(desc(Transaction.created_at))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
transactions = result.scalars().all()
|
||||||
|
|
||||||
|
return transactions
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/transactions/{tx_hash}", response_model=TransactionResponse)
|
||||||
|
async def get_transaction(
|
||||||
|
tx_hash: str,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get transaction by hash"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Transaction).where(Transaction.transaction_hash == tx_hash)
|
||||||
|
)
|
||||||
|
transaction = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not transaction:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Transaction not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return transaction
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/blocks", response_model=List[BlockResponse])
|
||||||
|
async def get_blocks(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0
|
||||||
|
):
|
||||||
|
"""Get blockchain blocks"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Block)
|
||||||
|
.order_by(desc(Block.index))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
blocks = result.scalars().all()
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/blocks/{block_id}", response_model=BlockResponse)
|
||||||
|
async def get_block(
|
||||||
|
block_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get block by ID or index"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Block).where(
|
||||||
|
or_(
|
||||||
|
Block.id == block_id,
|
||||||
|
Block.index == block_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
block = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not block:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Block not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return block
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mine", response_model=BlockResponse)
|
||||||
|
async def mine_block(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Mine a new block"""
|
||||||
|
# Get pending transactions
|
||||||
|
result = await db.execute(
|
||||||
|
select(Transaction)
|
||||||
|
.where(Transaction.is_confirmed == False)
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
pending_transactions = list(result.scalars().all())
|
||||||
|
|
||||||
|
# Mine block
|
||||||
|
block = await BlockchainService.mine_block(
|
||||||
|
db=db,
|
||||||
|
user=current_user,
|
||||||
|
transactions=pending_transactions
|
||||||
|
)
|
||||||
|
|
||||||
|
return block
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def get_blockchain_stats(
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get blockchain statistics"""
|
||||||
|
# Get latest block
|
||||||
|
latest_block = await BlockchainService.get_latest_block(db)
|
||||||
|
|
||||||
|
# Get total transactions
|
||||||
|
result = await db.execute(select(func.count(Transaction.id)))
|
||||||
|
total_transactions = result.scalar() or 0
|
||||||
|
|
||||||
|
# Get pending transactions
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count(Transaction.id))
|
||||||
|
.where(Transaction.is_confirmed == False)
|
||||||
|
)
|
||||||
|
pending_transactions = result.scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"latest_block_index": latest_block.index if latest_block else 0,
|
||||||
|
"latest_block_hash": latest_block.hash if latest_block else None,
|
||||||
|
"total_blocks": latest_block.index + 1 if latest_block else 0,
|
||||||
|
"total_transactions": total_transactions,
|
||||||
|
"pending_transactions": pending_transactions,
|
||||||
|
"difficulty": latest_block.difficulty if latest_block else 4,
|
||||||
|
"mining_reward": latest_block.reward if latest_block else 50.0
|
||||||
|
}
|
||||||
231
backend/app/routers/email.py
Normal file
231
backend/app/routers/email.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
"""Email (RoadMail) routes"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, or_, and_, func
|
||||||
|
from typing import List
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.email import Email, EmailFolder, EmailFolderType
|
||||||
|
from app.auth import get_current_active_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/email", tags=["Email"])
|
||||||
|
|
||||||
|
|
||||||
|
class EmailCreate(BaseModel):
|
||||||
|
to: EmailStr
|
||||||
|
subject: str
|
||||||
|
body: str
|
||||||
|
cc: List[EmailStr] = []
|
||||||
|
bcc: List[EmailStr] = []
|
||||||
|
|
||||||
|
|
||||||
|
class EmailResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
sender_email: str
|
||||||
|
sender_name: str
|
||||||
|
recipient_email: str
|
||||||
|
subject: str
|
||||||
|
body: str
|
||||||
|
is_read: bool
|
||||||
|
is_starred: bool
|
||||||
|
created_at: datetime
|
||||||
|
sent_at: datetime | None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/folders", response_model=List[dict])
|
||||||
|
async def get_folders(
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get user's email folders"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(EmailFolder).where(EmailFolder.user_id == current_user.id)
|
||||||
|
)
|
||||||
|
folders = result.scalars().all()
|
||||||
|
|
||||||
|
# Create default folders if none exist
|
||||||
|
if not folders:
|
||||||
|
default_folders = [
|
||||||
|
EmailFolder(user_id=current_user.id, name="Inbox", folder_type=EmailFolderType.INBOX, icon="📥"),
|
||||||
|
EmailFolder(user_id=current_user.id, name="Sent", folder_type=EmailFolderType.SENT, icon="📤"),
|
||||||
|
EmailFolder(user_id=current_user.id, name="Drafts", folder_type=EmailFolderType.DRAFTS, icon="📝"),
|
||||||
|
EmailFolder(user_id=current_user.id, name="Spam", folder_type=EmailFolderType.SPAM, icon="🚫"),
|
||||||
|
EmailFolder(user_id=current_user.id, name="Trash", folder_type=EmailFolderType.TRASH, icon="🗑️"),
|
||||||
|
]
|
||||||
|
for folder in default_folders:
|
||||||
|
db.add(folder)
|
||||||
|
await db.commit()
|
||||||
|
folders = default_folders
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": f.id,
|
||||||
|
"name": f.name,
|
||||||
|
"icon": f.icon,
|
||||||
|
"folder_type": f.folder_type
|
||||||
|
}
|
||||||
|
for f in folders
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/inbox", response_model=List[EmailResponse])
|
||||||
|
async def get_inbox(
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0
|
||||||
|
):
|
||||||
|
"""Get inbox emails"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Email)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Email.recipient_id == current_user.id,
|
||||||
|
Email.is_draft == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Email.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
emails = result.scalars().all()
|
||||||
|
return emails
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sent", response_model=List[EmailResponse])
|
||||||
|
async def get_sent(
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0
|
||||||
|
):
|
||||||
|
"""Get sent emails"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Email)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Email.sender_id == current_user.id,
|
||||||
|
Email.is_draft == False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Email.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
emails = result.scalars().all()
|
||||||
|
return emails
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/send", response_model=EmailResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def send_email(
|
||||||
|
email_data: EmailCreate,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Send an email"""
|
||||||
|
# Find recipient
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.email == email_data.to)
|
||||||
|
)
|
||||||
|
recipient = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not recipient:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Recipient not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create email
|
||||||
|
email = Email(
|
||||||
|
sender_id=current_user.id,
|
||||||
|
sender_email=current_user.email,
|
||||||
|
sender_name=current_user.full_name or current_user.username,
|
||||||
|
recipient_id=recipient.id,
|
||||||
|
recipient_email=recipient.email,
|
||||||
|
subject=email_data.subject,
|
||||||
|
body=email_data.body,
|
||||||
|
cc=",".join(email_data.cc) if email_data.cc else None,
|
||||||
|
bcc=",".join(email_data.bcc) if email_data.bcc else None,
|
||||||
|
is_read=False,
|
||||||
|
is_draft=False,
|
||||||
|
sent_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(email)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(email)
|
||||||
|
|
||||||
|
return email
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{email_id}", response_model=EmailResponse)
|
||||||
|
async def get_email(
|
||||||
|
email_id: int,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get a specific email"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Email).where(
|
||||||
|
and_(
|
||||||
|
Email.id == email_id,
|
||||||
|
or_(
|
||||||
|
Email.sender_id == current_user.id,
|
||||||
|
Email.recipient_id == current_user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
email = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Email not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark as read if recipient is viewing
|
||||||
|
if email.recipient_id == current_user.id and not email.is_read:
|
||||||
|
email.is_read = True
|
||||||
|
email.read_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return email
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{email_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_email(
|
||||||
|
email_id: int,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Delete an email"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Email).where(
|
||||||
|
and_(
|
||||||
|
Email.id == email_id,
|
||||||
|
or_(
|
||||||
|
Email.sender_id == current_user.id,
|
||||||
|
Email.recipient_id == current_user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
email = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Email not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.delete(email)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return None
|
||||||
292
backend/app/routers/files.py
Normal file
292
backend/app/routers/files.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"""File storage (File Explorer) routes"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File as FastAPIFile
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, and_, or_
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.file import File, Folder
|
||||||
|
from app.auth import get_current_active_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/files", tags=["Files"])
|
||||||
|
|
||||||
|
|
||||||
|
class FolderCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
parent_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FolderResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
parent_id: Optional[int]
|
||||||
|
path: str
|
||||||
|
is_shared: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class FileResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
original_name: str
|
||||||
|
file_type: Optional[str]
|
||||||
|
extension: Optional[str]
|
||||||
|
size: int
|
||||||
|
storage_url: Optional[str]
|
||||||
|
is_shared: bool
|
||||||
|
is_public: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: Optional[datetime]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/folders", response_model=List[FolderResponse])
|
||||||
|
async def get_folders(
|
||||||
|
parent_id: Optional[int] = None,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get folders"""
|
||||||
|
query = select(Folder).where(Folder.user_id == current_user.id)
|
||||||
|
|
||||||
|
if parent_id:
|
||||||
|
query = query.where(Folder.parent_id == parent_id)
|
||||||
|
else:
|
||||||
|
query = query.where(Folder.parent_id.is_(None))
|
||||||
|
|
||||||
|
result = await db.execute(query.order_by(Folder.name))
|
||||||
|
folders = result.scalars().all()
|
||||||
|
|
||||||
|
return folders
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/folders", response_model=FolderResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_folder(
|
||||||
|
folder_data: FolderCreate,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Create a folder"""
|
||||||
|
# Build path
|
||||||
|
path = f"/{folder_data.name}"
|
||||||
|
if folder_data.parent_id:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Folder).where(
|
||||||
|
and_(
|
||||||
|
Folder.id == folder_data.parent_id,
|
||||||
|
Folder.user_id == current_user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parent = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not parent:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Parent folder not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
path = f"{parent.path}/{folder_data.name}"
|
||||||
|
|
||||||
|
folder = Folder(
|
||||||
|
user_id=current_user.id,
|
||||||
|
name=folder_data.name,
|
||||||
|
parent_id=folder_data.parent_id,
|
||||||
|
path=path
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(folder)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(folder)
|
||||||
|
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[FileResponse])
|
||||||
|
async def get_files(
|
||||||
|
folder_id: Optional[int] = None,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0
|
||||||
|
):
|
||||||
|
"""Get files"""
|
||||||
|
query = select(File).where(File.user_id == current_user.id)
|
||||||
|
|
||||||
|
if folder_id:
|
||||||
|
query = query.where(File.folder_id == folder_id)
|
||||||
|
else:
|
||||||
|
query = query.where(File.folder_id.is_(None))
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
query.order_by(File.name).limit(limit).offset(offset)
|
||||||
|
)
|
||||||
|
files = result.scalars().all()
|
||||||
|
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=FileResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upload_file(
|
||||||
|
file: UploadFile = FastAPIFile(...),
|
||||||
|
folder_id: Optional[int] = None,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Upload a file"""
|
||||||
|
# Read file content
|
||||||
|
content = await file.read()
|
||||||
|
file_size = len(content)
|
||||||
|
|
||||||
|
# Generate unique filename
|
||||||
|
extension = file.filename.split('.')[-1] if '.' in file.filename else ''
|
||||||
|
unique_name = f"{secrets.token_hex(16)}.{extension}" if extension else secrets.token_hex(16)
|
||||||
|
|
||||||
|
# In production, upload to S3 here
|
||||||
|
storage_key = f"uploads/{current_user.id}/{unique_name}"
|
||||||
|
storage_url = f"https://storage.blackroad.com/{storage_key}" # Placeholder
|
||||||
|
|
||||||
|
# Get folder path if specified
|
||||||
|
path = f"/{file.filename}"
|
||||||
|
if folder_id:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Folder).where(
|
||||||
|
and_(
|
||||||
|
Folder.id == folder_id,
|
||||||
|
Folder.user_id == current_user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
folder = result.scalar_one_or_none()
|
||||||
|
if folder:
|
||||||
|
path = f"{folder.path}/{file.filename}"
|
||||||
|
|
||||||
|
file_record = File(
|
||||||
|
user_id=current_user.id,
|
||||||
|
folder_id=folder_id,
|
||||||
|
name=unique_name,
|
||||||
|
original_name=file.filename,
|
||||||
|
path=path,
|
||||||
|
file_type=file.content_type,
|
||||||
|
extension=extension,
|
||||||
|
size=file_size,
|
||||||
|
storage_key=storage_key,
|
||||||
|
storage_url=storage_url
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(file_record)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(file_record)
|
||||||
|
|
||||||
|
return file_record
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{file_id}", response_model=FileResponse)
|
||||||
|
async def get_file(
|
||||||
|
file_id: int,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get a file"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(File).where(
|
||||||
|
and_(
|
||||||
|
File.id == file_id,
|
||||||
|
or_(
|
||||||
|
File.user_id == current_user.id,
|
||||||
|
File.is_public == True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
file = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not file:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="File not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update last accessed
|
||||||
|
file.last_accessed = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return file
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{file_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_file(
|
||||||
|
file_id: int,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Delete a file"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(File).where(
|
||||||
|
and_(
|
||||||
|
File.id == file_id,
|
||||||
|
File.user_id == current_user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
file = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not file:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="File not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# In production, delete from S3 here
|
||||||
|
|
||||||
|
await db.delete(file)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{file_id}/share")
|
||||||
|
async def share_file(
|
||||||
|
file_id: int,
|
||||||
|
is_public: bool = False,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Share a file"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(File).where(
|
||||||
|
and_(
|
||||||
|
File.id == file_id,
|
||||||
|
File.user_id == current_user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
file = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not file:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="File not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
file.is_shared = True
|
||||||
|
file.is_public = is_public
|
||||||
|
|
||||||
|
if not file.share_token:
|
||||||
|
file.share_token = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"share_token": file.share_token,
|
||||||
|
"share_url": f"https://blackroad.com/files/shared/{file.share_token}"
|
||||||
|
}
|
||||||
307
backend/app/routers/social.py
Normal file
307
backend/app/routers/social.py
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
"""Social media (BlackRoad Social) routes"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, and_, or_, desc, func
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.social import Post, Comment, Like, Follow
|
||||||
|
from app.auth import get_current_active_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/social", tags=["Social"])
|
||||||
|
|
||||||
|
|
||||||
|
class PostCreate(BaseModel):
|
||||||
|
content: str
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
video_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PostResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
avatar_url: Optional[str]
|
||||||
|
content: str
|
||||||
|
image_url: Optional[str]
|
||||||
|
video_url: Optional[str]
|
||||||
|
likes_count: int
|
||||||
|
comments_count: int
|
||||||
|
shares_count: int
|
||||||
|
created_at: datetime
|
||||||
|
is_liked: bool = False
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class CommentCreate(BaseModel):
|
||||||
|
content: str
|
||||||
|
parent_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CommentResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
avatar_url: Optional[str]
|
||||||
|
content: str
|
||||||
|
likes_count: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feed", response_model=List[PostResponse])
|
||||||
|
async def get_feed(
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0
|
||||||
|
):
|
||||||
|
"""Get social media feed"""
|
||||||
|
# Get posts from followed users + own posts
|
||||||
|
result = await db.execute(
|
||||||
|
select(Post, User)
|
||||||
|
.join(User, Post.user_id == User.id)
|
||||||
|
.where(Post.is_public == True)
|
||||||
|
.order_by(desc(Post.created_at))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
posts_with_users = result.all()
|
||||||
|
|
||||||
|
# Check which posts current user has liked
|
||||||
|
post_ids = [post.id for post, _ in posts_with_users]
|
||||||
|
liked_result = await db.execute(
|
||||||
|
select(Like.post_id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Like.user_id == current_user.id,
|
||||||
|
Like.post_id.in_(post_ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
liked_post_ids = {row[0] for row in liked_result.all()}
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
feed = []
|
||||||
|
for post, user in posts_with_users:
|
||||||
|
feed.append(PostResponse(
|
||||||
|
id=post.id,
|
||||||
|
user_id=post.user_id,
|
||||||
|
username=user.username,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
content=post.content,
|
||||||
|
image_url=post.image_url,
|
||||||
|
video_url=post.video_url,
|
||||||
|
likes_count=post.likes_count,
|
||||||
|
comments_count=post.comments_count,
|
||||||
|
shares_count=post.shares_count,
|
||||||
|
created_at=post.created_at,
|
||||||
|
is_liked=post.id in liked_post_ids
|
||||||
|
))
|
||||||
|
|
||||||
|
return feed
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/posts", response_model=PostResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_post(
|
||||||
|
post_data: PostCreate,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Create a new post"""
|
||||||
|
post = Post(
|
||||||
|
user_id=current_user.id,
|
||||||
|
content=post_data.content,
|
||||||
|
image_url=post_data.image_url,
|
||||||
|
video_url=post_data.video_url,
|
||||||
|
is_public=True
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(post)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(post)
|
||||||
|
|
||||||
|
return PostResponse(
|
||||||
|
id=post.id,
|
||||||
|
user_id=post.user_id,
|
||||||
|
username=current_user.username,
|
||||||
|
avatar_url=current_user.avatar_url,
|
||||||
|
content=post.content,
|
||||||
|
image_url=post.image_url,
|
||||||
|
video_url=post.video_url,
|
||||||
|
likes_count=0,
|
||||||
|
comments_count=0,
|
||||||
|
shares_count=0,
|
||||||
|
created_at=post.created_at,
|
||||||
|
is_liked=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/posts/{post_id}/like")
|
||||||
|
async def like_post(
|
||||||
|
post_id: int,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Like a post"""
|
||||||
|
# Check if post exists
|
||||||
|
result = await db.execute(select(Post).where(Post.id == post_id))
|
||||||
|
post = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Post not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if already liked
|
||||||
|
result = await db.execute(
|
||||||
|
select(Like).where(
|
||||||
|
and_(
|
||||||
|
Like.user_id == current_user.id,
|
||||||
|
Like.post_id == post_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_like = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_like:
|
||||||
|
# Unlike
|
||||||
|
await db.delete(existing_like)
|
||||||
|
post.likes_count = max(0, post.likes_count - 1)
|
||||||
|
await db.commit()
|
||||||
|
return {"liked": False, "likes_count": post.likes_count}
|
||||||
|
else:
|
||||||
|
# Like
|
||||||
|
like = Like(user_id=current_user.id, post_id=post_id)
|
||||||
|
db.add(like)
|
||||||
|
post.likes_count += 1
|
||||||
|
await db.commit()
|
||||||
|
return {"liked": True, "likes_count": post.likes_count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/posts/{post_id}/comments", response_model=List[CommentResponse])
|
||||||
|
async def get_comments(
|
||||||
|
post_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0
|
||||||
|
):
|
||||||
|
"""Get comments for a post"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Comment, User)
|
||||||
|
.join(User, Comment.user_id == User.id)
|
||||||
|
.where(Comment.post_id == post_id)
|
||||||
|
.order_by(Comment.created_at.asc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
comments_with_users = result.all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
CommentResponse(
|
||||||
|
id=comment.id,
|
||||||
|
user_id=comment.user_id,
|
||||||
|
username=user.username,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
content=comment.content,
|
||||||
|
likes_count=comment.likes_count,
|
||||||
|
created_at=comment.created_at
|
||||||
|
)
|
||||||
|
for comment, user in comments_with_users
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/posts/{post_id}/comments", response_model=CommentResponse)
|
||||||
|
async def create_comment(
|
||||||
|
post_id: int,
|
||||||
|
comment_data: CommentCreate,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Add a comment to a post"""
|
||||||
|
# Check if post exists
|
||||||
|
result = await db.execute(select(Post).where(Post.id == post_id))
|
||||||
|
post = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Post not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
comment = Comment(
|
||||||
|
post_id=post_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
content=comment_data.content,
|
||||||
|
parent_id=comment_data.parent_id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(comment)
|
||||||
|
post.comments_count += 1
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(comment)
|
||||||
|
|
||||||
|
return CommentResponse(
|
||||||
|
id=comment.id,
|
||||||
|
user_id=comment.user_id,
|
||||||
|
username=current_user.username,
|
||||||
|
avatar_url=current_user.avatar_url,
|
||||||
|
content=comment.content,
|
||||||
|
likes_count=0,
|
||||||
|
created_at=comment.created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/follow")
|
||||||
|
async def follow_user(
|
||||||
|
user_id: int,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Follow a user"""
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot follow yourself"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if user exists
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if already following
|
||||||
|
result = await db.execute(
|
||||||
|
select(Follow).where(
|
||||||
|
and_(
|
||||||
|
Follow.follower_id == current_user.id,
|
||||||
|
Follow.following_id == user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_follow = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_follow:
|
||||||
|
# Unfollow
|
||||||
|
await db.delete(existing_follow)
|
||||||
|
await db.commit()
|
||||||
|
return {"following": False}
|
||||||
|
else:
|
||||||
|
# Follow
|
||||||
|
follow = Follow(follower_id=current_user.id, following_id=user_id)
|
||||||
|
db.add(follow)
|
||||||
|
await db.commit()
|
||||||
|
return {"following": True}
|
||||||
277
backend/app/routers/video.py
Normal file
277
backend/app/routers/video.py
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"""Video streaming (BlackStream) routes"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File as FastAPIFile
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, and_, desc
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.video import Video, VideoView, VideoLike
|
||||||
|
from app.auth import get_current_active_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/videos", tags=["Videos"])
|
||||||
|
|
||||||
|
|
||||||
|
class VideoCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
video_url: str
|
||||||
|
thumbnail_url: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
tags: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class VideoResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
avatar_url: Optional[str]
|
||||||
|
title: str
|
||||||
|
description: Optional[str]
|
||||||
|
thumbnail_url: Optional[str]
|
||||||
|
video_url: str
|
||||||
|
duration: Optional[int]
|
||||||
|
views_count: int
|
||||||
|
likes_count: int
|
||||||
|
dislikes_count: int
|
||||||
|
comments_count: int
|
||||||
|
is_public: bool
|
||||||
|
created_at: datetime
|
||||||
|
is_liked: Optional[bool] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[VideoResponse])
|
||||||
|
async def get_videos(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
category: Optional[str] = None,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get videos"""
|
||||||
|
query = select(Video, User).join(User, Video.user_id == User.id).where(Video.is_public == True)
|
||||||
|
|
||||||
|
if category:
|
||||||
|
query = query.where(Video.category == category)
|
||||||
|
|
||||||
|
query = query.order_by(desc(Video.created_at)).limit(limit).offset(offset)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
videos_with_users = result.all()
|
||||||
|
|
||||||
|
# Check which videos current user has liked
|
||||||
|
video_ids = [video.id for video, _ in videos_with_users]
|
||||||
|
liked_result = await db.execute(
|
||||||
|
select(VideoLike)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
VideoLike.user_id == current_user.id,
|
||||||
|
VideoLike.video_id.in_(video_ids),
|
||||||
|
VideoLike.is_like == True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
liked_video_ids = {like.video_id for like in liked_result.scalars().all()}
|
||||||
|
|
||||||
|
return [
|
||||||
|
VideoResponse(
|
||||||
|
id=video.id,
|
||||||
|
user_id=video.user_id,
|
||||||
|
username=user.username,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
title=video.title,
|
||||||
|
description=video.description,
|
||||||
|
thumbnail_url=video.thumbnail_url,
|
||||||
|
video_url=video.video_url,
|
||||||
|
duration=video.duration,
|
||||||
|
views_count=video.views_count,
|
||||||
|
likes_count=video.likes_count,
|
||||||
|
dislikes_count=video.dislikes_count,
|
||||||
|
comments_count=video.comments_count,
|
||||||
|
is_public=video.is_public,
|
||||||
|
created_at=video.created_at,
|
||||||
|
is_liked=video.id in liked_video_ids
|
||||||
|
)
|
||||||
|
for video, user in videos_with_users
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=VideoResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upload_video(
|
||||||
|
video_data: VideoCreate,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Upload a video"""
|
||||||
|
video = Video(
|
||||||
|
user_id=current_user.id,
|
||||||
|
title=video_data.title,
|
||||||
|
description=video_data.description,
|
||||||
|
video_url=video_data.video_url,
|
||||||
|
thumbnail_url=video_data.thumbnail_url,
|
||||||
|
category=video_data.category,
|
||||||
|
tags=video_data.tags,
|
||||||
|
is_public=True,
|
||||||
|
published_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(video)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(video)
|
||||||
|
|
||||||
|
return VideoResponse(
|
||||||
|
id=video.id,
|
||||||
|
user_id=video.user_id,
|
||||||
|
username=current_user.username,
|
||||||
|
avatar_url=current_user.avatar_url,
|
||||||
|
title=video.title,
|
||||||
|
description=video.description,
|
||||||
|
thumbnail_url=video.thumbnail_url,
|
||||||
|
video_url=video.video_url,
|
||||||
|
duration=video.duration,
|
||||||
|
views_count=0,
|
||||||
|
likes_count=0,
|
||||||
|
dislikes_count=0,
|
||||||
|
comments_count=0,
|
||||||
|
is_public=True,
|
||||||
|
created_at=video.created_at,
|
||||||
|
is_liked=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{video_id}", response_model=VideoResponse)
|
||||||
|
async def get_video(
|
||||||
|
video_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Get a specific video"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Video, User)
|
||||||
|
.join(User, Video.user_id == User.id)
|
||||||
|
.where(Video.id == video_id)
|
||||||
|
)
|
||||||
|
video_with_user = result.first()
|
||||||
|
|
||||||
|
if not video_with_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Video not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
video, user = video_with_user
|
||||||
|
|
||||||
|
# Record view
|
||||||
|
view = VideoView(
|
||||||
|
video_id=video.id,
|
||||||
|
user_id=current_user.id
|
||||||
|
)
|
||||||
|
db.add(view)
|
||||||
|
video.views_count += 1
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Check if liked
|
||||||
|
liked_result = await db.execute(
|
||||||
|
select(VideoLike)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
VideoLike.user_id == current_user.id,
|
||||||
|
VideoLike.video_id == video_id,
|
||||||
|
VideoLike.is_like == True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is_liked = liked_result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
return VideoResponse(
|
||||||
|
id=video.id,
|
||||||
|
user_id=video.user_id,
|
||||||
|
username=user.username,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
title=video.title,
|
||||||
|
description=video.description,
|
||||||
|
thumbnail_url=video.thumbnail_url,
|
||||||
|
video_url=video.video_url,
|
||||||
|
duration=video.duration,
|
||||||
|
views_count=video.views_count,
|
||||||
|
likes_count=video.likes_count,
|
||||||
|
dislikes_count=video.dislikes_count,
|
||||||
|
comments_count=video.comments_count,
|
||||||
|
is_public=video.is_public,
|
||||||
|
created_at=video.created_at,
|
||||||
|
is_liked=is_liked
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{video_id}/like")
|
||||||
|
async def like_video(
|
||||||
|
video_id: int,
|
||||||
|
is_like: bool = True,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Like or dislike a video"""
|
||||||
|
# Check if video exists
|
||||||
|
result = await db.execute(select(Video).where(Video.id == video_id))
|
||||||
|
video = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not video:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Video not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if already liked/disliked
|
||||||
|
result = await db.execute(
|
||||||
|
select(VideoLike).where(
|
||||||
|
and_(
|
||||||
|
VideoLike.user_id == current_user.id,
|
||||||
|
VideoLike.video_id == video_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_like = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_like:
|
||||||
|
# Update or remove
|
||||||
|
if existing_like.is_like == is_like:
|
||||||
|
# Remove like/dislike
|
||||||
|
await db.delete(existing_like)
|
||||||
|
if is_like:
|
||||||
|
video.likes_count = max(0, video.likes_count - 1)
|
||||||
|
else:
|
||||||
|
video.dislikes_count = max(0, video.dislikes_count - 1)
|
||||||
|
else:
|
||||||
|
# Change from like to dislike or vice versa
|
||||||
|
existing_like.is_like = is_like
|
||||||
|
if is_like:
|
||||||
|
video.likes_count += 1
|
||||||
|
video.dislikes_count = max(0, video.dislikes_count - 1)
|
||||||
|
else:
|
||||||
|
video.dislikes_count += 1
|
||||||
|
video.likes_count = max(0, video.likes_count - 1)
|
||||||
|
else:
|
||||||
|
# New like/dislike
|
||||||
|
like = VideoLike(
|
||||||
|
user_id=current_user.id,
|
||||||
|
video_id=video_id,
|
||||||
|
is_like=is_like
|
||||||
|
)
|
||||||
|
db.add(like)
|
||||||
|
if is_like:
|
||||||
|
video.likes_count += 1
|
||||||
|
else:
|
||||||
|
video.dislikes_count += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"liked": is_like if existing_like is None or existing_like.is_like != is_like else None,
|
||||||
|
"likes_count": video.likes_count,
|
||||||
|
"dislikes_count": video.dislikes_count
|
||||||
|
}
|
||||||
1
backend/app/schemas/__init__.py
Normal file
1
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Pydantic schemas for request/response validation"""
|
||||||
57
backend/app/schemas/user.py
Normal file
57
backend/app/schemas/user.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""User schemas"""
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
"""Base user schema"""
|
||||||
|
username: str = Field(..., min_length=3, max_length=50)
|
||||||
|
email: EmailStr
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
"""User creation schema"""
|
||||||
|
password: str = Field(..., min_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
"""User login schema"""
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
"""User update schema"""
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
bio: Optional[str] = None
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(UserBase):
|
||||||
|
"""User response schema"""
|
||||||
|
id: int
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
bio: Optional[str] = None
|
||||||
|
is_active: bool
|
||||||
|
is_verified: bool
|
||||||
|
wallet_address: Optional[str] = None
|
||||||
|
balance: float
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
"""JWT token response"""
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
"""Token payload data"""
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
username: Optional[str] = None
|
||||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Business logic services"""
|
||||||
167
backend/app/services/blockchain.py
Normal file
167
backend/app/services/blockchain.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""Blockchain service"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
from app.models.blockchain import Block, Transaction, Wallet
|
||||||
|
from app.models.user import User
|
||||||
|
from app.config import settings
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
|
||||||
|
class BlockchainService:
|
||||||
|
"""Blockchain service for RoadCoin"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_hash(index: int, timestamp: str, previous_hash: str,
|
||||||
|
transactions: List[dict], nonce: int) -> str:
|
||||||
|
"""Calculate block hash"""
|
||||||
|
data = f"{index}{timestamp}{previous_hash}{json.dumps(transactions)}{nonce}"
|
||||||
|
return hashlib.sha256(data.encode()).hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def create_genesis_block(db: AsyncSession) -> Block:
|
||||||
|
"""Create the genesis block"""
|
||||||
|
result = await db.execute(select(Block).where(Block.index == 0))
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
timestamp = datetime.utcnow()
|
||||||
|
genesis_hash = BlockchainService.calculate_hash(0, str(timestamp), "0", [], 0)
|
||||||
|
|
||||||
|
genesis_block = Block(
|
||||||
|
index=0,
|
||||||
|
timestamp=timestamp,
|
||||||
|
nonce=0,
|
||||||
|
previous_hash="0",
|
||||||
|
hash=genesis_hash,
|
||||||
|
difficulty=settings.BLOCKCHAIN_DIFFICULTY,
|
||||||
|
reward=0,
|
||||||
|
transaction_count=0,
|
||||||
|
is_valid=True
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(genesis_block)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(genesis_block)
|
||||||
|
|
||||||
|
return genesis_block
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_latest_block(db: AsyncSession) -> Optional[Block]:
|
||||||
|
"""Get the latest block in the chain"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Block).order_by(desc(Block.index)).limit(1)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def mine_block(db: AsyncSession, user: User, transactions: List[Transaction]) -> Block:
|
||||||
|
"""Mine a new block"""
|
||||||
|
latest_block = await BlockchainService.get_latest_block(db)
|
||||||
|
|
||||||
|
if not latest_block:
|
||||||
|
latest_block = await BlockchainService.create_genesis_block(db)
|
||||||
|
|
||||||
|
new_index = latest_block.index + 1
|
||||||
|
timestamp = datetime.utcnow()
|
||||||
|
previous_hash = latest_block.hash
|
||||||
|
difficulty = settings.BLOCKCHAIN_DIFFICULTY
|
||||||
|
|
||||||
|
# Convert transactions to dict for hashing
|
||||||
|
tx_data = [
|
||||||
|
{
|
||||||
|
"from": tx.from_address,
|
||||||
|
"to": tx.to_address,
|
||||||
|
"amount": tx.amount
|
||||||
|
}
|
||||||
|
for tx in transactions
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mining (proof of work)
|
||||||
|
nonce = 0
|
||||||
|
block_hash = ""
|
||||||
|
target = "0" * difficulty
|
||||||
|
|
||||||
|
while not block_hash.startswith(target):
|
||||||
|
nonce += 1
|
||||||
|
block_hash = BlockchainService.calculate_hash(
|
||||||
|
new_index, str(timestamp), previous_hash, tx_data, nonce
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new block
|
||||||
|
new_block = Block(
|
||||||
|
index=new_index,
|
||||||
|
timestamp=timestamp,
|
||||||
|
nonce=nonce,
|
||||||
|
previous_hash=previous_hash,
|
||||||
|
hash=block_hash,
|
||||||
|
miner_id=user.id,
|
||||||
|
miner_address=user.wallet_address,
|
||||||
|
difficulty=difficulty,
|
||||||
|
reward=settings.MINING_REWARD,
|
||||||
|
transaction_count=len(transactions),
|
||||||
|
is_valid=True
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_block)
|
||||||
|
|
||||||
|
# Update transaction confirmations
|
||||||
|
for tx in transactions:
|
||||||
|
tx.block_id = new_block.id
|
||||||
|
tx.block_index = new_block.index
|
||||||
|
tx.is_confirmed = True
|
||||||
|
tx.confirmations = 1
|
||||||
|
tx.confirmed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# Reward miner
|
||||||
|
user.balance += settings.MINING_REWARD
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_block)
|
||||||
|
|
||||||
|
return new_block
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_wallet_address() -> tuple[str, str]:
|
||||||
|
"""Generate a new wallet address and private key"""
|
||||||
|
private_key = secrets.token_hex(32)
|
||||||
|
public_key = hashlib.sha256(private_key.encode()).hexdigest()
|
||||||
|
address = "RD" + hashlib.sha256(public_key.encode()).hexdigest()[:38]
|
||||||
|
return address, private_key
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def create_transaction(
|
||||||
|
db: AsyncSession,
|
||||||
|
from_address: str,
|
||||||
|
to_address: str,
|
||||||
|
amount: float,
|
||||||
|
private_key: str
|
||||||
|
) -> Transaction:
|
||||||
|
"""Create a new transaction"""
|
||||||
|
# Generate transaction hash
|
||||||
|
tx_data = f"{from_address}{to_address}{amount}{datetime.utcnow()}"
|
||||||
|
transaction_hash = hashlib.sha256(tx_data.encode()).hexdigest()
|
||||||
|
|
||||||
|
# Sign transaction (simplified)
|
||||||
|
signature = hashlib.sha256(f"{transaction_hash}{private_key}".encode()).hexdigest()
|
||||||
|
|
||||||
|
transaction = Transaction(
|
||||||
|
transaction_hash=transaction_hash,
|
||||||
|
from_address=from_address,
|
||||||
|
to_address=to_address,
|
||||||
|
amount=amount,
|
||||||
|
signature=signature,
|
||||||
|
is_confirmed=False,
|
||||||
|
confirmations=0
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(transaction)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(transaction)
|
||||||
|
|
||||||
|
return transaction
|
||||||
69
backend/docker-compose.yml
Normal file
69
backend/docker-compose.yml
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL Database
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: blackroad_postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: blackroad
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
POSTGRES_DB: blackroad_db
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U blackroad"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Redis Cache
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: blackroad_redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# FastAPI Backend
|
||||||
|
backend:
|
||||||
|
build: .
|
||||||
|
container_name: blackroad_backend
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://blackroad:password@postgres:5432/blackroad_db
|
||||||
|
DATABASE_ASYNC_URL: postgresql+asyncpg://blackroad:password@postgres:5432/blackroad_db
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
SECRET_KEY: ${SECRET_KEY:-your-secret-key-change-this}
|
||||||
|
ENVIRONMENT: ${ENVIRONMENT:-development}
|
||||||
|
DEBUG: ${DEBUG:-True}
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./app:/app/app
|
||||||
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
# Adminer (Database Admin UI)
|
||||||
|
adminer:
|
||||||
|
image: adminer:latest
|
||||||
|
container_name: blackroad_adminer
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
58
backend/requirements.txt
Normal file
58
backend/requirements.txt
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# FastAPI and Server
|
||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
python-multipart==0.0.6
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlalchemy==2.0.23
|
||||||
|
alembic==1.12.1
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
asyncpg==0.29.0
|
||||||
|
|
||||||
|
# Authentication & Security
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
bcrypt==4.1.1
|
||||||
|
pyjwt==2.8.0
|
||||||
|
|
||||||
|
# Redis Cache
|
||||||
|
redis==5.0.1
|
||||||
|
hiredis==2.2.3
|
||||||
|
|
||||||
|
# AWS S3 for File Storage
|
||||||
|
boto3==1.29.7
|
||||||
|
botocore==1.32.7
|
||||||
|
|
||||||
|
# Email
|
||||||
|
python-email-validator==2.1.0
|
||||||
|
emails==0.6.0
|
||||||
|
jinja2==3.1.2
|
||||||
|
|
||||||
|
# WebSockets
|
||||||
|
websockets==12.0
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
pydantic==2.5.0
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
|
||||||
|
# HTTP Client
|
||||||
|
httpx==0.25.2
|
||||||
|
aiohttp==3.9.1
|
||||||
|
|
||||||
|
# Crypto/Blockchain
|
||||||
|
cryptography==41.0.7
|
||||||
|
ecdsa==0.18.0
|
||||||
|
hashlib-additional==1.0.0
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest==7.4.3
|
||||||
|
pytest-asyncio==0.21.1
|
||||||
|
pytest-cov==4.1.0
|
||||||
|
httpx==0.25.2
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
prometheus-client==0.19.0
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
python-cors==1.0.0
|
||||||
11
backend/run.py
Normal file
11
backend/run.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""Run the application"""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(
|
||||||
|
"app.main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8000,
|
||||||
|
reload=True,
|
||||||
|
log_level="info"
|
||||||
|
)
|
||||||
1
backend/tests/__init__.py
Normal file
1
backend/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for BlackRoad OS Backend"""
|
||||||
85
backend/tests/conftest.py
Normal file
85
backend/tests/conftest.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""Pytest configuration and fixtures"""
|
||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.database import get_db, Base
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
# Test database URL
|
||||||
|
TEST_DATABASE_URL = "postgresql+asyncpg://blackroad:password@localhost:5432/blackroad_test"
|
||||||
|
|
||||||
|
# Create test engine
|
||||||
|
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||||
|
TestSessionLocal = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def event_loop():
|
||||||
|
"""Create event loop for tests"""
|
||||||
|
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||||
|
yield loop
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
async def db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Create test database session"""
|
||||||
|
async with test_engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
async with TestSessionLocal() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
async with test_engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||||
|
"""Create test client"""
|
||||||
|
async def override_get_db():
|
||||||
|
yield db_session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
|
||||||
|
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_user(client: AsyncClient):
|
||||||
|
"""Create a test user"""
|
||||||
|
user_data = {
|
||||||
|
"username": "testuser",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "testpassword123",
|
||||||
|
"full_name": "Test User"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post("/api/auth/register", json=user_data)
|
||||||
|
assert response.status_code == 201
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def auth_headers(client: AsyncClient, test_user):
|
||||||
|
"""Get authentication headers"""
|
||||||
|
login_data = {
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "testpassword123"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
data=login_data
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
token = response.json()["access_token"]
|
||||||
|
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
83
backend/tests/test_auth.py
Normal file
83
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""Authentication tests"""
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_user(client: AsyncClient):
|
||||||
|
"""Test user registration"""
|
||||||
|
user_data = {
|
||||||
|
"username": "newuser",
|
||||||
|
"email": "newuser@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"full_name": "New User"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post("/api/auth/register", json=user_data)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
assert data["username"] == "newuser"
|
||||||
|
assert data["email"] == "newuser@example.com"
|
||||||
|
assert "wallet_address" in data
|
||||||
|
assert data["balance"] == 100.0 # Starting bonus
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_duplicate_user(client: AsyncClient, test_user):
|
||||||
|
"""Test registering duplicate user"""
|
||||||
|
user_data = {
|
||||||
|
"username": "testuser",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post("/api/auth/register", json=user_data)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login(client: AsyncClient, test_user):
|
||||||
|
"""Test user login"""
|
||||||
|
login_data = {
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "testpassword123"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post("/api/auth/login", data=login_data)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert "refresh_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_invalid_credentials(client: AsyncClient, test_user):
|
||||||
|
"""Test login with invalid credentials"""
|
||||||
|
login_data = {
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "wrongpassword"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post("/api/auth/login", data=login_data)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_current_user(client: AsyncClient, auth_headers):
|
||||||
|
"""Test getting current user info"""
|
||||||
|
response = await client.get("/api/auth/me", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
assert data["username"] == "testuser"
|
||||||
|
assert data["email"] == "test@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_current_user_unauthorized(client: AsyncClient):
|
||||||
|
"""Test getting current user without token"""
|
||||||
|
response = await client.get("/api/auth/me")
|
||||||
|
assert response.status_code == 401
|
||||||
51
backend/tests/test_blockchain.py
Normal file
51
backend/tests/test_blockchain.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Blockchain tests"""
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_wallet(client: AsyncClient, auth_headers):
|
||||||
|
"""Test getting user wallet"""
|
||||||
|
response = await client.get("/api/blockchain/wallet", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
assert "address" in data
|
||||||
|
assert "balance" in data
|
||||||
|
assert data["balance"] == 100.0 # Starting balance
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_balance(client: AsyncClient, auth_headers):
|
||||||
|
"""Test getting wallet balance"""
|
||||||
|
response = await client.get("/api/blockchain/balance", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
assert "address" in data
|
||||||
|
assert "balance" in data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_blockchain_stats(client: AsyncClient):
|
||||||
|
"""Test getting blockchain stats"""
|
||||||
|
response = await client.get("/api/blockchain/stats")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
assert "latest_block_index" in data
|
||||||
|
assert "total_blocks" in data
|
||||||
|
assert "difficulty" in data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mine_block(client: AsyncClient, auth_headers):
|
||||||
|
"""Test mining a block"""
|
||||||
|
response = await client.post("/api/blockchain/mine", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
assert "index" in data
|
||||||
|
assert "hash" in data
|
||||||
|
assert "reward" in data
|
||||||
|
assert data["reward"] == 50.0 # Mining reward
|
||||||
Reference in New Issue
Block a user