commit 467cf313e459238e5eea9c730f9383f71bfb920d Author: ɧσℓσ Date: Mon Dec 1 22:22:15 2025 +0100 Initial commit: API Anime Tracker avec authentification et synchronisation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8732df9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +ENV/ +env/ +.venv + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment variables +.env +.env.local + +# Database +*.db +*.sqlite +*.sqlite3 +data/ + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# Docker +.dockerignore + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..467e711 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Installer les dépendances système +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copier les fichiers de dépendances +COPY requirements.txt . + +# Installer les dépendances Python +RUN pip install --no-cache-dir -r requirements.txt + +# Copier le code de l'application +COPY . . + +# Exposer le port +EXPOSE 8000 + +# Commande pour démarrer l'application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ba9680 --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# API Anime Tracker + +API de synchronisation pour l'extension Chrome Anime Tracker, permettant de synchroniser l'historique des animés sur plusieurs appareils. + +## Fonctionnalités + +- ✅ Authentification JWT (inscription, connexion) +- ✅ Synchronisation des animés entre appareils +- ✅ CRUD complet pour les animés +- ✅ Endpoint de synchronisation en masse +- ✅ Base de données PostgreSQL (service externe) + +## Configuration + +### Variables d'environnement + +Créez un fichier `.env` à partir de `env.example` : + +```bash +cp env.example .env +``` + +Éditez `.env` et configurez : + +- `SECRET_KEY`: Clé secrète pour JWT (minimum 32 caractères, générer une clé aléatoire) +- `DATABASE_URL`: URL de connexion PostgreSQL (format: `postgresql://user:password@host:port/database`) + +### Exemple de configuration + +```env +SECRET_KEY=votre-cle-secrete-tres-longue-et-aleatoire-minimum-32-caracteres +DATABASE_URL=postgresql://username:password@postgres.holo795.fr:5432/anime_tracker +``` + +## Installation avec Docker + +### 1. Lancer avec Docker Compose + +```bash +docker-compose up -d +``` + +L'API sera accessible sur `https://anime-tracker.holo795.fr` (ou le port configuré) + +### 2. Vérifier le statut + +```bash +docker-compose ps +docker-compose logs -f api +``` + +## Développement local (sans Docker) + +```bash +# Créer un environnement virtuel +python -m venv venv +source venv/bin/activate # Sur Windows: venv\Scripts\activate + +# Installer les dépendances +pip install -r requirements.txt + +# Lancer l'API +uvicorn main:app --reload --host 0.0.0.0 --port 8000 +``` + +## Documentation API + +Une fois l'API lancée, accédez à : +- **Swagger UI**: https://anime-tracker.holo795.fr/docs +- **ReDoc**: https://anime-tracker.holo795.fr/redoc + +## Endpoints principaux + +### Authentification + +- `POST /api/auth/register` - Inscription + ```json + { + "username": "mon_username", + "email": "email@example.com", + "password": "motdepasse123" + } + ``` + +- `POST /api/auth/login` - Connexion + ```json + { + "email_or_username": "email@example.com", + "password": "motdepasse123" + } + ``` + +- `GET /api/auth/me` - Informations utilisateur (nécessite authentification) + +### Animés + +- `GET /api/animes` - Liste des animés (nécessite authentification) +- `POST /api/animes` - Créer/mettre à jour un animé (nécessite authentification) +- `PUT /api/animes/{anime_id}` - Mettre à jour un animé (nécessite authentification) +- `DELETE /api/animes/{anime_id}` - Supprimer un animé (nécessite authentification) +- `POST /api/animes/sync` - Synchroniser plusieurs animés (nécessite authentification) + +### Santé + +- `GET /api/health` - Vérification de l'état + +## Production + +### Déploiement + +1. Configurez les variables d'environnement sur votre serveur +2. Assurez-vous que PostgreSQL est accessible depuis le conteneur Docker +3. Utilisez un reverse proxy (nginx) avec HTTPS +4. Configurez les certificats SSL + +### Sécurité + +- ✅ Utilisez une `SECRET_KEY` forte et unique +- ✅ Utilisez HTTPS en production +- ✅ Configurez CORS pour limiter les origines autorisées +- ✅ Utilisez un mot de passe fort pour PostgreSQL +- ✅ Limitez les connexions réseau à PostgreSQL + +### Exemple de configuration nginx + +```nginx +server { + listen 443 ssl; + server_name anime-tracker.holo795.fr; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://localhost:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +## Base de données + +### Création de la base de données PostgreSQL + +```sql +CREATE DATABASE anime_tracker; +CREATE USER anime_user WITH PASSWORD 'votre_mot_de_passe'; +GRANT ALL PRIVILEGES ON DATABASE anime_tracker TO anime_user; +``` + +Les tables seront créées automatiquement au premier démarrage de l'API. + +### Migration + +Si vous devez migrer depuis SQLite vers PostgreSQL : + +1. Exportez les données depuis SQLite +2. Importez-les dans PostgreSQL +3. Mettez à jour `DATABASE_URL` dans `.env` diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..4c16221 --- /dev/null +++ b/auth.py @@ -0,0 +1,94 @@ +""" +Fonctions d'authentification et de sécurité +""" +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 HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +import os +from dotenv import load_dotenv + +from database import get_db +from models import User + +load_dotenv() + +# Configuration JWT +SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production-min-32-chars") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 * 24 * 60 # 30 jours + +# Configuration du hachage de mot de passe +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +security = HTTPBearer() + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Vérifier un mot de passe""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """Hacher un mot de passe""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """Créer un token JWT""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def verify_token(token: str) -> Optional[dict]: + """Vérifier et décoder un token JWT""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + return None + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +) -> User: + """Obtenir l'utilisateur actuel à partir du token JWT""" + token = credentials.credentials + payload = verify_token(token) + + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token invalide ou expiré", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_id: str = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token invalide", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user = db.query(User).filter(User.id == int(user_id)).first() + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Utilisateur non trouvé", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return user + diff --git a/database.py b/database.py new file mode 100644 index 0000000..c34fc3a --- /dev/null +++ b/database.py @@ -0,0 +1,52 @@ +""" +Configuration de la base de données +""" +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +import os +from dotenv import load_dotenv + +load_dotenv() + +# URL de la base de données (PostgreSQL en production) +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://user:password@localhost:5432/anime_tracker") + +# Configuration du moteur SQLAlchemy +if DATABASE_URL.startswith("sqlite"): + # SQLite pour développement local + engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False} + ) +elif DATABASE_URL.startswith("postgresql"): + # PostgreSQL avec pool de connexions + engine = create_engine( + DATABASE_URL, + pool_pre_ping=True, # Vérifier les connexions avant utilisation + pool_recycle=3600, # Recycler les connexions après 1 heure + pool_size=10, # Taille du pool de connexions + max_overflow=20 # Nombre maximum de connexions supplémentaires + ) +else: + engine = create_engine(DATABASE_URL) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +def get_db(): + """Dépendance pour obtenir une session de base de données""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """Initialiser la base de données (créer les tables)""" + from models import User, Anime + Base.metadata.create_all(bind=engine) + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..70b4d19 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3.8' + +services: + api: + build: . + container_name: anime_tracker_api + ports: + - "8000" + environment: + - DATABASE_URL=${DATABASE_URL:-postgresql://user:password@host:5432/anime_tracker} + - SECRET_KEY=${SECRET_KEY:-change-this-secret-key-in-production-min-32-characters-long} + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + diff --git a/env.example b/env.example new file mode 100644 index 0000000..a5ff0b8 --- /dev/null +++ b/env.example @@ -0,0 +1,8 @@ +# Clé secrète pour JWT (générer une clé aléatoire de 32+ caractères) +SECRET_KEY=your-secret-key-change-in-production-min-32-chars + +# URL de la base de données PostgreSQL +# Format: postgresql://user:password@host:port/database +# Exemple avec service externe: +DATABASE_URL=postgresql://username:password@postgres.holo795.fr:5432/anime_tracker + diff --git a/main.py b/main.py new file mode 100644 index 0000000..d48db97 --- /dev/null +++ b/main.py @@ -0,0 +1,264 @@ +""" +API Anime Tracker - Synchronisation multi-appareils +""" +from fastapi import FastAPI, Depends, HTTPException, status +from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from datetime import datetime, timedelta +from typing import Optional, List +import os +from dotenv import load_dotenv + +from database import get_db, init_db +from models import User, Anime +from schemas import ( + UserCreate, UserLogin, UserResponse, Token, + AnimeCreate, AnimeUpdate, AnimeResponse +) +from auth import ( + verify_password, get_password_hash, + create_access_token, verify_token, + get_current_user +) + +load_dotenv() + +app = FastAPI( + title="Anime Tracker API", + description="API de synchronisation pour l'extension Anime Tracker", + version="1.0.0" +) + +# CORS pour permettre les requêtes depuis l'extension Chrome +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # En production, spécifier les domaines autorisés + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +security = HTTPBearer() + +# Initialiser la base de données au démarrage +@app.on_event("startup") +async def startup_event(): + init_db() + + +# ==================== AUTHENTIFICATION ==================== + +@app.post("/api/auth/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +async def register(user_data: UserCreate, db: Session = Depends(get_db)): + """Inscription d'un nouvel utilisateur""" + # Vérifier si l'utilisateur existe déjà + existing_user = db.query(User).filter(User.email == user_data.email).first() + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Un utilisateur avec cet email existe déjà" + ) + + # Vérifier si le nom d'utilisateur existe déjà + existing_username = db.query(User).filter(User.username == user_data.username).first() + if existing_username: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ce nom d'utilisateur est déjà pris" + ) + + # Créer le nouvel utilisateur + hashed_password = get_password_hash(user_data.password) + new_user = User( + username=user_data.username, + email=user_data.email, + hashed_password=hashed_password + ) + + db.add(new_user) + db.commit() + db.refresh(new_user) + + return UserResponse( + id=new_user.id, + username=new_user.username, + email=new_user.email, + created_at=new_user.created_at + ) + + +@app.post("/api/auth/login", response_model=Token) +async def login(credentials: UserLogin, db: Session = Depends(get_db)): + """Connexion d'un utilisateur""" + # Trouver l'utilisateur par email ou username + user = db.query(User).filter( + (User.email == credentials.email_or_username) | + (User.username == credentials.email_or_username) + ).first() + + if not user or not verify_password(credentials.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Email/username ou mot de passe incorrect", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Créer le token JWT + access_token = create_access_token(data={"sub": str(user.id)}) + + return Token(access_token=access_token, token_type="bearer") + + +@app.get("/api/auth/me", response_model=UserResponse) +async def get_current_user_info(current_user: User = Depends(get_current_user)): + """Récupérer les informations de l'utilisateur connecté""" + return UserResponse( + id=current_user.id, + username=current_user.username, + email=current_user.email, + created_at=current_user.created_at + ) + + +# ==================== ANIMES ==================== + +@app.get("/api/animes", response_model=List[AnimeResponse]) +async def get_animes( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Récupérer tous les animés de l'utilisateur""" + animes = db.query(Anime).filter(Anime.user_id == current_user.id).all() + return [AnimeResponse.from_orm(anime) for anime in animes] + + +@app.post("/api/animes", response_model=AnimeResponse, status_code=status.HTTP_201_CREATED) +async def create_anime( + anime_data: AnimeCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Créer ou mettre à jour un animé""" + # Vérifier si l'animé existe déjà pour cet utilisateur + existing_anime = db.query(Anime).filter( + Anime.user_id == current_user.id, + Anime.anime_id == anime_data.anime_id + ).first() + + if existing_anime: + # Mettre à jour l'animé existant + for key, value in anime_data.dict(exclude_unset=True).items(): + setattr(existing_anime, key, value) + existing_anime.last_updated = datetime.utcnow() + db.commit() + db.refresh(existing_anime) + return AnimeResponse.from_orm(existing_anime) + else: + # Créer un nouvel animé + new_anime = Anime( + user_id=current_user.id, + **anime_data.dict() + ) + db.add(new_anime) + db.commit() + db.refresh(new_anime) + return AnimeResponse.from_orm(new_anime) + + +@app.put("/api/animes/{anime_id}", response_model=AnimeResponse) +async def update_anime( + anime_id: str, + anime_data: AnimeUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Mettre à jour un animé spécifique""" + anime = db.query(Anime).filter( + Anime.user_id == current_user.id, + Anime.anime_id == anime_id + ).first() + + if not anime: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Animé non trouvé" + ) + + for key, value in anime_data.dict(exclude_unset=True).items(): + setattr(anime, key, value) + + anime.last_updated = datetime.utcnow() + db.commit() + db.refresh(anime) + + return AnimeResponse.from_orm(anime) + + +@app.delete("/api/animes/{anime_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_anime( + anime_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Supprimer un animé""" + anime = db.query(Anime).filter( + Anime.user_id == current_user.id, + Anime.anime_id == anime_id + ).first() + + if not anime: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Animé non trouvé" + ) + + db.delete(anime) + db.commit() + return None + + +@app.post("/api/animes/sync", response_model=List[AnimeResponse]) +async def sync_animes( + animes_data: List[AnimeCreate], + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Synchroniser plusieurs animés en une seule requête""" + synced_animes = [] + + for anime_data in animes_data: + existing_anime = db.query(Anime).filter( + Anime.user_id == current_user.id, + Anime.anime_id == anime_data.anime_id + ).first() + + if existing_anime: + # Mettre à jour + for key, value in anime_data.dict(exclude_unset=True).items(): + setattr(existing_anime, key, value) + existing_anime.last_updated = datetime.utcnow() + else: + # Créer + new_anime = Anime( + user_id=current_user.id, + **anime_data.dict() + ) + db.add(new_anime) + existing_anime = new_anime + + synced_animes.append(existing_anime) + + db.commit() + + for anime in synced_animes: + db.refresh(anime) + + return [AnimeResponse.from_orm(anime) for anime in synced_animes] + + +@app.get("/api/health") +async def health_check(): + """Vérification de l'état de l'API""" + return {"status": "ok", "message": "API Anime Tracker is running"} + diff --git a/models.py b/models.py new file mode 100644 index 0000000..95a0cd9 --- /dev/null +++ b/models.py @@ -0,0 +1,42 @@ +""" +Modèles de base de données +""" +from sqlalchemy import Column, Integer, String, DateTime, Text, Float, ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, index=True, nullable=False) + email = Column(String(100), unique=True, index=True, nullable=False) + hashed_password = Column(String(255), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + animes = relationship("Anime", back_populates="user", cascade="all, delete-orphan") + + +class Anime(Base): + __tablename__ = "animes" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + anime_id = Column(String(100), nullable=False, index=True) # ID unique de l'animé (généré par l'extension) + title = Column(String(255), nullable=False) + current_episode = Column(Integer, default=1) + total_episodes = Column(Integer, nullable=True) + last_url = Column(Text, nullable=True) + source = Column(String(50), nullable=True) # 'crunchyroll' ou 'voiranime' + status = Column(String(20), default='en_cours') # 'en_cours' ou 'termine' + comment = Column(Text, nullable=True) + rating = Column(Float, default=0.0) + anilist_id = Column(Integer, nullable=True) + cover_image = Column(Text, nullable=True) + description = Column(Text, nullable=True) + last_updated = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + user = relationship("User", back_populates="animes") + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..43fd249 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +psycopg2-binary==2.9.9 +pydantic==2.5.0 +pydantic-settings==2.1.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +python-dotenv==1.0.0 + diff --git a/schemas.py b/schemas.py new file mode 100644 index 0000000..8912ce3 --- /dev/null +++ b/schemas.py @@ -0,0 +1,89 @@ +""" +Schémas Pydantic pour la validation des données +""" +from pydantic import BaseModel, EmailStr, Field +from typing import Optional, List +from datetime import datetime + + +# ==================== USER SCHEMAS ==================== + +class UserCreate(BaseModel): + username: str = Field(..., min_length=3, max_length=50) + email: EmailStr + password: str = Field(..., min_length=6) + + +class UserLogin(BaseModel): + email_or_username: str + password: str + + +class UserResponse(BaseModel): + id: int + username: str + email: str + created_at: datetime + + class Config: + from_attributes = True + + +# ==================== AUTH SCHEMAS ==================== + +class Token(BaseModel): + access_token: str + token_type: str + + +# ==================== ANIME SCHEMAS ==================== + +class AnimeCreate(BaseModel): + anime_id: str + title: str + current_episode: int = 1 + total_episodes: Optional[int] = None + last_url: Optional[str] = None + source: Optional[str] = None + status: str = "en_cours" + comment: Optional[str] = None + rating: float = 0.0 + anilist_id: Optional[int] = None + cover_image: Optional[str] = None + description: Optional[str] = None + + +class AnimeUpdate(BaseModel): + title: Optional[str] = None + current_episode: Optional[int] = None + total_episodes: Optional[int] = None + last_url: Optional[str] = None + source: Optional[str] = None + status: Optional[str] = None + comment: Optional[str] = None + rating: Optional[float] = None + anilist_id: Optional[int] = None + cover_image: Optional[str] = None + description: Optional[str] = None + + +class AnimeResponse(BaseModel): + id: int + user_id: int + anime_id: str + title: str + current_episode: int + total_episodes: Optional[int] + last_url: Optional[str] + source: Optional[str] + status: str + comment: Optional[str] + rating: float + anilist_id: Optional[int] + cover_image: Optional[str] + description: Optional[str] + last_updated: datetime + + class Config: + from_attributes = True +