diff --git a/apache/conf/extra/httpd-vhosts.conf b/apache/conf/extra/httpd-vhosts.conf index 0177b8d..9e0bc2d 100644 --- a/apache/conf/extra/httpd-vhosts.conf +++ b/apache/conf/extra/httpd-vhosts.conf @@ -56,24 +56,29 @@ Listen 443 # OIDC config - point to Keycloak via auth.local OIDCProviderMetadataURL https://auth.local/realms/master/.well-known/openid-configuration - OIDCClientID SOA - OIDCRedirectURI https://api.local/api/redirect - OIDCClientSecret NuLgdHzPldRauqIln0I0TN5216PgX3Ty + OIDCClientID soa + OIDCRedirectURI https://api.local/api/private/redirect + OIDCClientSecret mysecret OIDCCryptoPassphrase fdfd8280-13b5-11f0-a320-080027e6dc53 - OIDCPassClaimsAs both + OIDCPassClaimsAs headers + OIDCClaimPrefix OIDC- + OIDCPassUserInfoAs claims OIDCRemoteUserClaim email + OIDCScope "openid email profile" OIDCSessionInactivityTimeout 86400 OIDCSSLValidateServer Off # Proxy public API (no auth) - ProxyPass /public/ http://public_api:5001/ - ProxyPassReverse /public/ http://public_api:5001/ + ProxyPass /api/public http://public_api:5001/ + ProxyPassReverse /api/public http://public_api:5001/ # Proxy private API (OIDC protected) - ProxyPass /api/ http://user_api:5002/ - ProxyPassReverse /api/ http://user_api:5002/ + ProxyPass /api/private http://private_api:5002/api/private + ProxyPassReverse /api/private http://private_api:5002/api/private - + AuthType openid-connect Require valid-user + RequestHeader set X-User-Email "%{HTTP_OIDC_EMAIL}i" + RequestHeader set X-User-Name "%{HTTP_OIDC_PREFERRED_USERNAME}i" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 7f51ef0..310aca6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,7 +44,7 @@ services: networks: - soa - user_api: + private_api: build: context: ./private depends_on: @@ -65,7 +65,7 @@ services: depends_on: - keycloak - public_api - - user_api + - private_api volumes: - ./apache/logs:/usr/local/apache2/conf/logs environment: diff --git a/keyclock-setup.sh b/keyclock-setup.sh index 54767d7..50fc58b 100755 --- a/keyclock-setup.sh +++ b/keyclock-setup.sh @@ -2,8 +2,8 @@ # Variables KC_HOST="http://localhost:8080" -REALM="myrealm" -CLIENT_ID="myclient" +REALM="master" +CLIENT_ID="soa" CLIENT_SECRET="mysecret" USERNAME="alexis" PASSWORD="password" @@ -73,79 +73,3 @@ setup_keycloak() { # Lancer le setup wait_for_keycloak setup_keycloak -#!/bin/bash - -# Variables -KC_HOST="http://localhost:8080" -REALM="myrealm" -CLIENT_ID="myclient" -CLIENT_SECRET="mysecret" -USERNAME="alexis" -PASSWORD="password" - -# Fonction d'attente -wait_for_keycloak() { - echo "⏳ Attente de Keycloak..." - until curl -s "$KC_HOST" > /dev/null; do - sleep 2 - done - echo "✅ Keycloak est prêt." -} - -# Obtenir un token admin -get_admin_token() { - curl -s -X POST "$KC_HOST/realms/master/protocol/openid-connect/token" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "username=admin" \ - -d "password=admin" \ - -d "grant_type=password" \ - -d "client_id=admin-cli" | - jq -r .access_token -} - -# Créer un realm, client et utilisateur -setup_keycloak() { - TOKEN=$(get_admin_token) - - echo "🛠️ Création du realm $REALM..." - curl -s -X POST "$KC_HOST/admin/realms" \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d "{\"realm\":\"$REALM\",\"enabled\":true}" > /dev/null - - echo "🛠️ Création du client $CLIENT_ID..." - curl -s -X POST "$KC_HOST/admin/realms/$REALM/clients" \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d "{ - \"clientId\": \"$CLIENT_ID\", - \"enabled\": true, - \"publicClient\": false, - \"secret\": \"$CLIENT_SECRET\", - \"redirectUris\": [\"*\"], - \"standardFlowEnabled\": true - }" > /dev/null - - echo "👤 Création de l'utilisateur $USERNAME..." - curl -s -X POST "$KC_HOST/admin/realms/$REALM/users" \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d "{ - \"username\": \"$USERNAME\", - \"enabled\": true, - \"credentials\": [{ - \"type\": \"password\", - \"value\": \"$PASSWORD\", - \"temporary\": false - }] - }" > /dev/null - - echo "✅ Configuration terminée !" - echo "🔐 Utilisateur: $USERNAME / $PASSWORD" - echo "🪪 Client secret: $CLIENT_SECRET" -} - -# Lancer le setup -wait_for_keycloak -setup_keycloak - diff --git a/private/Dockerfile b/private/Dockerfile index f5b09f1..2b398f5 100644 --- a/private/Dockerfile +++ b/private/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.11-slim WORKDIR /app COPY . . -RUN pip install flask flask_sqlalchemy pyjwt requests pymysql cryptography redis +RUN pip install flask flask_sqlalchemy pyjwt requests pymysql cryptography redis python-jose CMD ["python", "app.py"] diff --git a/private/app.py b/private/app.py index 84d1316..adca438 100644 --- a/private/app.py +++ b/private/app.py @@ -1,11 +1,13 @@ -from flask import Flask, jsonify, request, abort +from flask import Flask, request, jsonify, g, abort from flask_sqlalchemy import SQLAlchemy +from sqlalchemy.exc import IntegrityError +from jose import jwt, JWTError import requests -import jwt import time import pymysql import redis import json +from functools import wraps app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://myuser:mypassword@mysql:3306/mydb' @@ -13,6 +15,7 @@ app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False db = SQLAlchemy(app) + MYSQL_HOST = "mysql" MYSQL_PORT = 3306 MYSQL_USER = "myuser" @@ -38,81 +41,821 @@ while True: print("Waiting for MySQL...", e) time.sleep(2) -class Visite(db.Model): +print('Creating DB') + +# Keycloak config +KEYCLOAK_REALM = "master" +KEYCLOAK_URL = "http://keycloak:8080" +CLIENT_ID = "soa" + +ISSUER = f"{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}" +JWKS_URL = f"{ISSUER}/protocol/openid-connect/certs" + +for _ in range(30): + try: + r = requests.get("http://keycloak:8080/realms/master/.well-known/openid-configuration") + if r.status_code == 200: + break + except Exception: + pass + time.sleep(2) +else: + raise Exception("Keycloak is not available after waiting") + +jwks = requests.get(JWKS_URL).json()["keys"] + + + +def get_signing_key(token): + unverified_header = jwt.get_unverified_header(token) + kid = unverified_header.get("kid") + for key in jwks: + if key["kid"] == kid: + return key + raise Exception("Public key not found.") + +# Decorator for OIDC protection +def oidc_required(f): + @wraps(f) + def wrapper(*args, **kwargs): + # Get user info from Apache headers + user_email = request.headers.get("OIDC_email") + username = request.headers.get("OIDC_user") or user_email + if not user_email or not username: + return jsonify({"error": "Not authenticated"}), 401 + + # Find or create user in DB + user = User.query.filter_by(email=user_email).first() + if not user: + user = User( + username=username, + email=user_email, + alias=username, + ) + db.session.add(user) + db.session.commit() + event = { + "type": "user_created", + "data": {"id": user.id, "alias": user.alias} + } + redis_client.publish('events', json.dumps(event)) + g.db_user = user + + return f(*args, **kwargs) + return wrapper +@app.route("/api/private/debug-headers") +def debug_headers(): + return jsonify(dict(request.headers)) +class User(db.Model): + __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) - galerie_id = db.Column(db.Integer, nullable=False) + username = db.Column(db.String(50), unique=True, nullable=False) + email = db.Column(db.String(255), unique=True, nullable=False) + alias = db.Column(db.String(255), nullable=False) + first_name = db.Column(db.String(100)) + last_name = db.Column(db.String(100)) + bio = db.Column(db.Text) + profile_picture_url = db.Column(db.String(255)) + created_at = db.Column(db.DateTime, server_default=db.func.now()) + updated_at = db.Column(db.DateTime, server_default=db.func.now(), onupdate=db.func.now()) -class Critique(db.Model): +class Gallery(db.Model): + __tablename__ = "galleries" id = db.Column(db.Integer, primary_key=True) - oeuvre_id = db.Column(db.Integer, nullable=False) - texte = db.Column(db.Text, nullable=False) - username = db.Column(db.String(100), nullable=False) + owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + title = db.Column(db.String(255), nullable=False) + description = db.Column(db.Text) + is_public = db.Column(db.Boolean, default=False) + publication_date = db.Column(db.DateTime) + created_at = db.Column(db.DateTime, server_default=db.func.now()) + updated_at = db.Column(db.DateTime, server_default=db.func.now(), onupdate=db.func.now()) +class Artwork(db.Model): + __tablename__ = "artworks" + id = db.Column(db.Integer, primary_key=True) + gallery_id = db.Column(db.Integer, db.ForeignKey('galleries.id'), nullable=False) + creator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + title = db.Column(db.String(255), nullable=False) + description = db.Column(db.Text) + image_url = db.Column(db.String(255), nullable=False) + medium = db.Column(db.String(100)) + dimensions = db.Column(db.String(50)) + creation_year = db.Column(db.Integer) + price = db.Column(db.Numeric(10, 2)) + is_visible = db.Column(db.Boolean, default=True) + is_for_sale = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, server_default=db.func.now()) + updated_at = db.Column(db.DateTime, server_default=db.func.now(), onupdate=db.func.now()) -@app.route("/", methods=["GET"]) -def index(): - return f"User API - Authenticated as {request.user}", 200 +class GalleryMember(db.Model): + __tablename__ = "gallery_members" + gallery_id = db.Column(db.Integer, db.ForeignKey('galleries.id'), primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), primary_key=True) + role = db.Column(db.String(50), nullable=False, default='viewer') + status = db.Column(db.String(50), nullable=False, default='pending') + invited_at = db.Column(db.DateTime, server_default=db.func.now()) + entered_at = db.Column(db.DateTime) + updated_at = db.Column(db.DateTime, server_default=db.func.now(), onupdate=db.func.now()) -@app.route("/galerie//entrer", methods=["POST"]) -def entrer_galerie(galerie_id): - visite = Visite(galerie_id=galerie_id) - db.session.add(visite) +class ArtworkReview(db.Model): + __tablename__ = "artwork_reviews" + id = db.Column(db.Integer, primary_key=True) + artwork_id = db.Column(db.Integer, db.ForeignKey('artworks.id'), nullable=False) + author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + grade = db.Column(db.Integer) + description = db.Column(db.Text) + parent_ar_id = db.Column(db.Integer, db.ForeignKey('artwork_reviews.id')) + created_at = db.Column(db.DateTime, server_default=db.func.now()) + updated_at = db.Column(db.DateTime, server_default=db.func.now(), onupdate=db.func.now()) + __table_args__ = (db.CheckConstraint('grade >= 0 AND grade <= 5', name='check_grade_range_artwork'),) + +class GalleryReview(db.Model): + __tablename__ = "gallery_reviews" + id = db.Column(db.Integer, primary_key=True) + gallery_id = db.Column(db.Integer, db.ForeignKey('galleries.id'), nullable=False) + author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + grade = db.Column(db.Integer) + description = db.Column(db.Text) + parent_gr_id = db.Column(db.Integer, db.ForeignKey('gallery_reviews.id')) + created_at = db.Column(db.DateTime, server_default=db.func.now()) + updated_at = db.Column(db.DateTime, server_default=db.func.now(), onupdate=db.func.now()) + __table_args__ = (db.CheckConstraint('grade >= 0 AND grade <= 5', name='check_grade_range_gallery'),) + +with app.app_context(): + db.create_all() # Ensure all tables are created if they do not exist + +@app.route("/api/private/redirect") +def oidc_redirect(): + code = request.args.get("code") + if not code: + return "Missing code", 400 + + # Exchange code for tokens + token_url = "https://auth.local/realms/master/protocol/openid-connect/token" + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "https://api.local/api/private/redirect", + "client_id": "soa", + "client_secret": "mysecret" + } + resp = requests.post(token_url, data=data) + if resp.status_code != 200: + return "Token exchange failed", 400 + + tokens = resp.json() + # Store tokens in session, or set as cookie, or return to frontend + # Example: set as cookie (not for production, just for demo) + response = redirect("/") # or wherever you want + response.set_cookie("access_token", tokens["access_token"], httponly=True, secure=True) + return response + +# User profile + +# Retrieve the authenticated user's profile information. +@app.route("/api/private/me", methods=["GET"]) +@oidc_required +def get_me(): + user = g.db_user + return jsonify({ + "id": user.id, + "username": user.username, + "email": user.email, + "alias": user.alias, + "first_name": user.first_name, + "last_name": user.last_name, + "bio": user.bio, + "profile_picture_url": user.profile_picture_url, + "created_at": user.created_at, + "updated_at": user.updated_at + }) + +# Update the authenticated user's editable profile fields (alias, first name, last name, bio, profile picture). +@app.route("/api/private/me", methods=["PUT"]) +@oidc_required +def update_me(): + data = request.json + user = g.db_user + user.alias = data.get("alias", user.alias) + user.first_name = data.get("first_name", user.first_name) + user.last_name = data.get("last_name", user.last_name) + user.bio = data.get("bio", user.bio) + user.profile_picture_url = data.get("profile_picture_url", user.profile_picture_url) db.session.commit() - return jsonify({"message": "Entré dans la galerie"}), 201 - -@app.route("/galerie//sortir", methods=["POST"]) -def sortir_galerie(galerie_id): - Visite.query.filter_by(galerie_id=galerie_id).delete() - db.session.commit() - return jsonify({"message": "Sorti de la galerie"}), 200 - -@app.route("/oeuvres", methods=["POST"]) -def create_oeuvre(): - data = flask.request.get_json() - titre = data.get("titre") - if not titre: - return {"error": "Titre requis"}, 400 - oeuvre = Oeuvre(titre=titre, exposee=True) - db.session.add(oeuvre) - db.session.commit() - # Publier l'événement event = { - "type": "oeuvre_created", - "data": {"id": oeuvre.id, "titre": oeuvre.titre} + "type": "user_updated", + "data": {"id": user.id, "alias": user.alias} } redis_client.publish('events', json.dumps(event)) - return {"id": oeuvre.id, "titre": oeuvre.titre}, 201 + return jsonify({"message": "Profile updated"}) -@app.route("/oeuvre//critiquer", methods=["POST"]) -def critiquer_oeuvre(oeuvre_id): - data = request.get_json() - if not data or not data.get("texte"): + +# Invitations + +# Send an invitation to another user to join the specified gallery. +@app.route("/api/private/gallery//invite", methods=["POST"]) +@oidc_required +def invite_user(gallery_id): + data = request.json + invited_user_id = data.get("user_id") + role = data.get("role", "viewer") + gallery = Gallery.query.get_or_404(gallery_id) + if gallery.owner_id != g.db_user.id: + abort(403) + user = User.query.get(invited_user_id) + if not user: + abort(404) + invitation = GalleryMember( + gallery_id=gallery_id, + user_id=invited_user_id, + role=role, + status="pending" + ) + try: + db.session.add(invitation) + db.session.commit() + event = { + "type": "invitation_sent", + "data": {"user_id": invitation.user_id, "gallery_id": invitation.gallery_id} + } + redis_client.publish('events', json.dumps(event)) + except IntegrityError: + db.session.rollback() + return jsonify({"error": "Invitation already exists"}), 409 + return jsonify({"message": "Invitation sent"}), 201 + +# Allow an invited user to accept or reject a gallery invitation. +@app.route("/api/private/invitations//respond", methods=["PUT"]) +@oidc_required +def respond_invitation(gallery_id): + data = request.json + status = data.get("status") + if status not in ["accepted", "rejected"]: abort(400) - critique = Critique(oeuvre_id=oeuvre_id, texte=data["texte"], username=request.user) - db.session.add(critique) + invitation = GalleryMember.query.filter_by(gallery_id=gallery_id, user_id=g.db_user.id).first_or_404() + if invitation.status != "pending": + abort(403) + invitation.status = status + if status == "accepted": + invitation.entered_at = db.func.now() db.session.commit() - return jsonify({"message": "Critique ajoutée"}), 201 - -# ROUTE CREATION GALERIE -@app.route("/galeries", methods=["POST"]) -def create_galerie(): - data = request.get_json() - nom = data.get("nom") - if not nom: - return {"error": "Nom requis"}, 400 - galerie = Galerie(nom=nom, auteur=request.user) - db.session.add(galerie) - db.session.commit() - # Publier l'événement event = { - "type": "galerie_created", - "data": {"id": galerie.id, "nom": galerie.nom, "auteur": galerie.auteur} + "type": "invitation_answered", + "data": {"user_id": invitation.user_id, "gallery_id": invitation.gallery_id, "answer": invitation.status} } redis_client.publish('events', json.dumps(event)) - return {"id": galerie.id, "nom": galerie.nom, "auteur": galerie.auteur}, 201 + return jsonify({"message": f"Invitation {status}"}) + +# List all invitations received by the authenticated user (with status). +@app.route("/api/private/invitations/received", methods=["GET"]) +@oidc_required +def get_received_invitations(): + invitations = GalleryMember.query.filter_by(user_id=g.db_user.id).all() + result = [] + for inv in invitations: + gal = Gallery.query.get(inv.gallery_id) + own = User.query.get(gal.owner_id) + result.append({ + "gallery_id": inv.gallery_id, + "gallery_title": gal.title, + "gallery_description": gal.description, + "owner": own.alias, + "role": inv.role, + "status": inv.status, + "invited_at": inv.invited_at, + "entered_at": inv.entered_at, + "updated_at": inv.updated_at + }) + return jsonify(result) + + +# Galleries + +# List all galleries accessible to the user (public, owned, or where they are a member). +@app.route("/api/private/galleries", methods=["GET"]) +@oidc_required +def get_galleries(): + user_id = g.db_user.id + public = Gallery.query.filter_by(is_public=True) + owned = Gallery.query.filter_by(owner_id=user_id) + member = Gallery.query.join(GalleryMember, Gallery.id==GalleryMember.gallery_id).filter(GalleryMember.user_id==user_id, GalleryMember.status=="accepted") + galleries = public.union(owned).union(member).all() + result = [] + for gal in galleries: + own = User.query.get(gal.owner_id) + result.append({ + "id": gal.id, + "title": gal.title, + "description": gal.description, + "owner": own.alias, + "is_public": gal.is_public, + "publication_date": gal.publication_date, + }) + return jsonify(result) + +# Show details of a single gallery (enforcing public or member access). +@app.route("/api/private/gallery/", methods=["GET"]) +@oidc_required +def get_gallery(gallery_id): + gal = Gallery.query.get_or_404(gallery_id) + if not gal.is_public: + member = GalleryMember.query.filter_by(gallery_id=gallery_id, user_id=g.db_user.id, status="accepted").first() + if gal.owner_id != g.db_user.id and not member: + abort(403) + own = User.query.get(gal.owner_id) + return jsonify({ + "id": gal.id, + "title": gal.title, + "description": gal.description, + "owner": own.alias, + "is_public": gal.is_public, + "publication_date": gal.publication_date, + "created_at": gal.created_at, + "updated_at": gal.updated_at + }) + +# Create a new gallery for the authenticated user. +@app.route("/api/private/gallery", methods=["POST"]) +@oidc_required +def create_gallery(): + data = request.json + gallery = Gallery( + owner_id=g.db_user.id, + title=data.get("title"), + description=data.get("description"), + is_public=data.get("is_public", False), + publication_date=data.get("publication_date") + ) + db.session.add(gallery) + db.session.commit() + event = { + "type": "gallery_created", + "data": {"user_id": gallery.owner_id, "gallery_id": gallery.id} + } + redis_client.publish('events', json.dumps(event)) + return jsonify({"id": gallery.id, "message": "Gallery created"}), 201 + +# Update a gallery's title, description, public flag, and publication date (owner only). +@app.route("/api/private/gallery/", methods=["PUT"]) +@oidc_required +def update_gallery(gallery_id): + gal = Gallery.query.get_or_404(gallery_id) + if gal.owner_id != g.db_user.id: + abort(403) + data = request.json + gal.title = data.get("title", gal.title) + gal.description = data.get("description", gal.description) + gal.is_public = data.get("is_public", gal.is_public) + gal.publication_date = data.get("publication_date", gal.publication_date) + db.session.commit() + event = { + "type": "gallery_updated", + "data": {"user_id": gal.owner_id, "gallery_id": gal.id} + } + redis_client.publish('events', json.dumps(event)) + return jsonify({"message": "Gallery updated"}) + +# Retrieve the list of galleries owned by the authenticated user. +@app.route("/api/private/galleries/mine", methods=["GET"]) +@oidc_required +def get_my_galleries(): + user_id = g.db_user.id + galleries = Gallery.query.filter_by(owner_id=user_id).all() + result = [] + for gal in galleries: + result.append({ + "id": gal.id, + "title": gal.title, + "description": gal.description, + "is_public": gal.is_public, + "publication_date": gal.publication_date, + "created_at": gal.created_at, + "updated_at": gal.updated_at + }) + return jsonify(result) + +# List all members of a gallery (including the owner), with roles and join dates. +@app.route("/api/private/gallery//members", methods=["GET"]) +@oidc_required +def get_gallery_members(gallery_id): + gal = Gallery.query.get_or_404(gallery_id) + if not gal.is_public: + member = GalleryMember.query.filter_by(gallery_id=gallery_id, user_id=g.db_user.id, status="accepted").first() + if gal.owner_id != g.db_user.id and not member: + abort(403) + members = GalleryMember.query.filter_by(gallery_id=gallery_id, status="accepted").all() + result = [] + owner = User.query.get(gal.owner_id) + result.append({ + "user_id": owner.id, + "alias": owner.alias, + "bio": owner.bio, + "profile_picture_url": owner.profile_picture_url, + "role": "owner", + "entered_at": gal.created_at + }) + for mem in members: + user = User.query.get(mem.user_id) + result.append({ + "user_id": user.id, + "alias": user.alias, + "bio": user.bio, + "profile_picture_url": user.profile_picture_url, + "role": mem.role, + "entered_at": mem.entered_at + }) + return jsonify(result) + + +# Artworks + +# List artworks in a gallery, filtering by visibility and access. +@app.route("/api/private/gallery//artworks", methods=["GET"]) +@oidc_required +def get_gallery_artworks(gallery_id): + gal = Gallery.query.get_or_404(gallery_id) + if not gal.is_public: + member = GalleryMember.query.filter_by(gallery_id=gallery_id, user_id=g.db_user.id, status="accepted").first() + if gal.owner_id != g.db_user.id and not member: + abort(403) + if gal.owner_id != g.db_user.id: + artworks = Artwork.query.filter_by(gallery_id=gallery_id, is_visible=True).all() + else: + artworks = Artwork.query.filter_by(gallery_id=gallery_id).all() + result = [] + for art in artworks: + cre = User.query.get(art.creator_id) + result.append({ + "id": art.id, + "title": art.title, + "description": art.description, + "creator": cre.alias, + "image_url": art.image_url, + "medium": art.medium, + "dimensions": art.dimensions, + "creation_year": art.creation_year, + "is_visible": art.is_visible, + "price": art.price, + "is_for_sale": art.is_for_sale + }) + return jsonify(result) + +# Retrieve detailed information about a single artwork (visibility & access checks). +@app.route("/api/private/artwork/", methods=["GET"]) +@oidc_required +def get_artwork(artwork_id): + art = Artwork.query.get_or_404(artwork_id) + gal = Gallery.query.get(art.gallery_id) + if not gal.is_public: + member = GalleryMember.query.filter_by(gallery_id=art.gallery_id, user_id=g.db_user.id, status="accepted").first() + if gal.owner_id != g.db_user.id and not member: + abort(403) + if art.creator_id != g.db_user.id and not art.is_visible: + abort(404) + cre = User.query.get(art.creator_id) + return jsonify({ + "id": art.id, + "gallery_id": art.gallery_id, + "creator": cre.alias, + "title": art.title, + "description": art.description, + "image_url": art.image_url, + "medium": art.medium, + "dimensions": art.dimensions, + "creation_year": art.creation_year, + "is_visible": art.is_visible, + "price": art.price, + "is_for_sale": art.is_for_sale, + "created_at": art.created_at, + "updated_at": art.updated_at + }) + +# Add a new artwork to the specified gallery (owner only). +@app.route("/api/private/gallery//artwork", methods=["POST"]) +@oidc_required +def create_artwork(gallery_id): + gallery = Gallery.query.get_or_404(gallery_id) + if gallery.owner_id != g.db_user.id: + abort(403) + data = request.json + artwork = Artwork( + gallery_id=gallery_id, + creator_id=g.db_user.id, + title=data.get("title"), + description=data.get("description"), + image_url=data.get("image_url"), + medium=data.get("medium"), + dimensions=data.get("dimensions"), + creation_year=data.get("creation_year"), + price=data.get("price"), + is_visible=data.get("is_visible", True), + is_for_sale=data.get("is_for_sale", False) + ) + db.session.add(artwork) + db.session.commit() + event = { + "type": "artwork_created", + "data": {"user_id": artwork.creator_id, "artwork_id": artwork.id} + } + redis_client.publish('events', json.dumps(event)) + return jsonify({"id": artwork.id, "message": "Artwork created"}), 201 + +# Update an existing artwork's details (creator only). +@app.route("/api/private/artwork/", methods=["PUT"]) +@oidc_required +def update_artwork(artwork_id): + art = Artwork.query.get_or_404(artwork_id) + if art.creator_id != g.db_user.id: + abort(403) + data = request.json + art.title = data.get("title", art.title) + art.description = data.get("description", art.description) + art.image_url = data.get("image_url", art.image_url) + art.medium = data.get("medium", art.medium) + art.dimensions = data.get("dimensions", art.dimensions) + art.creation_year = data.get("creation_year", art.creation_year) + art.price = data.get("price", art.price) + art.is_visible = data.get("is_visible", art.is_visible) + art.is_for_sale = data.get("is_for_sale", art.is_for_sale) + db.session.commit() + event = { + "type": "artwork_updated", + "data": {"user_id": art.creator_id, "artwork_id": art.id} + } + redis_client.publish('events', json.dumps(event)) + return jsonify({"message": "Artwork updated"}) + +# List all artworks created by the authenticated user. +@app.route("/api/private/artworks/mine", methods=["GET"]) +@oidc_required +def get_my_artworks(): + artworks = Artwork.query.filter_by(creator_id=g.db_user.id).all() + result = [] + for art in artworks: + result.append({ + "id": art.id, + "gallery_id": art.gallery_id, + "title": art.title, + "description": art.description, + "image_url": art.image_url, + "medium": art.medium, + "dimensions": art.dimensions, + "creation_year": art.creation_year, + "is_visible": art.is_visible, + "price": art.price, + "is_for_sale": art.is_for_sale, + "created_at": art.created_at, + "updated_at": art.updated_at + }) + return jsonify(result) + + +# Gallery reviews + +# List all reviews for a given gallery (with access checks). +@app.route("/api/private/gallery//reviews", methods=["GET"]) +@oidc_required +def get_gallery_reviews(gallery_id): + gal = Gallery.query.get_or_404(gallery_id) + if not gal.is_public: + member = GalleryMember.query.filter_by(gallery_id=gallery_id, user_id=g.db_user.id, status="accepted").first() + if gal.owner_id != g.db_user.id and not member: + abort(403) + reviews = GalleryReview.query.filter_by(gallery_id=gal.id).all() + result = [] + for rev in reviews: + aut = User.query.get(rev.author_id) + result.append({ + "id": rev.id, + "author": aut.alias, + "grade": rev.grade, + "description": rev.description, + "parent_gr_id": rev.parent_gr_id, + "created_at": rev.created_at, + "updated_at": rev.updated_at + }) + return jsonify(result) + +# Submit a new review for the specified gallery (access enforced). +@app.route("/api/private/gallery//review", methods=["POST"]) +@oidc_required +def create_gallery_review(gallery_id): + gal = Gallery.query.get_or_404(gallery_id) + if not gal.is_public: + member = GalleryMember.query.filter_by(gallery_id=gallery_id, user_id=g.db_user.id, status="accepted").first() + if gal.owner_id != g.db_user.id and not member: + abort(403) + data = request.json + review = GalleryReview( + gallery_id=gal.id, + author_id=g.db_user.id, + grade=data.get("grade"), + description=data.get("description"), + parent_gr_id=data.get("parent_gr_id") + ) + db.session.add(review) + db.session.commit() + event = { + "type": "gallery_review_created", + "data": {"user_id": review.author_id, "gallery_id": review.gallery_id, "gallery_review_id": review.id} + } + redis_client.publish('events', json.dumps(event)) + return jsonify({"id": review.id, "message": "Review created"}), 201 + +# Edit an existing gallery review (author only). +@app.route("/api/private/galleries/review/", methods=["PUT"]) +@oidc_required +def update_gallery_review(review_id): + rev = GalleryReview.query.get_or_404(review_id) + if rev.author_id != g.db_user.id: + abort(403) + gal = Gallery.query.get_or_404(rev.gallery_id) + if not gal.is_public: + member = GalleryMember.query.filter_by(gallery_id=rev.gallery_id, user_id=g.db_user.id, status="accepted").first() + if gal.owner_id != g.db_user.id and not member: + abort(403) + data = request.json + rev.grade = data.get("grade", rev.grade) + rev.description = data.get("description", rev.description) + db.session.commit() + event = { + "type": "gallery_review_updated", + "data": {"user_id": rev.author_id, "gallery_id": rev.gallery_id, "gallery_review_id": rev.id} + } + redis_client.publish('events', json.dumps(event)) + return jsonify({"message": "Review updated"}) + +# Retrieve all gallery reviews written by the authenticated user. +@app.route("/api/private/galleries/reviews/given", methods=["GET"]) +@oidc_required +def get_given_gallery_reviews(): + reviews = GalleryReview.query.filter_by(author_id=g.db_user.id).all() + result = [] + for rev in reviews: + gal = Gallery.query.get(rev.gallery_id) + result.append({ + "review_id": rev.id, + "gallery_id": gal.id, + "gallery_title": gal.title, + "grade": rev.grade, + "description": rev.description, + "parent_gr_id": rev.parent_gr_id, + "created_at": rev.created_at, + "updated_at": rev.updated_at + }) + return jsonify(result) + +# List all reviews received on galleries owned by the authenticated user. +@app.route("/api/private/galleries/reviews/received", methods=["GET"]) +@oidc_required +def get_received_gallery_reviews(): + galleries = Gallery.query.filter_by(owner_id=g.db_user.id).all() + result = [] + for gal in galleries: + reviews = GalleryReview.query.filter_by(gallery_id=gal.id).all() + for rev in reviews: + author = User.query.get(rev.author_id) + result.append({ + "review_id": rev.id, + "gallery_id": gal.id, + "gallery_title": gal.title, + "author": author.alias, + "grade": rev.grade, + "description": rev.description, + "parent_gr_id": rev.parent_gr_id, + "created_at": rev.created_at, + "updated_at": rev.updated_at + }) + return jsonify(result) + + +# Artwork reviews + +# List all reviews for a given artwork (with access checks). +@app.route("/api/private/artwork//reviews", methods=["GET"]) +@oidc_required +def get_artwork_reviews(artwork_id): + art = Artwork.query.get_or_404(artwork_id) + gal = Gallery.query.get(art.gallery_id) + if not gal.is_public: + member = GalleryMember.query.filter_by(gallery_id=art.gallery_id, user_id=g.db_user.id, status="accepted").first() + if gal.owner_id != g.db_user.id and not member: + abort(403) + if art.creator_id != g.db_user.id and not art.is_visible: + abort(404) + reviews = ArtworkReview.query.filter_by(artwork_id=art.id).all() + result = [] + for rev in reviews: + aut = User.query.get(rev.author_id) + result.append({ + "id": rev.id, + "author": aut.alias, + "grade": rev.grade, + "description": rev.description, + "parent_ar_id": rev.parent_ar_id, + "created_at": rev.created_at, + "updated_at": rev.updated_at + }) + return jsonify(result) + +# Submit a new review for the specified artwork (access to gallery enforced). +@app.route("/api/private/artwork//review", methods=["POST"]) +@oidc_required +def create_artwork_review(artwork_id): + art = Artwork.query.get_or_404(artwork_id) + gal = Gallery.query.get(art.gallery_id) + if not gal.is_public: + member = GalleryMember.query.filter_by(gallery_id=art.gallery_id, user_id=g.db_user.id, status="accepted").first() + if gal.owner_id != g.db_user.id and not member: + abort(403) + if art.creator_id != g.db_user.id and not art.is_visible: + abort(404) + data = request.json + review = ArtworkReview( + artwork_id=art.id, + author_id=g.db_user.id, + grade=data.get("grade"), + description=data.get("description"), + parent_ar_id=data.get("parent_ar_id") + ) + db.session.add(review) + db.session.commit() + event = { + "type": "artwork_review_created", + "data": {"user_id": review.author_id, "artwork_id": review.artwork_id_id, "artwork_review_id": review.id} + } + redis_client.publish('events', json.dumps(event)) + return jsonify({"id": review.id, "message": "Review created"}), 201 + +# Edit an existing artwork review (author only). +@app.route("/api/private/artworks/review/", methods=["PUT"]) +@oidc_required +def update_artwork_review(review_id): + rev = ArtworkReview.query.get_or_404(review_id) + if rev.author_id != g.db_user.id: + abort(403) + art = Artwork.query.get_or_404(rev.artwork_id) + gal = Gallery.query.get(art.gallery_id) + if not gal.is_public: + member = GalleryMember.query.filter_by(gallery_id=art.gallery_id, user_id=g.db_user.id, status="accepted").first() + if gal.owner_id != g.db_user.id and not member: + abort(403) + if art.creator_id != g.db_user.id and not art.is_visible: + abort(404) + data = request.json + rev.grade = data.get("grade", rev.grade) + rev.description = data.get("description", rev.description) + db.session.commit() + event = { + "type": "artwork_review_updated", + "data": {"user_id": rev.author_id, "artwork_id": rev.artwork_id_id, "artwork_review_id": rev.id} + } + redis_client.publish('events', json.dumps(event)) + return jsonify({"message": "Review updated"}) + +# Retrieve all artwork reviews written by the authenticated user. +@app.route("/api/private/artworks/reviews/given", methods=["GET"]) +@oidc_required +def get_given_artwork_reviews(): + reviews = ArtworkReview.query.filter_by(author_id=g.db_user.id).all() + result = [] + for rev in reviews: + art = Artwork.query.get(rev.artwork_id) + result.append({ + "review_id": rev.id, + "artwork_id": art.id, + "artwork_title": art.title, + "grade": rev.grade, + "description": rev.description, + "parent_ar_id": rev.parent_ar_id, + "created_at": rev.created_at, + "updated_at": rev.updated_at + }) + return jsonify(result) + +# List all reviews received on artworks owned by the authenticated user. +@app.route("/api/private/artworks/reviews/received", methods=["GET"]) +@oidc_required +def get_received_artwork_reviews(): + artworks = Artwork.query.filter_by(creator_id=g.db_user.id).all() + result = [] + for art in artworks: + reviews = ArtworkReview.query.filter_by(artwork_id=art.id).all() + for rev in reviews: + author = User.query.get(rev.author_id) + result.append({ + "review_id": rev.id, + "artwork_id": art.id, + "artwork_title": art.title, + "author": author.alias, + "grade": rev.grade, + "description": rev.description, + "parent_ar_id": rev.parent_ar_id, + "created_at": rev.created_at, + "updated_at": rev.updated_at + }) + return jsonify(result) + if __name__ == "__main__": - with app.app_context(): - db.create_all() app.run(host='0.0.0.0',port=5002, debug=True) - diff --git a/public/Dockerfile b/public/Dockerfile deleted file mode 100644 index fe06d38..0000000 --- a/public/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app -COPY . . - -RUN pip install flask flask_sqlalchemy pyjwt requests pymysql cryptography redis - -CMD ["python", "app.py"] - diff --git a/public/app.py b/public/app.py deleted file mode 100644 index 1f2150d..0000000 --- a/public/app.py +++ /dev/null @@ -1,76 +0,0 @@ -import time -import pymysql -from flask import Flask, jsonify -from flask_sqlalchemy import SQLAlchemy -import redis -import json - -app = Flask(__name__) -app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://myuser:mypassword@mysql:3306/mydb' -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - -db = SQLAlchemy(app) - -MYSQL_HOST = "mysql" -MYSQL_PORT = 3306 -MYSQL_USER = "myuser" -MYSQL_PASSWORD = "mypassword" -MYSQL_DB = "mydb" - -# Connexion Redis -redis_client = redis.Redis(host='redis', port=6379, decode_responses=True) - -while True: - try: - conn = pymysql.connect( - host=MYSQL_HOST, - port=MYSQL_PORT, - user=MYSQL_USER, - password=MYSQL_PASSWORD, - database=MYSQL_DB - ) - conn.close() - print("MySQL is up - continuing.") - break - except pymysql.err.OperationalError as e: - print("Waiting for MySQL...", e) - time.sleep(2) - -class Artiste(db.Model): - id = db.Column(db.Integer, primary_key=True) - nom = db.Column(db.String(100), nullable=False) - -class Galerie(db.Model): - id = db.Column(db.Integer, primary_key=True) - nom = db.Column(db.String(100), nullable=False) - -class Oeuvre(db.Model): - id = db.Column(db.Integer, primary_key=True) - titre = db.Column(db.String(200), nullable=False) - exposee = db.Column(db.Boolean, default=False) - -@app.route("/", methods=["GET"]) -def index(): - return "Public API", 200 - -@app.route("/artistes", methods=["GET"]) -def get_artistes(): - artistes = Artiste.query.all() - return jsonify([{"id": a.id, "nom": a.nom} for a in artistes]), 200 - -@app.route("/galeries", methods=["GET"]) -def get_galeries(): - galeries = Galerie.query.all() - return jsonify([{"id": g.id, "nom": g.nom} for g in galeries]), 200 - -@app.route("/oeuvres", methods=["GET"]) -def get_oeuvres(): - oeuvres = Oeuvre.query.filter_by(exposee=True).all() - return jsonify([{"id": o.id, "titre": o.titre} for o in oeuvres]), 200 - - -if __name__ == "__main__": - with app.app_context(): - db.create_all() - app.run(host='0.0.0.0',port=5001, debug=True) -