diff --git a/backend/app.py b/backend/app.py index bdf05170c..8305b82b7 100644 --- a/backend/app.py +++ b/backend/app.py @@ -20,6 +20,8 @@ import requests import secrets from urllib.parse import urlencode +from werkzeug.utils import secure_filename +import magic import logging from datetime import datetime, timedelta, timezone @@ -248,6 +250,11 @@ def _apply_security_headers(response): response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' response.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()' response.headers['X-Frame-Options'] = 'DENY' + + # Add Cache-Control for 3D assets and static images + if request.path.endswith(('.gltf', '.obj', '.png')): + response.headers['Cache-Control'] = 'public, max-age=31536000, immutable' + return response # ===================================================================== @@ -1904,6 +1911,75 @@ def logout(): return resp, status +UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads', 'avatars') +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} +ALLOWED_MIME_TYPES = {'image/png', 'image/jpeg', 'image/gif'} + +def allowed_file(filename): + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + +@app.route('/api/v1/users//avatar', methods=['POST']) +@jwt_required() +def upload_avatar(user_id): + """Upload and update user avatar with MIME type and magic number validation.""" + current_user_id = get_jwt_identity() + + if str(user_id) != str(current_user_id): + return forbidden_error("Unauthorized access to another user's profile") + + if 'file' not in request.files: + return jsonify({"error": "No file part in the request"}), 400 + + file = request.files['file'] + + if file.filename == '': + return jsonify({"error": "No file selected for uploading"}), 400 + + if not allowed_file(file.filename): + return jsonify({"error": "Allowed file types are png, jpg, jpeg, gif"}), 400 + + # Validate Content-Type header + file_content_type = file.content_type + if file_content_type not in ALLOWED_MIME_TYPES: + return jsonify({"error": "Invalid Content-Type"}), 400 + + # Verify file magic numbers + file_bytes = file.read(2048) + file.seek(0) # Reset file pointer after reading + + try: + mime = magic.Magic(mime=True) + file_mime = mime.from_buffer(file_bytes) + + if file_mime not in ALLOWED_MIME_TYPES: + return jsonify({"error": "Invalid file content (magic number mismatch)"}), 400 + except Exception as e: + logger.error(f"Error validating file magic numbers: {e}") + return jsonify({"error": "Could not validate file format"}), 500 + + filename = secure_filename(file.filename) + unique_filename = f"user_{user_id}_{filename}" + filepath = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename) + + try: + file.save(filepath) + + user = User.query.get(user_id) + if user: + user.profile_picture = f"/uploads/avatars/{unique_filename}" + db.session.commit() + + return jsonify({ + "message": "Avatar uploaded successfully", + "profile_picture": f"/uploads/avatars/{unique_filename}" + }), 200 + except Exception as e: + logger.error(f"Failed to save avatar: {e}") + return jsonify({"error": "Failed to save file"}), 500 + + @app.route('/api/v1/auth/forgot-password', methods=['POST']) @csrf.exempt @limiter.limit("5 per minute") diff --git a/backend/requirements.txt b/backend/requirements.txt index 5726fbba4..1940cad79 100644 Binary files a/backend/requirements.txt and b/backend/requirements.txt differ diff --git a/frontend/data/contributors.json b/frontend/data/contributors.json index 725dfdd51..b6c1e2c2f 100644 --- a/frontend/data/contributors.json +++ b/frontend/data/contributors.json @@ -197,6 +197,28 @@ "site_admin": false, "contributions": 23 }, + { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false, + "contributions": 13 + }, { "login": "github-actions[bot]", "id": 41898282, diff --git a/netlify.toml b/netlify.toml index 28bb31902..4db434a82 100644 --- a/netlify.toml +++ b/netlify.toml @@ -5,4 +5,18 @@ functions = "netlify/functions" [build.environment] PYTHON_VERSION = "3.10" -NODE_VERSION = "20" + +[[headers]] + for = "/*.gltf" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +[[headers]] + for = "/*.obj" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +[[headers]] + for = "/*.png" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable"