From f33dfd5ab77c7175b1c26e2c4ae33baeb835a6b8 Mon Sep 17 00:00:00 2001 From: EvanChal Date: Tue, 27 Jan 2026 02:39:51 +0100 Subject: [PATCH] fix(notification+vlog upload) --- backend/api/routers/events.py | 40 ++- backend/api/routers/push.py | 67 +++++ backend/api/routers/vlogs.py | 29 +- backend/app.py | 3 +- backend/config/settings.py | 5 + .../versions/0002_add_push_subscriptions.py | 38 +++ backend/models/notification.py | 13 + backend/models/user.py | 1 + backend/requirements.txt | 37 +-- backend/schemas/notification.py | 11 + backend/utils/push_service.py | 136 +++++++++ docker-compose.yml | 2 + .../src/components/TicketFloatingButton.vue | 14 +- frontend/src/services/notificationService.js | 277 +++++++----------- frontend/src/stores/auth.js | 11 +- frontend/src/utils/axios.js | 42 ++- frontend/src/views/AlbumDetail.vue | 4 +- frontend/src/views/Albums.vue | 15 +- frontend/src/views/Posts.vue | 7 +- frontend/src/views/Vlogs.vue | 9 +- 20 files changed, 499 insertions(+), 262 deletions(-) create mode 100644 backend/api/routers/push.py create mode 100644 backend/migrations/versions/0002_add_push_subscriptions.py create mode 100644 backend/utils/push_service.py diff --git a/backend/api/routers/events.py b/backend/api/routers/events.py index 6806333..2ef2816 100644 --- a/backend/api/routers/events.py +++ b/backend/api/routers/events.py @@ -9,6 +9,7 @@ from models.notification import Notification, NotificationType from schemas.event import EventCreate, EventUpdate, EventResponse, ParticipationUpdate from utils.security import get_current_active_user from utils.email import send_event_notification +from utils.push_service import send_push_to_user router = APIRouter() @@ -48,15 +49,22 @@ async def create_event( # Create notification if user.id != current_user.id: + notif_title = f"Invitation à un événement privé: {event.title}" + notif_message = f"{current_user.full_name} vous a invité à un événement privé" + notif_link = f"/events/{event.id}" + notification = Notification( user_id=user.id, type=NotificationType.EVENT_INVITATION, - title=f"Invitation à un événement privé: {event.title}", - message=f"{current_user.full_name} vous a invité à un événement privé", - link=f"/events/{event.id}" + title=notif_title, + message=notif_message, + link=notif_link ) db.add(notification) + # Send push notification + send_push_to_user(db, user.id, notif_title, notif_message, notif_link) + # Send email notification try: send_event_notification(user.email, event) @@ -75,15 +83,22 @@ async def create_event( # Create notification if user.id != current_user.id: + notif_title = f"Nouvel événement: {event.title}" + notif_message = f"{current_user.full_name} a créé un nouvel événement" + notif_link = f"/events/{event.id}" + notification = Notification( user_id=user.id, type=NotificationType.EVENT_INVITATION, - title=f"Nouvel événement: {event.title}", - message=f"{current_user.full_name} a créé un nouvel événement", - link=f"/events/{event.id}" + title=notif_title, + message=notif_message, + link=notif_link ) db.add(notification) + # Send push notification + send_push_to_user(db, user.id, notif_title, notif_message, notif_link) + # Send email notification try: send_event_notification(user.email, event) @@ -309,15 +324,22 @@ async def invite_users_to_event( db.add(participation) # Créer une notification + notif_title = f"Invitation à un événement privé: {event.title}" + notif_message = f"{current_user.full_name} vous a invité à un événement privé" + notif_link = f"/events/{event.id}" + notification = Notification( user_id=user.id, type=NotificationType.EVENT_INVITATION, - title=f"Invitation à un événement privé: {event.title}", - message=f"{current_user.full_name} vous a invité à un événement privé", - link=f"/events/{event.id}" + title=notif_title, + message=notif_message, + link=notif_link ) db.add(notification) + # Send push notification + send_push_to_user(db, user.id, notif_title, notif_message, notif_link) + # Envoyer un email try: send_event_notification(user.email, event) diff --git a/backend/api/routers/push.py b/backend/api/routers/push.py new file mode 100644 index 0000000..3e39d43 --- /dev/null +++ b/backend/api/routers/push.py @@ -0,0 +1,67 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from config.database import get_db +from config.settings import settings +from models.notification import PushSubscription +from models.user import User +from schemas.notification import PushSubscriptionCreate, VapidPublicKeyResponse +from utils.security import get_current_active_user + +router = APIRouter() + +@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse) +async def get_vapid_public_key(current_user: User = Depends(get_current_active_user)): + """Get the VAPID public key for push notifications.""" + if not settings.VAPID_PUBLIC_KEY: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="VAPID keys not configured on server" + ) + return {"public_key": settings.VAPID_PUBLIC_KEY} + +@router.post("/subscribe") +async def subscribe_push( + subscription: PushSubscriptionCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Subscribe to push notifications.""" + # Check if subscription already exists + existing = db.query(PushSubscription).filter( + PushSubscription.endpoint == subscription.endpoint + ).first() + + if existing: + # Update existing subscription + existing.user_id = current_user.id + existing.p256dh = subscription.keys.p256dh + existing.auth = subscription.keys.auth + db.commit() + return {"message": "Subscription updated"} + + # Create new subscription + new_sub = PushSubscription( + user_id=current_user.id, + endpoint=subscription.endpoint, + p256dh=subscription.keys.p256dh, + auth=subscription.keys.auth + ) + db.add(new_sub) + db.commit() + + return {"message": "Subscribed successfully"} + +@router.delete("/unsubscribe") +async def unsubscribe_push( + endpoint: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user) +): + """Unsubscribe from push notifications.""" + db.query(PushSubscription).filter( + PushSubscription.endpoint == endpoint + ).delete() + db.commit() + + return {"message": "Unsubscribed successfully"} + diff --git a/backend/api/routers/vlogs.py b/backend/api/routers/vlogs.py index 6671454..8c7250d 100644 --- a/backend/api/routers/vlogs.py +++ b/backend/api/routers/vlogs.py @@ -13,6 +13,7 @@ from schemas.vlog import VlogCreate, VlogUpdate, VlogResponse, VlogCommentCreate from utils.security import get_current_active_user from utils.video_utils import generate_video_thumbnail, get_video_duration from utils.settings_service import SettingsService +from utils.push_service import send_push_to_user router = APIRouter() @@ -254,12 +255,15 @@ async def upload_vlog_video( """Upload a vlog video.""" # Validate video file if not SettingsService.is_file_type_allowed(db, video.content_type or "unknown"): - allowed_types = SettingsService.get_setting(db, "allowed_video_types", - ["video/mp4", "video/mpeg", "video/quicktime", "video/webm"]) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid video type. Allowed types: {', '.join(allowed_types)}" - ) + # Fallback check for common video types if content_type is generic application/octet-stream + filename = video.filename.lower() + if not (filename.endswith('.mp4') or filename.endswith('.mov') or filename.endswith('.webm') or filename.endswith('.mkv')): + allowed_types = SettingsService.get_setting(db, "allowed_video_types", + ["video/mp4", "video/mpeg", "video/quicktime", "video/webm"]) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid video type. Allowed types: {', '.join(allowed_types)}" + ) # Check file size video_content = await video.read() @@ -335,17 +339,24 @@ async def upload_vlog_video( # Create notifications for all active users (except the creator) users = db.query(User).filter(User.is_active == True).all() + notif_title = "Nouveau vlog" + notif_message = f"{current_user.full_name} a publié un nouveau vlog : {vlog.title}" + notif_link = f"/vlogs/{vlog.id}" + for user in users: if user.id != current_user.id: notification = Notification( user_id=user.id, type=NotificationType.NEW_VLOG, - title="Nouveau vlog", - message=f"{current_user.full_name} a publié un nouveau vlog : {vlog.title}", - link=f"/vlogs/{vlog.id}", + title=notif_title, + message=notif_message, + link=notif_link, is_read=False ) db.add(notification) + + # Envoyer la notification push + send_push_to_user(db, user.id, notif_title, notif_message, notif_link) db.commit() diff --git a/backend/app.py b/backend/app.py index c74e0a8..00e47ce 100644 --- a/backend/app.py +++ b/backend/app.py @@ -8,7 +8,7 @@ import mimetypes from config.settings import settings from config.database import engine, Base -from api.routers import auth, users, events, albums, posts, vlogs, stats, admin, notifications, settings as settings_router, information, tickets +from api.routers import auth, users, events, albums, posts, vlogs, stats, admin, notifications, settings as settings_router, information, tickets, push from utils.init_db import init_database from utils.settings_service import SettingsService from config.database import SessionLocal @@ -294,6 +294,7 @@ app.include_router(vlogs.router, prefix="/api/vlogs", tags=["Vlogs"]) app.include_router(stats.router, prefix="/api/stats", tags=["Statistics"]) app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) app.include_router(notifications.router, prefix="/api/notifications", tags=["Notifications"]) +app.include_router(push.router, prefix="/api/push", tags=["Push Notifications"]) app.include_router(settings_router.router, prefix="/api/settings", tags=["Settings"]) app.include_router(information.router, prefix="/api/information", tags=["Information"]) app.include_router(tickets.router, prefix="/api/tickets", tags=["Tickets"]) diff --git a/backend/config/settings.py b/backend/config/settings.py index 0272b68..512204b 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -55,6 +55,11 @@ class Settings: if not ADMIN_PASSWORD: raise ValueError("ADMIN_PASSWORD variable is required") + # Notifications Push (VAPID) + VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "") + VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "") + VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "mailto:admin@lediscord.com") + # App APP_NAME: str = os.getenv("APP_NAME", "LeDiscord") APP_URL: str = os.getenv("APP_URL", "http://localhost:5173") diff --git a/backend/migrations/versions/0002_add_push_subscriptions.py b/backend/migrations/versions/0002_add_push_subscriptions.py new file mode 100644 index 0000000..29d2515 --- /dev/null +++ b/backend/migrations/versions/0002_add_push_subscriptions.py @@ -0,0 +1,38 @@ +"""Add push_subscriptions table for Web Push notifications + +Revision ID: 0002_push_subscriptions +Revises: 89527c8da8e1 +Create Date: 2025-01-27 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0002_push_subscriptions' +down_revision = '89527c8da8e1' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'push_subscriptions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('endpoint', sa.String(), nullable=False), + sa.Column('p256dh', sa.String(), nullable=False), + sa.Column('auth', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('endpoint') + ) + op.create_index(op.f('ix_push_subscriptions_id'), 'push_subscriptions', ['id'], unique=False) + + +def downgrade(): + op.drop_index(op.f('ix_push_subscriptions_id'), table_name='push_subscriptions') + op.drop_table('push_subscriptions') + diff --git a/backend/models/notification.py b/backend/models/notification.py index 8cc8bdf..1b12a80 100644 --- a/backend/models/notification.py +++ b/backend/models/notification.py @@ -27,3 +27,16 @@ class Notification(Base): # Relationships user = relationship("User", back_populates="notifications") + +class PushSubscription(Base): + __tablename__ = "push_subscriptions" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + endpoint = Column(String, unique=True, nullable=False) + p256dh = Column(String, nullable=False) + auth = Column(String, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + user = relationship("User", back_populates="push_subscriptions") diff --git a/backend/models/user.py b/backend/models/user.py index 19c34cf..4747193 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -27,6 +27,7 @@ class User(Base): mentions = relationship("PostMention", back_populates="mentioned_user", cascade="all, delete-orphan") vlogs = relationship("Vlog", back_populates="author", cascade="all, delete-orphan") notifications = relationship("Notification", back_populates="user", cascade="all, delete-orphan") + push_subscriptions = relationship("PushSubscription", back_populates="user", cascade="all, delete-orphan") vlog_likes = relationship("VlogLike", back_populates="user", cascade="all, delete-orphan") vlog_comments = relationship("VlogComment", back_populates="user", cascade="all, delete-orphan") media_likes = relationship("MediaLike", back_populates="user", cascade="all, delete-orphan") diff --git a/backend/requirements.txt b/backend/requirements.txt index 8917254..e6b1fcd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,22 +1,15 @@ -fastapi==0.104.1 -uvicorn[standard]==0.24.0 -sqlalchemy==2.0.23 -alembic==1.12.1 -psycopg2-binary==2.9.9 -python-jose[cryptography]==3.3.0 -passlib[bcrypt]==1.7.4 -bcrypt==4.0.1 -python-multipart==0.0.6 -python-dotenv==1.0.0 -pydantic==2.5.0 -pydantic[email]==2.5.0 -pydantic-settings==2.1.0 -aiofiles==23.2.1 -pillow==10.1.0 -httpx==0.25.2 -redis==5.0.1 -celery==5.3.4 -flower==2.0.1 -python-magic==0.4.27 -numpy==1.26.4 -opencv-python==4.8.1.78 +fastapi>=0.68.0 +uvicorn>=0.15.0 +sqlalchemy>=1.4.0 +psycopg2-binary>=2.9.0 +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 +python-multipart>=0.0.5 +python-dotenv>=0.19.0 +pydantic>=1.8.0 +email-validator>=1.1.3 +pillow>=9.0.0 +moviepy>=1.0.3 +aiofiles>=0.8.0 +python-magic>=0.4.27 +pywebpush>=1.14.0 diff --git a/backend/schemas/notification.py b/backend/schemas/notification.py index ab8a17f..0db88c5 100644 --- a/backend/schemas/notification.py +++ b/backend/schemas/notification.py @@ -15,3 +15,14 @@ class NotificationResponse(BaseModel): class Config: from_attributes = True + +class PushSubscriptionKeys(BaseModel): + p256dh: str + auth: str + +class PushSubscriptionCreate(BaseModel): + endpoint: str + keys: PushSubscriptionKeys + +class VapidPublicKeyResponse(BaseModel): + public_key: str diff --git a/backend/utils/push_service.py b/backend/utils/push_service.py new file mode 100644 index 0000000..924b015 --- /dev/null +++ b/backend/utils/push_service.py @@ -0,0 +1,136 @@ +""" +Service d'envoi de notifications push via Web Push (VAPID). +""" +from typing import Optional, Dict, Any +from sqlalchemy.orm import Session +from config.settings import settings +from models.notification import PushSubscription + +# Import conditionnel de pywebpush +try: + from pywebpush import webpush, WebPushException + WEBPUSH_AVAILABLE = True +except ImportError: + WEBPUSH_AVAILABLE = False + print("⚠️ pywebpush non installé - Les notifications push sont désactivées") + + +def is_push_configured() -> bool: + """Vérifie si les notifications push sont configurées.""" + return ( + WEBPUSH_AVAILABLE and + bool(settings.VAPID_PRIVATE_KEY) and + bool(settings.VAPID_PUBLIC_KEY) + ) + + +def send_push_to_user( + db: Session, + user_id: int, + title: str, + body: str, + link: str = "/", + data: Optional[Dict[str, Any]] = None +) -> int: + """ + Envoie une notification push à tous les appareils d'un utilisateur. + + Args: + db: Session de base de données + user_id: ID de l'utilisateur + title: Titre de la notification + body: Corps de la notification + link: Lien vers lequel rediriger + data: Données supplémentaires + + Returns: + Nombre de notifications envoyées avec succès + """ + if not is_push_configured(): + return 0 + + # Récupérer tous les abonnements de l'utilisateur + subscriptions = db.query(PushSubscription).filter( + PushSubscription.user_id == user_id + ).all() + + if not subscriptions: + return 0 + + success_count = 0 + failed_endpoints = [] + + import json + payload = json.dumps({ + "title": title, + "body": body, + "link": link, + "data": data or {} + }) + + vapid_claims = { + "sub": settings.VAPID_CLAIMS_EMAIL + } + + for sub in subscriptions: + try: + webpush( + subscription_info={ + "endpoint": sub.endpoint, + "keys": { + "p256dh": sub.p256dh, + "auth": sub.auth + } + }, + data=payload, + vapid_private_key=settings.VAPID_PRIVATE_KEY, + vapid_claims=vapid_claims + ) + success_count += 1 + print(f"✅ Push envoyé à {sub.endpoint[:50]}...") + except WebPushException as e: + print(f"❌ Erreur push pour {sub.endpoint[:50]}...: {e}") + # Si l'abonnement est expiré ou invalide, on le marque pour suppression + if e.response and e.response.status_code in [404, 410]: + failed_endpoints.append(sub.endpoint) + except Exception as e: + print(f"❌ Erreur inattendue push: {type(e).__name__}: {e}") + + # Supprimer les abonnements invalides + if failed_endpoints: + db.query(PushSubscription).filter( + PushSubscription.endpoint.in_(failed_endpoints) + ).delete(synchronize_session=False) + db.commit() + print(f"🗑️ Supprimé {len(failed_endpoints)} abonnements invalides") + + return success_count + + +def send_push_to_users( + db: Session, + user_ids: list, + title: str, + body: str, + link: str = "/", + data: Optional[Dict[str, Any]] = None +) -> int: + """ + Envoie une notification push à plusieurs utilisateurs. + + Args: + db: Session de base de données + user_ids: Liste des IDs utilisateurs + title: Titre de la notification + body: Corps de la notification + link: Lien vers lequel rediriger + data: Données supplémentaires + + Returns: + Nombre total de notifications envoyées avec succès + """ + total = 0 + for user_id in user_ids: + total += send_push_to_user(db, user_id, title, body, link, data) + return total + diff --git a/docker-compose.yml b/docker-compose.yml index b860ec3..0151dd8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,8 @@ services: MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE} ALLOWED_IMAGE_TYPES: ${ALLOWED_IMAGE_TYPES} ALLOWED_VIDEO_TYPES: ${ALLOWED_VIDEO_TYPES} + VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} + VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} volumes: - ${UPLOAD_PATH:-./uploads}:/app/uploads - ./backend:/app diff --git a/frontend/src/components/TicketFloatingButton.vue b/frontend/src/components/TicketFloatingButton.vue index 6eda096..4c3f6de 100644 --- a/frontend/src/components/TicketFloatingButton.vue +++ b/frontend/src/components/TicketFloatingButton.vue @@ -129,6 +129,7 @@ import { ref, computed } from 'vue' import { useToast } from 'vue-toastification' import { useRouter } from 'vue-router' import axios from '@/utils/axios' +import { uploadFormData } from '@/utils/axios' import { useAuthStore } from '@/stores/auth' import { Plus, X, Send, Ticket } from 'lucide-vue-next' import LoadingLogo from '@/components/LoadingLogo.vue' @@ -189,18 +190,7 @@ async function submitTicket() { // Debug: afficher les données envoyées console.log('DEBUG - Ticket form data:') - console.log(' title:', ticketForm.value.title) - console.log(' description:', ticketForm.value.description) - console.log(' ticket_type:', ticketForm.value.ticket_type) - console.log(' priority:', ticketForm.value.priority) - console.log(' screenshot:', screenshotInput.value?.files[0]) - - // Debug: afficher le FormData - for (let [key, value] of formData.entries()) { - console.log(`DEBUG - FormData entry: ${key} = ${value}`) - } - - await axios.post('/api/tickets/', formData) + await uploadFormData('/api/tickets/', formData) toast.success('Ticket envoyé avec succès !') closeModal() diff --git a/frontend/src/services/notificationService.js b/frontend/src/services/notificationService.js index c28f8f2..e731d0a 100644 --- a/frontend/src/services/notificationService.js +++ b/frontend/src/services/notificationService.js @@ -1,10 +1,28 @@ import { useAuthStore } from '@/stores/auth' +import axios from '@/utils/axios' + +// Utilitaire pour convertir la clé VAPID +function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4) + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/') + + const rawData = window.atob(base64) + const outputArray = new Uint8Array(rawData.length) + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i) + } + return outputArray +} class NotificationService { constructor() { this.pollingInterval = null this.pollInterval = 30000 // 30 secondes this.isPolling = false + this.vapidPublicKey = null } // Détecter iOS @@ -25,6 +43,14 @@ class NotificationService { console.warn('Notifications API not supported') return false } + if (!('serviceWorker' in navigator)) { + console.warn('Service Worker API not supported') + return false + } + if (!('PushManager' in window)) { + console.warn('Push API not supported') + return false + } // Sur iOS, les notifications push ne fonctionnent que si la PWA est installée (iOS 16.4+) if (this.isIOS()) { @@ -49,6 +75,8 @@ class NotificationService { } startPolling() { + // Le polling est conservé comme fallback pour les notifications in-app + // ou si le push n'est pas supporté/activé if (this.isPolling) return const authStore = useAuthStore() @@ -81,40 +109,9 @@ class NotificationService { } try { - const result = await authStore.fetchNotifications() - - // Si de nouvelles notifications non lues ont été détectées - if (result && result.hasNewNotifications && result.newCount > result.previousCount) { - // Trouver les nouvelles notifications non lues (les plus récentes en premier) - const newUnreadNotifications = authStore.notifications - .filter(n => !n.is_read) - .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) - .slice(0, result.newCount - result.previousCount) - - if (newUnreadNotifications.length > 0) { - // Ne pas afficher de notification si l'app est en focus - // (pour éviter les doublons avec les notifications dans l'app) - if (document.hasFocus()) { - return - } - - // Afficher une notification push pour la plus récente - const latestNotification = newUnreadNotifications[0] - - // Gérer les liens de posts différemment - let link = latestNotification.link || '/' - if (link.startsWith('/posts/')) { - const postId = link.split('/posts/')[1] - link = `/posts?highlight=${postId}` - } - - await this.showPushNotification(latestNotification.title, { - body: latestNotification.message, - link: link, - data: { notificationId: latestNotification.id } - }) - } - } + // Juste mettre à jour le store (compteur, liste) + // Les notifications push sont gérées par le service worker + await authStore.fetchNotifications() } catch (error) { console.error('Error polling notifications:', error) } @@ -128,34 +125,70 @@ class NotificationService { } } + // Récupérer la clé publique VAPID depuis le backend + async getVapidPublicKey() { + if (this.vapidPublicKey) return this.vapidPublicKey + + try { + const response = await axios.get('/api/push/vapid-public-key') + this.vapidPublicKey = response.data.public_key + return this.vapidPublicKey + } catch (error) { + console.error('Error fetching VAPID public key:', error) + return null + } + } + + // S'abonner aux notifications push + async subscribeToPush() { + if (!this.isNotificationSupported()) return false + + try { + const registration = await navigator.serviceWorker.ready + const publicKey = await this.getVapidPublicKey() + + if (!publicKey) { + console.error('No VAPID public key available') + return false + } + + const convertedVapidKey = urlBase64ToUint8Array(publicKey) + + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: convertedVapidKey + }) + + console.log('✅ Push subscription successful:', subscription) + + // Envoyer l'abonnement au backend + await axios.post('/api/push/subscribe', { + endpoint: subscription.endpoint, + keys: { + p256dh: btoa(String.fromCharCode.apply(null, new Uint8Array(subscription.getKey('p256dh')))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''), + auth: btoa(String.fromCharCode.apply(null, new Uint8Array(subscription.getKey('auth')))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + } + }) + + return true + } catch (error) { + console.error('Error subscribing to push:', error) + return false + } + } + // Gestion des notifications push PWA async requestNotificationPermission() { console.log('🔔 requestNotificationPermission appelée') - console.log('🔔 isIOS:', this.isIOS()) - console.log('🔔 isPWAInstalled:', this.isPWAInstalled()) - console.log('🔔 Notification API disponible:', 'Notification' in window) - console.log('🔔 Permission actuelle:', Notification.permission) if (!this.isNotificationSupported()) { - console.warn('⚠️ Notifications not supported on this platform') - if (this.isIOS()) { - if (!this.isPWAInstalled()) { - console.warn('⚠️ iOS: PWA must be installed (added to home screen)') - } - const iosVersion = navigator.userAgent.match(/OS (\d+)_(\d+)/) - if (iosVersion) { - const major = parseInt(iosVersion[1], 10) - const minor = parseInt(iosVersion[2], 10) - if (major < 16 || (major === 16 && minor < 4)) { - console.warn(`⚠️ iOS: Version ${major}.${minor} - Push notifications require iOS 16.4+`) - } - } - } return false } if (Notification.permission === 'granted') { console.log('✅ Notification permission already granted') + // S'assurer qu'on est bien abonné au push + this.subscribeToPush() return true } @@ -167,7 +200,6 @@ class NotificationService { // Sur iOS, s'assurer que la PWA est installée avant de demander if (this.isIOS() && !this.isPWAInstalled()) { console.warn('⚠️ iOS: Cannot request notification permission - PWA must be installed first') - console.warn('⚠️ Instructions: Add the app to home screen, then open it from home screen') return false } @@ -176,29 +208,9 @@ class NotificationService { const permission = await Notification.requestPermission() const granted = permission === 'granted' - console.log('🔔 Résultat de la demande:', permission) - if (granted) { console.log('✅ Notification permission granted') - - // Sur iOS, vérifier que le service worker est prêt - if (this.isIOS() && 'serviceWorker' in navigator) { - try { - const registration = await navigator.serviceWorker.ready - console.log('✅ Service worker ready on iOS') - - // Tester une notification pour vérifier que ça fonctionne - await registration.showNotification('Test LeDiscord', { - body: 'Les notifications sont activées !', - icon: '/icon-192x192.png', - badge: '/icon-96x96.png', - tag: 'test-notification' - }) - console.log('✅ Test notification sent successfully') - } catch (error) { - console.error('❌ Error testing notification on iOS:', error) - } - } + await this.subscribeToPush() } else { console.warn('❌ Notification permission denied:', permission) } @@ -211,115 +223,30 @@ class NotificationService { } async showPushNotification(title, options = {}) { - console.log('🔔 showPushNotification appelée:', { title, options }) + // Cette méthode est maintenant principalement utilisée pour les tests + // ou les notifications locales générées par le client - if (!this.isNotificationSupported()) { - console.warn('⚠️ Cannot show notification - not supported on this platform') - return null - } - - const hasPermission = await this.requestNotificationPermission() - if (!hasPermission) { - console.warn('⚠️ Cannot show notification - permission not granted') - return null - } - - console.log('✅ Permission granted, affichage de la notification...') - - // Préparer les options de notification (iOS ne supporte pas vibrate) - const notificationOptions = { - body: options.body || options.message || '', - icon: '/icon-192x192.png', - badge: '/icon-96x96.png', - tag: 'lediscord-notification', - requireInteraction: false, - data: { - link: options.link || '/', - notificationId: options.data?.notificationId - }, - ...options - } - - // Retirer vibrate sur iOS (non supporté) - if (!this.isIOS()) { - notificationOptions.vibrate = [200, 100, 200] - } + if (!this.isNotificationSupported()) return null + if (Notification.permission !== 'granted') return null // Toujours utiliser le service worker pour les notifications push - // Cela permet aux notifications de fonctionner même quand l'app est fermée if ('serviceWorker' in navigator) { try { - console.log('🔔 Tentative d\'affichage via service worker...') const registration = await navigator.serviceWorker.ready - console.log('🔔 Service worker ready, active:', !!registration.active) + if (!registration.active) return null - if (!registration.active) { - console.warn('⚠️ Service worker not active, using fallback') - throw new Error('Service worker not active') - } - - // Sur iOS, utiliser directement l'API du service worker - // (les messages peuvent ne pas fonctionner correctement) - if (this.isIOS()) { - console.log('🔔 iOS: Utilisation directe de showNotification') - await registration.showNotification(title, notificationOptions) - console.log('✅ Notification affichée via service worker (iOS)') - } else { - // Envoyer un message au service worker pour afficher la notification - registration.active.postMessage({ - type: 'SHOW_NOTIFICATION', - title, - options: notificationOptions - }) - - // Aussi utiliser l'API directe du service worker - await registration.showNotification(title, notificationOptions) - console.log('✅ Notification affichée via service worker') - } - - return null - } catch (error) { - console.error('❌ Error showing notification via service worker:', error) - console.error('❌ Error details:', { - message: error.message, - stack: error.stack, - name: error.name + await registration.showNotification(title, { + icon: '/icon-192x192.png', + badge: '/icon-96x96.png', + tag: 'lediscord-notification', + ...options }) - // Continuer avec le fallback + return true + } catch (error) { + console.error('Error showing notification:', error) } - } else { - console.warn('⚠️ Service worker not available') - } - - // Fallback: notification native du navigateur (seulement si le SW n'est pas disponible) - try { - const notification = new Notification(title, notificationOptions) - - notification.onclick = () => { - window.focus() - notification.close() - - if (options.link) { - // Utiliser le router si disponible - if (window.location.pathname !== options.link) { - window.location.href = options.link - } - } - } - - // Fermer automatiquement après 5 secondes (sauf sur iOS où c'est géré par le système) - if (!this.isIOS()) { - setTimeout(() => { - notification.close() - }, 5000) - } - - console.log('Notification shown via native API') - return notification - } catch (error) { - console.error('Error showing notification:', error) - return null } + return null } // Écouter les messages du service worker pour les notifications push @@ -327,8 +254,8 @@ class NotificationService { if ('serviceWorker' in navigator) { navigator.serviceWorker.addEventListener('message', (event) => { if (event.data && event.data.type === 'NOTIFICATION') { - const { title, options } = event.data - this.showPushNotification(title, options) + // Notification reçue via message (fallback) + console.log('Notification message received:', event.data) } }) } diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 0ee9420..da0a30e 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import axios from '@/utils/axios' +import { uploadFormData } from '@/utils/axios' import router from '@/router' import { useToast } from 'vue-toastification' @@ -112,15 +113,13 @@ export const useAuthStore = defineStore('auth', () => { const formData = new FormData() formData.append('file', file) - const response = await axios.post('/api/users/me/avatar', formData) - - user.value = response.data + const data = await uploadFormData('/api/users/me/avatar', formData) + user.value = data toast.success('Avatar mis à jour') - return { success: true, data: response.data } + return { success: true, data } } catch (error) { - console.error('Error uploading avatar:', error) toast.error('Erreur lors de l\'upload de l\'avatar') - return { success: false, error: error.response?.data?.detail || 'Erreur inconnue' } + return { success: false, error: error.message || 'Erreur inconnue' } } } diff --git a/frontend/src/utils/axios.js b/frontend/src/utils/axios.js index 403323e..6fd60e7 100644 --- a/frontend/src/utils/axios.js +++ b/frontend/src/utils/axios.js @@ -71,9 +71,10 @@ instance.interceptors.request.use( // Augmenter le timeout pour les requêtes POST/PUT avec FormData (uploads) if ((config.method === 'POST' || config.method === 'PUT') && config.data instanceof FormData) { config.timeout = 120000 // 2 minutes pour les uploads - // Ne pas définir Content-Type pour FormData - laisser le navigateur l'ajouter avec la boundary - // C'est crucial sur mobile où définir explicitement le Content-Type peut causer des erreurs - if (config.headers && config.headers['Content-Type'] === 'multipart/form-data') { + + // IMPORTANT: Supprimer le Content-Type pour laisser le navigateur définir le multipart/form-data avec la boundary + // Axios peut avoir mis 'application/json' par défaut ou on peut l'avoir mis manuellement + if (config.headers && config.headers['Content-Type']) { delete config.headers['Content-Type'] } } @@ -109,15 +110,15 @@ instance.interceptors.response.use( requestHeaders: error.config?.headers }) - // Log supplémentaire pour les erreurs 401/403 - if (error.response?.status === 401 || error.response?.status === 403) { + // Log supplémentaire pour les erreurs 401/403/422 + if ([401, 403, 422].includes(error.response?.status)) { const token = localStorage.getItem('token') - console.error('🔍 Diagnostic erreur auth:', { + console.error(`🔍 Diagnostic erreur ${error.response?.status}:`, { hasToken: !!token, tokenLength: token?.length, - tokenPreview: token ? token.substring(0, 20) + '...' : null, url: error.config?.url, - method: error.config?.method + method: error.config?.method, + validationErrors: error.response?.data?.detail }) } @@ -208,3 +209,28 @@ export function getApiUrl() { export function getAppUrl() { return import.meta.env.VITE_APP_URL || window.location.origin } + +// Fonction utilitaire pour upload de FormData via fetch natif +// (contourne les problèmes d'axios avec FormData sur certains navigateurs/mobiles) +export async function uploadFormData(endpoint, formData) { + const token = localStorage.getItem('token') + const apiUrl = getApiUrl() + + const response = await fetch(`${apiUrl}${endpoint}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + // Ne PAS mettre Content-Type, fetch le gère automatiquement avec FormData + }, + body: formData + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + const error = new Error(errorData.detail || 'Erreur lors de l\'upload') + error.response = { status: response.status, data: errorData } + throw error + } + + return await response.json() +} diff --git a/frontend/src/views/AlbumDetail.vue b/frontend/src/views/AlbumDetail.vue index f71c5ec..a28fc31 100644 --- a/frontend/src/views/AlbumDetail.vue +++ b/frontend/src/views/AlbumDetail.vue @@ -566,7 +566,7 @@ import { useRoute, useRouter } from 'vue-router' import { useAuthStore } from '@/stores/auth' import { useToast } from 'vue-toastification' import axios from '@/utils/axios' -import { getMediaUrl } from '@/utils/axios' +import { getMediaUrl, uploadFormData } from '@/utils/axios' import { formatDateInFrenchTimezone, formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils' import { ArrowLeft, @@ -716,7 +716,7 @@ async function uploadMedia() { formData.append('files', media.file) }) - await axios.post(`/api/albums/${album.value.id}/media`, formData) + await uploadFormData(`/api/albums/${album.value.id}/media`, formData) // Refresh album data await fetchAlbum() diff --git a/frontend/src/views/Albums.vue b/frontend/src/views/Albums.vue index aaea01f..ece03cc 100644 --- a/frontend/src/views/Albums.vue +++ b/frontend/src/views/Albums.vue @@ -334,7 +334,7 @@ import { ref, onMounted } from 'vue' import { useToast } from 'vue-toastification' import { useRouter } from 'vue-router' import axios from '@/utils/axios' -import { getMediaUrl } from '@/utils/axios' +import { getMediaUrl, uploadFormData } from '@/utils/axios' import { formatRelativeDateInFrenchTimezone, formatShortDateInFrenchTimezone } from '@/utils/dateUtils' import { Plus, @@ -653,14 +653,11 @@ async function createAlbum() { } }) - await axios.post(`/api/albums/${album.id}/media`, formData, { - onUploadProgress: (progressEvent) => { - // Update progress for this batch - const batchProgress = (progressEvent.loaded / progressEvent.total) * 100 - const overallProgress = ((batchIndex * batchSize + batch.length) / newAlbum.value.media.length) * 100 - uploadProgress.value = Math.min(overallProgress, 100) - } - }) + await uploadFormData(`/api/albums/${album.id}/media`, formData) + + // Update progress + const overallProgress = ((batchIndex * batchSize + batch.length) / newAlbum.value.media.length) * 100 + uploadProgress.value = Math.min(overallProgress, 100) // Mark batch as successful batch.forEach((media, index) => { diff --git a/frontend/src/views/Posts.vue b/frontend/src/views/Posts.vue index c3db368..cfb11ff 100644 --- a/frontend/src/views/Posts.vue +++ b/frontend/src/views/Posts.vue @@ -289,7 +289,7 @@ import { useRoute, useRouter } from 'vue-router' import { useAuthStore } from '@/stores/auth' import { useToast } from 'vue-toastification' import axios from '@/utils/axios' -import { getMediaUrl } from '@/utils/axios' +import { getMediaUrl, uploadFormData } from '@/utils/axios' import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils' import { Plus, @@ -465,9 +465,8 @@ async function handleImageChange(event) { const formData = new FormData() formData.append('file', file) - const response = await axios.post('/api/posts/upload-image', formData) - - newPost.value.image_url = response.data.image_url + const data = await uploadFormData('/api/posts/upload-image', formData) + newPost.value.image_url = data.image_url } catch (error) { toast.error('Erreur lors de l\'upload de l\'image') } diff --git a/frontend/src/views/Vlogs.vue b/frontend/src/views/Vlogs.vue index 52640cc..e1e60c2 100644 --- a/frontend/src/views/Vlogs.vue +++ b/frontend/src/views/Vlogs.vue @@ -267,7 +267,7 @@ import { ref, onMounted } from 'vue' import { useToast } from 'vue-toastification' import { useRouter } from 'vue-router' import axios from '@/utils/axios' -import { getMediaUrl } from '@/utils/axios' +import { getMediaUrl, uploadFormData } from '@/utils/axios' import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils' import { Plus, @@ -435,14 +435,13 @@ async function createVlog() { formData.append('thumbnail', newVlog.value.thumbnailFile) } - const response = await axios.post('/api/vlogs/upload', formData) - - vlogs.value.unshift(response.data) + const data = await uploadFormData('/api/vlogs/upload', formData) + vlogs.value.unshift(data) showCreateModal.value = false resetForm() toast.success('Vlog créé avec succès') } catch (error) { - toast.error('Erreur lors de la création du vlog') + toast.error(error.message || 'Erreur lors de la création du vlog') } creating.value = false }