From 8aea2666e33f0c82247dd17c217a8a434d54dbc1 Mon Sep 17 00:00:00 2001 From: nanehambardzumyan Date: Mon, 14 Jul 2025 14:55:24 +0400 Subject: [PATCH 1/3] Add reset endpoints for the auth, todos and users to be reconfigured at runtime --- .github/workflows/docker-build-push.yml | 70 +++++++ app/config/auth_config.py | 76 ++++++- app/main.py | 33 +-- app/middleware/auth_middleware.py | 33 ++- app/routes/auth.py | 255 ++++++++++++++++++++++-- app/routes/todos.py | 104 +++++++++- app/services/auth_service.py | 120 ++++++++++- app/services/todo_service.py | 86 +++++++- 8 files changed, 728 insertions(+), 49 deletions(-) create mode 100644 .github/workflows/docker-build-push.yml diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml new file mode 100644 index 0000000..c0fd213 --- /dev/null +++ b/.github/workflows/docker-build-push.yml @@ -0,0 +1,70 @@ +name: Build and Push Docker Image +run-name: Build and push todo-api image for ${{ github.ref_name }} by @${{ github.actor }} + +on: + push: + branches: + - 'main' + - 'develop' + tags: + - 'v*' + pull_request: + branches: + - 'main' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + # set latest tag for default branch + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true diff --git a/app/config/auth_config.py b/app/config/auth_config.py index d6eae55..02522cb 100644 --- a/app/config/auth_config.py +++ b/app/config/auth_config.py @@ -12,18 +12,84 @@ def __init__(self): self.api_key = None self.jwt_secret = None self.session_secret = None - + def configure_api_key(self, api_key): self.auth_method = AuthMethod.API_KEY self.api_key = api_key - + def configure_jwt(self, secret_key): self.auth_method = AuthMethod.JWT self.jwt_secret = secret_key - + def configure_session(self, secret_key): self.auth_method = AuthMethod.SESSION self.session_secret = secret_key - + def disable_auth(self): - self.auth_method = AuthMethod.NONE \ No newline at end of file + self.auth_method = AuthMethod.NONE + + def update_from_dict(self, config_dict): + """Update authentication configuration from a dictionary. + + Args: + config_dict (dict): Configuration dictionary with 'auth' key containing: + - method: one of 'none', 'api_key', 'jwt', 'session' + - api_key: API key (required if method is 'api_key') + - secret: Secret key (required if method is 'jwt' or 'session') + + Raises: + ValueError: If configuration is invalid + """ + auth_config = config_dict.get('auth', {}) + method = auth_config.get('method', 'none') + + # Validate method + valid_methods = [e.value for e in AuthMethod] + if method not in valid_methods: + raise ValueError(f"Invalid authentication method: {method}. Must be one of: {valid_methods}") + + # Reset current configuration + self.auth_method = AuthMethod.NONE + self.api_key = None + self.jwt_secret = None + self.session_secret = None + + # Apply new configuration + if method == 'none': + self.disable_auth() + elif method == 'api_key': + api_key = auth_config.get('api_key') + if not api_key: + raise ValueError("API key must be provided when using api_key authentication") + self.configure_api_key(api_key) + elif method == 'jwt': + secret = auth_config.get('secret') + if not secret: + raise ValueError("Secret key must be provided when using JWT authentication") + self.configure_jwt(secret) + elif method == 'session': + secret = auth_config.get('secret') + if not secret: + raise ValueError("Secret key must be provided when using session authentication") + self.configure_session(secret) + + def to_dict(self): + """Convert current configuration to dictionary format. + + Returns: + dict: Configuration dictionary in the same format as the YAML file + """ + config = { + 'auth': { + 'method': self.auth_method.value + } + } + + if self.auth_method == AuthMethod.API_KEY and self.api_key: + config['auth']['api_key'] = self.api_key + elif self.auth_method == AuthMethod.JWT and self.jwt_secret: + config['auth']['secret'] = self.jwt_secret + elif self.auth_method == AuthMethod.SESSION and self.session_secret: + config['auth']['secret'] = self.session_secret + + return config diff --git a/app/main.py b/app/main.py index 30e9c93..1392a54 100644 --- a/app/main.py +++ b/app/main.py @@ -4,7 +4,7 @@ from routes.docs import docs_bp from routes.notes import notes_bp from routes.auth import auth_bp, init_auth_routes -from middleware.auth_middleware import AuthMiddleware +from middleware.auth_middleware import AuthMiddleware, set_auth_middleware_instance from utils.config import load_config, load_initial_todos, load_initial_users from utils.auth import setup_auth_config from services.auth_service import init_auth_service, add_user @@ -13,27 +13,27 @@ def create_app(auth_config): """Create and configure the Flask application. - + This function: 1. Creates a new Flask instance 2. Configures app settings and secrets 3. Sets up authentication middleware 4. Registers blueprints with their URL prefixes - + Args: auth_config: Authentication configuration object - + Returns: Flask: Configured Flask application instance """ app = Flask(__name__) - + # Configure application settings app.config['JSONIFY_PRETTYPRINT_REGULAR'] = True app.config['SECRET_KEY'] = secrets.token_hex(32) # Generate secure random secret key app.config['auth_config'] = auth_config app.config['initial_todos'] = load_initial_todos() # Load initial todos from config file - + # Configure Swagger template = { "swagger": "2.0", @@ -43,26 +43,29 @@ def create_app(auth_config): "version": "1.0.0" } } - + app.config['SWAGGER'] = { 'title': 'Todo API', 'uiversion': 3, 'specs_route': '/', 'url_prefix': '/swagger' } - + Swagger(app, template=template) - + # Set up authentication init_auth_routes(auth_config) auth_middleware = AuthMiddleware(auth_config) - + + # Store the middleware instance globally for runtime updates + set_auth_middleware_instance(auth_middleware) + # Define routes that require authentication protected_blueprints = { todos_bp: "/todos", # Todo management endpoints notes_bp: "/notes" # Note management endpoints } - + # Register protected routes with authentication middleware for blueprint, url_prefix in protected_blueprints.items(): auth_middleware.protect_blueprint(blueprint) @@ -84,17 +87,17 @@ def seed_users(): try: # Load authentication configuration from config file auth_method, secret = load_config() - + # Set up authentication based on configuration auth_config = setup_auth_config(auth_method, secret) seed_users() - + # Initialize auth service init_auth_service(auth_config) - + # Create and configure the application app = create_app(auth_config) - + # Start the server app.run(host="0.0.0.0", port=8000) # Listen on all interfaces, port 8000 except ValueError as e: diff --git a/app/middleware/auth_middleware.py b/app/middleware/auth_middleware.py index c2c6a1e..4d88eb3 100644 --- a/app/middleware/auth_middleware.py +++ b/app/middleware/auth_middleware.py @@ -54,11 +54,38 @@ def _validate_session(self): """Validate session authentication""" if not session.get("authenticated"): return jsonify({"error": "Valid session required"}), 401 - + # Check if session has been invalidated current_session = request.cookies.get('session') if current_session and current_session in invalidated_sessions: session.clear() return jsonify({"error": "Session has been invalidated"}), 401 - - return None \ No newline at end of file + + return None + +# Global middleware instance for runtime updates +_global_middleware_instance = None + +def reset_auth_middleware(new_config: AuthConfig): + """Reset the global auth middleware instance with new configuration. + + This function updates the middleware configuration that's used + by all protected blueprints. Due to Flask's blueprint registration + mechanics, we update the global instance that blueprints reference. + + Args: + new_config: New AuthConfig instance + """ + global _global_middleware_instance + if _global_middleware_instance: + _global_middleware_instance.config = new_config + print(f"Auth middleware reset with new configuration: {new_config.auth_method.value}") + +def get_auth_middleware_instance(): + """Get the global middleware instance.""" + return _global_middleware_instance + +def set_auth_middleware_instance(instance): + """Set the global middleware instance.""" + global _global_middleware_instance + _global_middleware_instance = instance diff --git a/app/routes/auth.py b/app/routes/auth.py index 34dbfc1..4fe7c18 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -8,7 +8,8 @@ login_jwt, login_session, logout_jwt, - logout_session + logout_session, + reset_users ) # --- Configuration --- @@ -28,7 +29,7 @@ def signup(): """ if auth_config.auth_method == AuthMethod.API_KEY: return jsonify({"error": "Signup not available with API key authentication"}), 400 - + data = request.get_json() return signup_user(data) @@ -40,17 +41,17 @@ def login(): """ if auth_config.auth_method == AuthMethod.API_KEY: return jsonify({"error": "Login not available with API key authentication"}), 400 - + data = request.get_json() if not data or "username" not in data or "password" not in data: return jsonify({"error": "Username and password are required"}), 400 - + username = data["username"] password = data["password"] - + if auth_config.auth_method == AuthMethod.JWT: return login_jwt(username, password) - + return login_session(username, password) @auth_bp.route("/logout", methods=["POST"]) @@ -68,17 +69,17 @@ def logout(): if not auth_header or not auth_header.startswith('Bearer '): return jsonify({"error": "Access token is required in Authorization header"}), 401 access_token = auth_header.split(' ')[1] - + if not request.is_json: return jsonify({"error": "Request must be JSON"}), 415 - + data = request.get_json() refresh_token = data.get("refresh_token") if not refresh_token: return jsonify({"error": "Refresh token is required in request body"}), 400 - + return logout_jwt(access_token, refresh_token) - + return logout_session() @auth_bp.route("/refresh", methods=["POST"]) @@ -93,13 +94,239 @@ def refresh_token(): username = validate_refresh_token(refresh_token) if not username: return jsonify({"error": "Invalid refresh token"}), 401 - + access_token, new_refresh_token = generate_jwt_token(username) - + if refresh_token in refresh_tokens: del refresh_tokens[refresh_token] - + return jsonify({ "access_token": access_token, "refresh_token": new_refresh_token - }), 200 \ No newline at end of file + }), 200 + +@auth_bp.route("/reset", methods=["POST"]) +def reset_config(): + """ + Reset authentication configuration at runtime + --- + tags: + - Authentication + summary: Update authentication configuration at runtime + description: | + Updates the authentication configuration without restarting the server or + modifying the original auth_config.yml file. All existing tokens and sessions + are invalidated for security reasons. + parameters: + - in: body + name: config + description: Authentication configuration + required: true + schema: + type: object + required: + - auth + properties: + auth: + type: object + required: + - method + properties: + method: + type: string + enum: [none, api_key, jwt, session] + description: Authentication method to use + api_key: + type: string + description: API key (required if method is api_key) + secret: + type: string + description: Secret key (required if method is jwt or session) + example: + method: "api_key" + api_key: "my-secure-api-key" + responses: + 200: + description: Configuration updated successfully + schema: + type: object + properties: + message: + type: string + example: "Authentication configuration updated successfully" + new_config: + type: object + properties: + auth: + type: object + properties: + method: + type: string + api_key: + type: string + secret: + type: string + 400: + description: Invalid configuration or request format + schema: + type: object + properties: + error: + type: string + example: "Invalid authentication method: invalid_method" + 415: + description: Request must be JSON + schema: + type: object + properties: + error: + type: string + example: "Request must be JSON" + 500: + description: Internal server error + schema: + type: object + properties: + error: + type: string + example: "Failed to update configuration: Internal error" + """ + from flask import current_app + from services.auth_service import reset_auth_service + from middleware.auth_middleware import reset_auth_middleware + + if not request.is_json: + return jsonify({"error": "Request must be JSON"}), 415 + + try: + new_config = request.get_json() + + # Validate configuration format + if not isinstance(new_config, dict) or 'auth' not in new_config: + return jsonify({ + "error": "Invalid configuration format. Expected: {'auth': {'method': '...', ...}}" + }), 400 + + # Update the global auth configuration + auth_config.update_from_dict(new_config) + + # Update the Flask app configuration + current_app.config['auth_config'] = auth_config + + # Reset auth service with new configuration + reset_auth_service(auth_config) + + # Reset auth middleware with new configuration + reset_auth_middleware(auth_config) + + return jsonify({ + "message": "Authentication configuration updated successfully", + "new_config": auth_config.to_dict() + }), 200 + + except ValueError as e: + return jsonify({"error": str(e)}), 400 + except Exception as e: + return jsonify({"error": f"Failed to update configuration: {str(e)}"}), 500 + +@auth_bp.route("/reset-users", methods=["POST"]) +def reset_users_endpoint(): + """Reset users with data from uploaded JSON file + --- + tags: + - Authentication + summary: Reset all users with data from JSON file + description: | + Replaces all existing users with data from an uploaded JSON file. + The original initial_users.json file remains unchanged. + All existing authentication tokens and sessions are cleared for security. + + The uploaded file should be in the same format as initial_users.json: + { + "data": [ + { + "username": "user1", + "password": "password123" + } + ] + } + consumes: + - multipart/form-data + parameters: + - in: formData + name: file + type: file + required: true + description: JSON file containing users data + responses: + 200: + description: Users reset successfully + schema: + type: object + properties: + message: + type: string + example: "Users reset successfully. Loaded 2 users." + users_count: + type: integer + example: 2 + usernames: + type: array + items: + type: string + example: ["user1", "user2"] + filename: + type: string + example: "my_users.json" + 400: + description: Invalid file format or data + schema: + type: object + properties: + error: + type: string + example: "Invalid JSON format: Expecting ',' delimiter" + 415: + description: No file provided + schema: + type: object + properties: + error: + type: string + example: "No file provided" + """ + from flask import request, jsonify + + # Check if file was uploaded + if 'file' not in request.files: + return jsonify({"error": "No file provided. Please upload a JSON file."}), 400 + + file = request.files['file'] + + # Check if file was actually selected + if file.filename == '': + return jsonify({"error": "No file selected. Please select a JSON file."}), 400 + + # Check file extension + if not file.filename.lower().endswith('.json'): + return jsonify({"error": "Invalid file type. Please upload a JSON file."}), 400 + + try: + # Read file content + file_content = file.read().decode('utf-8') + + # Reset users with file content + response, status_code = reset_users(file_content) + + # Add filename to successful response + if status_code == 200: + response_data = response.get_json() + response_data['filename'] = file.filename + return jsonify(response_data), status_code + + return response, status_code + + except UnicodeDecodeError: + return jsonify({"error": "Invalid file encoding. Please ensure the file is UTF-8 encoded."}), 400 + except Exception as e: + return jsonify({"error": f"Failed to process file: {str(e)}"}), 500 diff --git a/app/routes/todos.py b/app/routes/todos.py index 91d8aa1..770fd3a 100644 --- a/app/routes/todos.py +++ b/app/routes/todos.py @@ -253,4 +253,106 @@ def delete_todo(todo_id): 404: description: Todo not found """ - return TodoService.delete_todo(todo_id) \ No newline at end of file + return TodoService.delete_todo(todo_id) + +@todos_bp.route("/reset", methods=["POST"]) +def reset_todos(): + """Reset todos with data from uploaded JSON file + --- + tags: + - todos + summary: Reset all todos with data from JSON file + description: | + Replaces all existing todos with data from an uploaded JSON file. + The original initial_todos.json file remains unchanged. + This is useful for testing different todo datasets or resetting to a clean state. + + The uploaded file should be in the same format as initial_todos.json: + { + "todos": [ + { + "id": 1, + "title": "Todo title", + "done": false, + "description": "Optional description" + } + ] + } + consumes: + - multipart/form-data + parameters: + - in: formData + name: file + type: file + required: true + description: JSON file containing todos data + responses: + 200: + description: Todos reset successfully + schema: + type: object + properties: + message: + type: string + example: "Todos reset successfully. Loaded 4 todos." + todos_count: + type: integer + example: 4 + next_id: + type: integer + example: 5 + filename: + type: string + example: "my_todos.json" + 400: + description: Invalid file format or data + schema: + type: object + properties: + error: + type: string + example: "Invalid JSON format: Expecting ',' delimiter" + 415: + description: No file provided + schema: + type: object + properties: + error: + type: string + example: "No file provided" + """ + from flask import request, jsonify + + # Check if file was uploaded + if 'file' not in request.files: + return jsonify({"error": "No file provided. Please upload a JSON file."}), 400 + + file = request.files['file'] + + # Check if file was actually selected + if file.filename == '': + return jsonify({"error": "No file selected. Please select a JSON file."}), 400 + + # Check file extension + if not file.filename.lower().endswith('.json'): + return jsonify({"error": "Invalid file type. Please upload a JSON file."}), 400 + + try: + # Read file content + file_content = file.read().decode('utf-8') + + # Reset todos with file content + response, status_code = TodoService.reset_todos(file_content) + + # Add filename to successful response + if status_code == 200: + response_data = response.get_json() + response_data['filename'] = file.filename + return jsonify(response_data), status_code + + return response, status_code + + except UnicodeDecodeError: + return jsonify({"error": "Invalid file encoding. Please ensure the file is UTF-8 encoded."}), 400 + except Exception as e: + return jsonify({"error": f"Failed to process file: {str(e)}"}), 500 diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 8c5b174..ec1c1c6 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -71,10 +71,10 @@ def signup_user(data): """Register a new user with username and password""" if not data or "username" not in data or "password" not in data: return jsonify({"error": "Username and password are required"}), 400 - + if is_username_taken(data["username"]): return jsonify({"error": "Username already exists"}), 400 - + add_user(data["username"], data["password"]) return jsonify({"message": "Signup successful. Please log in to continue."}), 201 @@ -83,7 +83,7 @@ def login_jwt(username, password): user = validate_credentials(username, password) if not user: return jsonify({"error": "Invalid username or password"}), 401 - + access_token, refresh_token = generate_jwt_token(username) return jsonify({ "message": "Login successful", @@ -96,7 +96,7 @@ def login_session(username, password): user = validate_credentials(username, password) if not user: return jsonify({"error": "Invalid username or password"}), 401 - + session["authenticated"] = True session["username"] = username return jsonify({"message": "Login successful"}) @@ -105,25 +105,125 @@ def logout_jwt(access_token, refresh_token): """Invalidate JWT access and refresh tokens""" if not access_token or not refresh_token: return jsonify({"error": "Both access token and refresh token are required"}), 400 - + blacklist_token(access_token) if refresh_token in refresh_tokens: del refresh_tokens[refresh_token] - + return jsonify({"message": "Logout successful"}) def logout_session(): """Clear user session if authenticated and invalidate the session cookie""" if not session.get("authenticated"): return jsonify({"error": "Not authenticated"}), 401 - + response = jsonify({"message": "Logout successful"}) - + # Add current session ID to invalidated sessions set if request.cookies.get('session'): invalidated_sessions.add(request.cookies.get('session')) - + session.clear() # Set the session cookie to expire immediately response.set_cookie('session', '', expires=0) - return response \ No newline at end of file + return response + +def reset_auth_service(new_config: AuthConfig): + """Reset the auth service with new configuration. + + This function: + 1. Updates the global auth_config + 2. Clears all existing tokens and sessions for security + 3. Re-initializes the auth service + + Args: + new_config: New AuthConfig instance + """ + global auth_config + auth_config = new_config + + # Clear all existing authentication tokens and sessions for security + # This ensures that after a config change, users need to re-authenticate + refresh_tokens.clear() + blacklisted_tokens.clear() + invalidated_sessions.clear() + + print(f"Auth service reset with new configuration: {auth_config.auth_method.value}") + +def reset_users(file_content): + """Reset users with new data from uploaded JSON file. + + This method: + 1. Parses the JSON file content + 2. Validates the file format and user data + 3. Clears all existing users and their tokens/sessions + 4. Loads new users from the file data + + Args: + file_content (str): JSON file content as string + + Returns: + tuple: JSON response and status code + """ + import json + + global users + + # Parse JSON content + try: + data = json.loads(file_content) + except json.JSONDecodeError as e: + return jsonify({"error": f"Invalid JSON format: {str(e)}"}), 400 + + # Validate file structure - expect {"data": [...]} format (same as initial_users.json) + if not isinstance(data, dict) or 'data' not in data: + return jsonify({"error": "Invalid file format. Expected JSON with 'data' array field."}), 400 + + new_users_data = data['data'] + + # Validate the users data + if not isinstance(new_users_data, list): + return jsonify({"error": "Invalid users format. Expected an array of user objects."}), 400 + + # Validate each user item + for i, user_data in enumerate(new_users_data): + if not isinstance(user_data, dict): + return jsonify({"error": f"Invalid user at index {i}. Expected an object."}), 400 + + required_fields = ['username', 'password'] + for field in required_fields: + if field not in user_data: + return jsonify({"error": f"Missing required field '{field}' in user at index {i}."}), 400 + + # Validate field types + if not isinstance(user_data['username'], str): + return jsonify({"error": f"Invalid 'username' type in user at index {i}. Expected string."}), 400 + if not isinstance(user_data['password'], str): + return jsonify({"error": f"Invalid 'password' type in user at index {i}. Expected string."}), 400 + + # Validate username is not empty + if not user_data['username'].strip(): + return jsonify({"error": f"Empty username in user at index {i}."}), 400 + if not user_data['password'].strip(): + return jsonify({"error": f"Empty password in user at index {i}."}), 400 + + # Check for duplicate usernames + usernames = [user['username'] for user in new_users_data] + if len(usernames) != len(set(usernames)): + return jsonify({"error": "Duplicate usernames found in the data."}), 400 + + # Clear existing users and all authentication data for security + users.clear() + refresh_tokens.clear() + blacklisted_tokens.clear() + invalidated_sessions.clear() + + # Load new users + for user_data in new_users_data: + users.append(User(user_data['username'], user_data['password'])) + + return jsonify({ + "message": f"Users reset successfully. Loaded {len(new_users_data)} users.", + "users_count": len(new_users_data), + "usernames": [user.username for user in users] + }), 200 diff --git a/app/services/todo_service.py b/app/services/todo_service.py index 9d4cb87..f0e3d19 100644 --- a/app/services/todo_service.py +++ b/app/services/todo_service.py @@ -50,7 +50,7 @@ def get_all_todos(request): Returns: tuple: JSON response containing list of todos and HTTP status code - + Examples: GET /todos - Returns all todos GET /todos?done=true - Returns all completed todos @@ -139,3 +139,87 @@ def delete_todo(todo_id): return jsonify({"error": "Todo not found"}), 404 del service.todos[todo_id] return '', 204 + + @staticmethod + def reset_todos(file_content): + """Reset todos with new data from uploaded JSON file. + + This method: + 1. Parses the JSON file content + 2. Validates the file format and todo data + 3. Clears all existing todos + 4. Loads new todos from the file data + 5. Updates the next_id counter appropriately + + Args: + file_content (str): JSON file content as string + + Returns: + tuple: JSON response and status code + """ + import json + + service = TodoService.get_instance() + + # Parse JSON content + try: + data = json.loads(file_content) + except json.JSONDecodeError as e: + return jsonify({"error": f"Invalid JSON format: {str(e)}"}), 400 + + # Validate file structure - expect {"todos": [...]} format + if not isinstance(data, dict) or 'todos' not in data: + return jsonify({"error": "Invalid file format. Expected JSON with 'todos' array field."}), 400 + + new_todos_data = data['todos'] + + # Validate the todos data + if not isinstance(new_todos_data, list): + return jsonify({"error": "Invalid todos format. Expected an array of todo objects."}), 400 + + # Validate each todo item + for i, todo_data in enumerate(new_todos_data): + if not isinstance(todo_data, dict): + return jsonify({"error": f"Invalid todo at index {i}. Expected an object."}), 400 + + required_fields = ['id', 'title', 'done'] + for field in required_fields: + if field not in todo_data: + return jsonify({"error": f"Missing required field '{field}' in todo at index {i}."}), 400 + + # Validate field types + if not isinstance(todo_data['id'], int): + return jsonify({"error": f"Invalid 'id' type in todo at index {i}. Expected integer."}), 400 + if not isinstance(todo_data['title'], str): + return jsonify({"error": f"Invalid 'title' type in todo at index {i}. Expected string."}), 400 + if not isinstance(todo_data['done'], bool): + return jsonify({"error": f"Invalid 'done' type in todo at index {i}. Expected boolean."}), 400 + if 'description' in todo_data and not isinstance(todo_data['description'], str): + return jsonify({"error": f"Invalid 'description' type in todo at index {i}. Expected string."}), 400 + + # Check for duplicate IDs + ids = [todo['id'] for todo in new_todos_data] + if len(ids) != len(set(ids)): + return jsonify({"error": "Duplicate todo IDs found in the data."}), 400 + + # Clear existing todos + service.todos.clear() + service.next_id = 1 + + # Load new todos + for todo_data in new_todos_data: + todo_id = todo_data['id'] + service.todos[todo_id] = Todo( + todo_id, + todo_data['title'], + todo_data['done'], + todo_data.get('description', '') + ) + # Update next_id to be greater than the highest existing id + service.next_id = max(service.next_id, todo_id + 1) + + return jsonify({ + "message": f"Todos reset successfully. Loaded {len(new_todos_data)} todos.", + "todos_count": len(new_todos_data), + "next_id": service.next_id + }), 200 From e00551a7d6307d1b35de3bdd984780b163247c44 Mon Sep 17 00:00:00 2001 From: nanehambardzumyan Date: Mon, 14 Jul 2025 16:20:13 +0400 Subject: [PATCH 2/3] rm attestation --- .github/workflows/docker-build-push.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index c0fd213..211d21b 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -61,10 +61,3 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max - - - name: Generate artifact attestation - uses: actions/attest-build-provenance@v1 - with: - subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} - subject-digest: ${{ steps.build.outputs.digest }} - push-to-registry: true From 13b915b27c68f3439005973822fd51cff552c769 Mon Sep 17 00:00:00 2001 From: nanehambardzumyan Date: Tue, 15 Jul 2025 00:32:25 +0400 Subject: [PATCH 3/3] Improvements --- app/routes/auth.py | 10 ++++------ app/routes/todos.py | 3 +-- app/services/auth_service.py | 4 ++-- app/services/todo_service.py | 2 +- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/app/routes/auth.py b/app/routes/auth.py index 4fe7c18..2f69852 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,5 +1,6 @@ -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, current_app from config.auth_config import AuthMethod, AuthConfig +from middleware.auth_middleware import reset_auth_middleware from services.auth_service import ( signup_user, validate_refresh_token, @@ -9,7 +10,8 @@ login_session, logout_jwt, logout_session, - reset_users + reset_users, + reset_auth_service, ) # --- Configuration --- @@ -191,9 +193,6 @@ def reset_config(): type: string example: "Failed to update configuration: Internal error" """ - from flask import current_app - from services.auth_service import reset_auth_service - from middleware.auth_middleware import reset_auth_middleware if not request.is_json: return jsonify({"error": "Request must be JSON"}), 415 @@ -295,7 +294,6 @@ def reset_users_endpoint(): type: string example: "No file provided" """ - from flask import request, jsonify # Check if file was uploaded if 'file' not in request.files: diff --git a/app/routes/todos.py b/app/routes/todos.py index 770fd3a..26164f8 100644 --- a/app/routes/todos.py +++ b/app/routes/todos.py @@ -1,4 +1,4 @@ -from flask import Blueprint, request +from flask import Blueprint, request, jsonify from services.todo_service import TodoService todos_bp = Blueprint("todos", __name__) @@ -321,7 +321,6 @@ def reset_todos(): type: string example: "No file provided" """ - from flask import request, jsonify # Check if file was uploaded if 'file' not in request.files: diff --git a/app/services/auth_service.py b/app/services/auth_service.py index ec1c1c6..ebbab4a 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -1,7 +1,8 @@ import jwt +import json import datetime import secrets -from config.auth_config import AuthConfig, AuthMethod +from config.auth_config import AuthConfig from models.user import User from flask import session, jsonify, request @@ -165,7 +166,6 @@ def reset_users(file_content): Returns: tuple: JSON response and status code """ - import json global users diff --git a/app/services/todo_service.py b/app/services/todo_service.py index f0e3d19..e9cbcea 100644 --- a/app/services/todo_service.py +++ b/app/services/todo_service.py @@ -1,5 +1,6 @@ from flask import jsonify, request, current_app from models.todo import Todo +import json class TodoService: _instance = None @@ -157,7 +158,6 @@ def reset_todos(file_content): Returns: tuple: JSON response and status code """ - import json service = TodoService.get_instance()