Initial commit: API Anime Tracker avec authentification et synchronisation

This commit is contained in:
ɧσℓσ
2025-12-01 22:22:15 +01:00
commit 467cf313e4
11 changed files with 820 additions and 0 deletions

56
.gitignore vendored Normal file
View File

@@ -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

24
Dockerfile Normal file
View File

@@ -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"]

162
README.md Normal file
View File

@@ -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`

94
auth.py Normal file
View File

@@ -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

52
database.py Normal file
View File

@@ -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)

18
docker-compose.yml Normal file
View File

@@ -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

8
env.example Normal file
View File

@@ -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

264
main.py Normal file
View File

@@ -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"}

42
models.py Normal file
View File

@@ -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")

11
requirements.txt Normal file
View File

@@ -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

89
schemas.py Normal file
View File

@@ -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