diff --git a/server/core/hook_engine.py b/server/core/hook_engine.py index c66e570..9699ad7 100644 --- a/server/core/hook_engine.py +++ b/server/core/hook_engine.py @@ -6,7 +6,7 @@ from services import get_all_services from core.reaction_executor import reaction_executor -logger = logging.getLogger("AREA-App") +logger = logging.getLogger("AREA-API") def check_hooks(app=None): ctx = app.app_context() if app else current_app.app_context() @@ -32,19 +32,18 @@ def check_hooks(app=None): last_run = area.last_run.replace(tzinfo=timezone.utc) if (now - last_run) < timedelta(seconds=area.frequency): continue - + params = area.params or {} data = act_srv.check_action(user, area.action, params=params) if data: logger.info(f"[check_hooks] AREA {area.id} triggered") + logger.info(params) + area.set_params(params) area.last_run = now db.session.commit() reaction_executor(user, rea_srv, area.reaction, params=params, data=data) except Exception as e: - logger.exception(f"[check_hooks] Error AREA {area.id}: {e}") - - - + logger.exception(f"[check_hooks] Error AREA {area.id}: {e}") \ No newline at end of file diff --git a/server/models/area.py b/server/models/area.py index 233672f..24399bd 100644 --- a/server/models/area.py +++ b/server/models/area.py @@ -1,5 +1,6 @@ from extensions import db from sqlalchemy.dialects.postgresql import JSON +from sqlalchemy.ext.mutable import MutableDict from utils.crypto_manager import crypto class Area(db.Model): @@ -14,7 +15,7 @@ class Area(db.Model): reaction = db.Column(db.String(80), nullable=False) frequency = db.Column(db.Integer, default=3600) last_run = db.Column(db.DateTime, default=None) - params = db.Column(db.JSON, nullable=True) + params = db.Column(MutableDict.as_mutable(db.JSON), nullable=True) enabled = db.Column(db.Boolean, default=True) public = db.Column(db.Boolean, default=False) @@ -25,12 +26,17 @@ def set_params(self, params: dict): safe = {} for k, v in (params or {}).items(): - if "password" in k.lower(): + if isinstance(v, dict): + safe[k] = {sub_k: crypto.encrypt(sub_v) if "password" in sub_k.lower() else sub_v + for sub_k, sub_v in v.items()} + elif "password" in k.lower(): safe[k] = crypto.encrypt(v) else: safe[k] = v + self.params = safe + def get_params(self) -> dict: safe = {} diff --git a/server/models/service.py b/server/models/service.py index 33dc16a..2808eac 100644 --- a/server/models/service.py +++ b/server/models/service.py @@ -31,7 +31,7 @@ def seed_services(): "name": "Spotify", "description": "Spotify is a digital music platform where you can stream millions of songs and podcasts. It lets you create and share playlists for any mood or moment. Discover new artists and listen to your favorite albums anytime, anywhere. You can connect with friends and see what they’re listening to. Perfect for anyone who loves music and wants instant access to it.", "image": image_to_binary("spotify"), - "auth_url": "https://accounts.spotify.com/authorize?client_id=a63f13819159493eb695b3c16785aa55&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fspotify%2Fcallback&scope=user-read-currently-playing+user-read-playback-state&show_dialog=true" + "auth_url": "http://127.0.0.1:8080/spotify/login" }, { "name": "OpenWeather", @@ -42,7 +42,7 @@ def seed_services(): "name": "GitHub", "description": "GitHub is a platform where developers store and share their code online. It allows teams to collaborate on software projects from anywhere. You can track changes, review contributions, and manage updates easily. Developers use it to build, test, and improve their applications together. It’s the world’s largest community for open-source and coding projects.", "image": image_to_binary("github"), - "auth_url": "https://github.com/login/oauth/authorize?client_id=Ov23liHHgFODT3eZrD2N&scope=repo+user", + "auth_url": "http://127.0.0.1:8080/git/login", }, { "name": "NASA", diff --git a/server/models/user.py b/server/models/user.py index 98b8295..a4b6a9c 100644 --- a/server/models/user.py +++ b/server/models/user.py @@ -8,6 +8,7 @@ class User(db.Model): password_hash = db.Column(db.Text, nullable=False) pictures = db.Column(db.LargeBinary(length=(2**24)-1), nullable=True) expo_push_token = db.Column(db.String(255), nullable=True) + allow_notifications = db.Column(db.Boolean, default=False) def set_password(self, password): self.password_hash = generate_password_hash(password) @@ -16,4 +17,4 @@ def check_password(self, password): return check_password_hash(self.password_hash, password) def to_dict(self): - return {"id": self.id, "email": self.email, "pictures": binascii.b2a_base64(self.pictures).decode('utf-8') if self.pictures else None} + return {"id": self.id, "email": self.email, "pictures": binascii.b2a_base64(self.pictures).decode('utf-8') if self.pictures else None, "allow_notifications": self.allow_notifications} diff --git a/server/oui.txt b/server/oui.txt deleted file mode 100644 index 6d669fd..0000000 --- a/server/oui.txt +++ /dev/null @@ -1 +0,0 @@ -https://avowedly-uncomputed-velvet.ngrok-free.dev/git/callback diff --git a/server/requirements.txt b/server/requirements.txt index 01eda9f..fcda864 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -10,7 +10,7 @@ APScheduler==3.10.4 flasgger discord cryptography -pytest -pytest-flask +pytest +pytest-flask pytest-mock -exponent-server-sdk +exponent-server-sdk \ No newline at end of file diff --git a/server/routes/__init__.py b/server/routes/__init__.py index 11638cc..deabedb 100644 --- a/server/routes/__init__.py +++ b/server/routes/__init__.py @@ -6,6 +6,7 @@ from .service import bp as service_bp from .spotify import bp as spotify_bp from .github_service import bp as github_service_bp +from .ip_manager import bp as ip_manager_bp def register_routes(app): app.register_blueprint(users_bp) @@ -15,4 +16,5 @@ def register_routes(app): app.register_blueprint(area_bp) app.register_blueprint(json_bp) app.register_blueprint(spotify_bp) - app.register_blueprint(github_service_bp) \ No newline at end of file + app.register_blueprint(github_service_bp) + app.register_blueprint(ip_manager_bp) \ No newline at end of file diff --git a/server/routes/github_service.py b/server/routes/github_service.py index 0044077..c63db1d 100644 --- a/server/routes/github_service.py +++ b/server/routes/github_service.py @@ -1,5 +1,7 @@ from flask import Blueprint, jsonify, redirect, request import requests, urllib.parse, os +from .ip_manager import decode_ip +import json bp = Blueprint("github_service", __name__, url_prefix="/git") @@ -15,11 +17,25 @@ def github_login(): """ Redirige l'utilisateur vers GitHub pour autorisation OAuth """ - with open("oui.txt", "w") as f: - print(REDIRECT_URI, file=f) + frontend = request.args.get("frontend", "web") + + if frontend == "mobile": + encoded_ip = request.args.get("ip", None) + port = request.args.get("port", None) + + if encoded_ip: + ip = decode_ip(encoded_ip) + if not ip: + return jsonify({"error": "Invalid or tampered 'ip' parameter"}), 400 + + scope = "repo user" - state = f"frontend:{frontend}" + state = json.dumps({ + "frontend": frontend, + "ip": ip if frontend == "mobile" else None, + "port": port if frontend == "mobile" else None + }) params = { "client_id": CLIENT_ID, @@ -30,7 +46,6 @@ def github_login(): url = f"{AUTH_URL}?{urllib.parse.urlencode(params)}" return redirect(url) -# --- 🟡 Étape 2 : callback GitHub après autorisation @bp.route("/callback", methods=["GET"]) def github_callback(): """ @@ -40,18 +55,23 @@ def github_callback(): return jsonify({"error": request.args["error"]}), 400 code = request.args.get("code") - state = request.args.get("state", "frontend:web") + state_str = request.args.get("state", '{"frontend":"web"}') + + clean = state_str.replace('+', '') + data = json.loads(clean) + + frontend = data.get("frontend") + ip = data.get("ip") + port = data.get("port") if not code: return jsonify({"error": "Missing authorization code"}), 400 - frontend = state.split(":")[1] if frontend == "mobile": - mobile_redirect_uri = "exp://10.18.208.5:8081" + mobile_redirect_uri = f"exp://{ip}:{port}" return redirect(f"{mobile_redirect_uri}?code={code}") - # --- 🌐 Sinon : flow classique web data = { "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, @@ -66,12 +86,12 @@ def github_callback(): if "access_token" not in tokens: return jsonify({"error": "GitHub OAuth failed", "details": tokens}), 400 + return redirect(f"http://localhost:8081/services?tokens={tokens}") return jsonify({ "message": "GitHub connected successfully!", "tokens": tokens }) -# --- 🔵 Étape 3 : échange du code (flow mobile) @bp.route("/exchange_token", methods=["POST"]) def github_exchange_token(): """ diff --git a/server/routes/ip_manager.py b/server/routes/ip_manager.py new file mode 100644 index 0000000..857dd88 --- /dev/null +++ b/server/routes/ip_manager.py @@ -0,0 +1,67 @@ +from flask import request, jsonify, Blueprint +from itsdangerous import URLSafeSerializer, BadSignature +import os +import re +import ipaddress + +bp = Blueprint("ip_manager", __name__, url_prefix="/ip") + +SECRET_KEY = os.getenv("IP_SECRET_KEY", "default_secret_key") +SALT = os.getenv("IP_SALT", "ip_salt") + +serializer = URLSafeSerializer(SECRET_KEY, salt=SALT) + +def extract_ip(exp_url: str) -> str | None: + """ + Extrait l'IP d'une URL Expo (exp://IP:PORT) + """ + pattern = r"exp://([\d\.]+):\d+" + match = re.match(pattern, exp_url) + if match: + return match.group(1) + return None + +def validate_ip(ip_str): + try: + ipaddress.ip_address(ip_str) + return True + except ValueError: + return False + + + +def decode_ip(ip_encoded): + """ + Attendu JSON: { "encoded": "" } + Réponse JSON: { "ip": "1.2.3.4" } ou erreur 400/401 + """ + data = ip_encoded + if not ip_encoded: + return None + + try: + ip = serializer.loads(data) + except BadSignature: + return None + except Exception: + return None + + if not validate_ip(ip): + return None + + return ip + +@bp.route("/encode", methods=["POST"]) +def encode_ip(): + data = request.get_json(force=True, silent=True) + if not data or "ip" not in data: + return jsonify({"error": "Missing 'ip' in request body"}), 400 + + url = str(data["ip"]).strip() + ip = extract_ip(url) + with open("oui.txt", "w") as f: + print(f"Encoding IP: {ip}", file=f) + if not validate_ip(ip): + return jsonify({"error": "Invalid IP address format"}), 400 + token = serializer.dumps(ip) + return jsonify({"encoded": token}), 200 \ No newline at end of file diff --git a/server/routes/service.py b/server/routes/service.py index ddba861..bbba357 100644 --- a/server/routes/service.py +++ b/server/routes/service.py @@ -219,4 +219,9 @@ def service_fetch_by_name(service_name): ), 200 except Exception as e: - return jsonify({"error": str(e)}), 500 \ No newline at end of file + return jsonify({"error": str(e)}), 500 + + +@bp.route("/db") +def db(): + return redirect("http://localhost:8082", code=302) \ No newline at end of file diff --git a/server/routes/spotify.py b/server/routes/spotify.py index f4e915d..d52b5b8 100644 --- a/server/routes/spotify.py +++ b/server/routes/spotify.py @@ -3,6 +3,10 @@ import urllib.parse import os from datetime import datetime +from .ip_manager import decode_ip +import json +import re + bp = Blueprint("spotify_auth", __name__) @@ -15,8 +19,22 @@ @bp.route("/spotify/login", methods=["GET"]) def spotify_login(): frontend = request.args.get("frontend", "web") + + if frontend == "mobile": + encoded_ip = request.args.get("ip", None) + port = request.args.get("port", "8081") + + if encoded_ip: + ip = decode_ip(encoded_ip) + if not ip: + return jsonify({"error": "Invalid or tampered 'ip' parameter"}), 400 + scope = "user-read-currently-playing user-read-playback-state" - state = f"frontend:{frontend}" + state = json.dumps({ + "frontend": frontend, + "ip": ip if frontend == "mobile" else None, + "port": port if frontend == "mobile" else None + }) params = { "client_id": CLIENT_ID, @@ -34,15 +52,20 @@ def spotify_login(): @bp.route("/spotify/callback") def spotify_callback(): code = request.args.get("code") - state = request.args.get("state", "frontend:web") + state_str = request.args.get("state", '{"frontend":"web"}') - frontend = state.split(":")[1] + clean = state_str.replace('+', '') + data = json.loads(clean) + + frontend = data.get("frontend") + ip = data.get("ip") + port = data.get("port") if not code: return jsonify({"error": "Missing authorization code"}), 400 if frontend == "mobile": - mobile_redirect_uri = f"exp://10.18.208.5:8081?code={code}" + mobile_redirect_uri = f"exp://{ip}:{port}?code={code}" print(f"Redirecting to mobile app: {mobile_redirect_uri}") return redirect(mobile_redirect_uri) @@ -59,6 +82,7 @@ def spotify_callback(): return jsonify({"error": res.text}), 400 tokens = res.json() + return redirect(f"http://localhost:8081/services?tokens={tokens}") return jsonify({ "message": "Spotify connected successfully!", "tokens": tokens diff --git a/server/routes/users.py b/server/routes/users.py index bf5737f..693e80d 100644 --- a/server/routes/users.py +++ b/server/routes/users.py @@ -123,14 +123,20 @@ def create_user(): data = request.get_json() email = data.get("email") password = data.get("password") + expo_push_token = data.get("expo_push_token") + notif = False if not email or not password: return jsonify({"error": "email or password required"}), 400 if User.query.filter_by(email=email).first(): return jsonify({"error": "user already exist"}), 400 + if expo_push_token: + notif = True user = User(email=email) user.set_password(password) + user.expo_push_token = expo_push_token + user.allow_notifications = notif db.session.add(user) db.session.commit() @@ -219,6 +225,7 @@ def update_user(user_id): email = data.get("email") password = data.get("password") pictures = data.get("pictures") + allow_notifications = data.get("allow_notifications") if email: user.email = email @@ -229,6 +236,8 @@ def update_user(user_id): user.pictures = base64.b64decode(pictures) else: user.pictures = pictures + if allow_notifications is not None: + user.allow_notifications = allow_notifications db.session.commit() return jsonify(user.to_dict()), 200 diff --git a/server/services/__init__.py b/server/services/__init__.py index 1471117..4d9cdad 100644 --- a/server/services/__init__.py +++ b/server/services/__init__.py @@ -5,6 +5,7 @@ from .gmail_service import GmailService from .spotify_service import SpotifyService from .github_service import GithubService +from .notification_service import NotificationService registered_services = [ OpenWeatherService(), @@ -13,7 +14,8 @@ GmailService(), SpotifyService(), NasaService(), - GithubService() + GithubService(), + NotificationService() ] def get_all_services(): diff --git a/server/services/github_service.py b/server/services/github_service.py index e39c814..db33521 100644 --- a/server/services/github_service.py +++ b/server/services/github_service.py @@ -31,6 +31,38 @@ def get_reactions(self): {"name": "create_issue", "description": "Crée une issue dans un dépôt GitHub"}, {"name": "star_repo", "description": "Ajoute une étoile à un dépôt GitHub"} ] + + def get_actions_params(self, action_name): + if action_name == "new_commit": + return [ + {"name": "repo", "type": "string", "description": "Dépôt à surveiller au format 'owner/repo'"}, + {"name": "access_token", "type": "string", "description": "Token d'accès GitHub avec les permissions nécessaires"}, + {"name": "last_commit", "type": "string", "description": "SHA du dernier commit vu (utilisé pour le suivi)"} + ] + elif action_name == "new_issue": + return [ + {"name": "repo", "type": "string", "description": "Dépôt à surveiller au format 'owner/repo'"}, + {"name": "access_token", "type": "string", "description": "Token d'accès GitHub avec les permissions nécessaires"}, + {"name": "last_issue", "type": "integer", "description": "ID de la dernière issue vue (utilisé pour le suivi)"} + ] + else: + return [] + + def get_reactions_params(self, reaction_name): + if reaction_name == "create_issue": + return [ + {"name": "repo", "type": "string", "description": "Dépôt cible au format 'owner/repo'"}, + {"name": "title", "type": "string", "description": "Titre de l’issue"}, + {"name": "body", "type": "string", "description": "Corps de l’issue (peut inclure des placeholders pour les données d’action)"}, + {"name": "access_token", "type": "string", "description": "Token d'accès GitHub avec les permissions nécessaires"} + ] + elif reaction_name == "star_repo": + return [ + {"name": "repo", "type": "string", "description": "Dépôt à étoiler au format 'owner/repo'"}, + {"name": "access_token", "type": "string", "description": "Token d'accès GitHub avec les permissions nécessaires"} + ] + else: + return [] def check_action(self, user, action, params=None): diff --git a/server/services/notification.py b/server/services/notification_service.py similarity index 64% rename from server/services/notification.py rename to server/services/notification_service.py index 5a533e0..2f05887 100644 --- a/server/services/notification.py +++ b/server/services/notification_service.py @@ -9,8 +9,28 @@ from requests.exceptions import ConnectionError, HTTPError from models.user import User +def send_push_notification(user, message, title): + expo_push_token = User.query.filter_by(id=user.id).first().expo_push_token + try: + response = PushClient().publish( + PushMessage( + to=expo_push_token, + title=title, + body=message, + sound="default", + ) + ) + except PushServerError as exc: + return + except (ConnectionError, HTTPError) as exc: + return + except DeviceNotRegisteredError: + return + except InvalidCredentialsError: + return + class NotificationService(BaseService): - name = "notification" + name = "notifications" def __init__(self): pass @@ -23,8 +43,16 @@ def get_reactions(self): return [ {"name": "send_notification", "description": "Envoyer une notification push à l'utilisateur."} ] + + def get_reactions_params(self, reaction_name): + if reaction_name == "send_notification": + return [ + {"name": "message", "type": "string", "description": "Le message de la notification."}, + {"name": "title", "type": "string", "description": "Le titre de la notification."} + ] + return [] - def check_reaction(self, user, reaction, params=None): + def execute_reaction(self, user, reaction, params=None, data=None): message = params.get("message", "Hello from AREA!") title = params.get("title", "AREA Notification") expo_push_token = User.query.filter_by(id=user.id).first().expo_push_token @@ -33,7 +61,6 @@ def check_reaction(self, user, reaction, params=None): if reaction not in available_reactions: print(f"Reaction non disponible: {reaction}") return None - if reaction == "send_notification": # print(f"Envoi de la notification à l'utilisateur {user.id}") try: diff --git a/server/services/spotify_service.py b/server/services/spotify_service.py index b499c0c..fb0dde2 100644 --- a/server/services/spotify_service.py +++ b/server/services/spotify_service.py @@ -2,7 +2,7 @@ import requests import re import os -from datetime import datetime +from datetime import datetime, timezone class SpotifyService(BaseService): @@ -34,6 +34,24 @@ def get_actions(self): {"name": "currently_playing", "description": "recupere la musique actuellement jouée"} ] + def get_reactions_params(self, reaction_name): + if reaction_name == "play": + return [ + {"name": "citrack_uri", "type": "String", "required": False, "description": "URI de la piste à jouer (optionnel)"}, + {"name": "access_token", "type": "Object", "required": True, "description": "Tokens d'authentification Spotify"}, + {"name": "refresh_token", "type": "String", "required": True, "description": "Refresh token Spotify"} + ] + return [ + {"name": "access_token", "type": "Object", "required": True, "description": "Tokens d'authentification Spotify"}, + {"name": "refresh_token", "type": "String", "required": True, "description": "Refresh token Spotify"} + ] + + def get_actions_params(self, action_name): + return [ + {"name": "access_token", "type": "String", "required": True, "description": "Access token Spotify"}, + {"name": "refresh_token", "type": "String", "required": True, "description": "Refresh token Spotify"}, + ] + def get_reactions(self): return [ {"name": "play", "description": "Joue une musique"}, @@ -43,54 +61,65 @@ def get_reactions(self): ] def check_action(self, user, action, params=None): - tokens = params.get("tokens", {}) - access_token = tokens.get("access_token") - refresh_token = tokens.get("refresh_token") - - if not access_token or not refresh_token: - return None - - headers = {"Authorization": f"Bearer {access_token}"} - url = f"{self.api_base}/me/player/currently-playing" - - res = requests.get(url, headers=headers) - - if res.status_code == 401: - refreshed = self.refresh_token(refresh_token) - if not refreshed: + params = params or {} + access_token = params.get("access_token") + refresh_token = params.get("refresh_token") + + if not access_token or not refresh_token: + print("[Spotify] Tokens manquants") return None - - access_token = refreshed.get("access_token") - headers["Authorization"] = f"Bearer {access_token}" + + headers = {"Authorization": f"Bearer {access_token}"} + url = f"{self.api_base}/me/player/currently-playing" + res = requests.get(url, headers=headers) - - if res.status_code != 200: - print(f"[Spotify] Erreur API ({res.status_code}): {res.text}") - return None - data = res.json() - item = data.get("item") - if not item: - return None + if res.status_code == 401: + refreshed = self.refresh_token(refresh_token) + if not refreshed: + print("[Spotify] Refresh token échoué") + return None - track_name = item.get("name") - artist_name = item["artists"][0]["name"] if item.get("artists") else "Inconnu" - album_name = item["album"]["name"] - url = item["external_urls"]["spotify"] + access_token = refreshed.get("access_token") + headers["Authorization"] = f"Bearer {access_token}" + res = requests.get(url, headers=headers) - if action == "new_title": - last_title = params.get("last_title") - if last_title == track_name: + if res.status_code != 200: + print(f"[Spotify] Erreur API ({res.status_code}): {res.text}") return None - params["last_title"] = track_name - return { - "track": track_name, - "artist": artist_name, - "album": album_name, - "url": url - } + data = res.json() + item = data.get("item") + if not item: + return None + + track_name = item.get("name") + artist_name = item["artists"][0]["name"] if item.get("artists") else "Inconnu" + album_name = item["album"]["name"] + track_url = item["external_urls"]["spotify"] + + if action == "currently_playing": + return { + "track": track_name, + "artist": artist_name, + "album": album_name, + "url": track_url + } + + elif action == "new_title": + last_title = params.get("last_title") + if last_title == track_name: + return None + params["last_title"] = track_name + params["last_updated"] = datetime.now(timezone.utc).isoformat() + return { + "track": track_name, + "artist": artist_name, + "album": album_name, + "url": track_url + } + return None def execute_reaction(self, user, reaction, params=None, data=None): tokens = params.get("tokens", {}) @@ -142,5 +171,3 @@ def execute_reaction(self, user, reaction, params=None, data=None): return True - -