diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3309913 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,50 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ +*.egg-info/ +dist/ +build/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Git +.git/ +.gitignore + +# Environment files (will be mounted separately) +.env + +# Documentation +README.md + +# CI/CD +.github/ + +# Claude +.claude/ + +# Instance data (will be mounted as volume) +src/app/instance/ +src/app/static/images/ +src/app/static/barcodes/ + +# Test files +*.pyc +.pytest_cache/ +.coverage +htmlcov/ + +# OS +.DS_Store +Thumbs.db diff --git a/.env.example b/.env.example index a7aeed6..00cf69f 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,10 @@ SECRET_KEY=your-secret-key-change-this-in-production FLASK_DEBUG=1 +# Authentication Mode +# Options: 'entra' (Entra ID/Azure AD) or 'none' (no authentication for local testing) +AUTH_MODE=none + # Entra ID (Azure AD) Configuration # Get these values from your Azure Portal > App Registrations AZURE_CLIENT_ID=your-client-id-here diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..7f49d6f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,127 @@ +name: Bug Report +description: Report a bug or issue with the Flask AR API Demo Server +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! Please fill out the information below to help us resolve the issue. + + - type: dropdown + id: component + attributes: + label: Component + description: Which component is affected? + options: + - API Endpoints (login, arcontentfields, arinfo) + - Admin Interface + - Barcode Generation + - Image Serving + - Authentication (Entra ID) + - Database/Models + - Other + validations: + required: true + + - type: textarea + id: description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is. + placeholder: Describe the bug... + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior + placeholder: | + 1. Navigate to '...' + 2. Click on '...' + 3. Make API call to '...' + 4. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What did you expect to happen? + placeholder: Describe what should happen... + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened? + placeholder: Describe what actually happened... + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Error Logs/Screenshots + description: If applicable, add error logs, stack traces, or screenshots + placeholder: Paste logs or drag and drop screenshots here... + validations: + required: false + + - type: dropdown + id: environment + attributes: + label: Environment + description: Where are you running the application? + options: + - Local Development + - Docker + - Cloud Deployment + - Other + validations: + required: true + + - type: input + id: python-version + attributes: + label: Python Version + description: What version of Python are you using? + placeholder: e.g., 3.9.0, 3.10.2, 3.11.5 + validations: + required: false + + - type: input + id: flask-version + attributes: + label: Flask Version + description: What version of Flask are you using? + placeholder: e.g., 2.2.5 + validations: + required: false + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Add any other context about the problem here + placeholder: Any additional information... + validations: + required: false + + - type: checkboxes + id: checklist + attributes: + label: Pre-submission Checklist + description: Please confirm the following + options: + - label: I have searched existing issues to ensure this bug hasn't been reported + required: true + - label: I have included all relevant information above + required: true + - label: I can reproduce this bug consistently + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..09ba4a5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,155 @@ +name: Feature Request +description: Suggest a new feature or enhancement for the Flask AR API Demo Server +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a new feature! Please provide as much detail as possible to help us understand your request. + + - type: dropdown + id: component + attributes: + label: Component + description: Which component would this feature affect? + options: + - API Endpoints + - Admin Interface + - Barcode Generation + - Image Management + - Authentication/Authorization + - Database/Models + - Product Management + - Tenant Management + - Documentation + - Other + validations: + required: true + + - type: dropdown + id: feature-type + attributes: + label: Feature Type + description: What type of feature is this? + options: + - New Functionality + - Enhancement to Existing Feature + - UI/UX Improvement + - Performance Improvement + - Developer Experience + - Documentation + - Testing + - Security + - Other + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem Statement + description: Is your feature request related to a problem? Please describe. + placeholder: I'm frustrated when... / There's no way to... / It would be helpful if... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: Describe the solution you'd like to see + placeholder: I would like to be able to... / The system should... / Add a new endpoint that... + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Have you considered any alternative solutions or workarounds? + placeholder: I've thought about... / Another approach could be... / Currently using... + validations: + required: false + + - type: textarea + id: use-case + attributes: + label: Use Case + description: Describe your use case and how this feature would benefit you or others + placeholder: | + This feature would allow me to... + It would be useful for... + The expected workflow would be... + validations: + required: true + + - type: textarea + id: implementation + attributes: + label: Implementation Ideas + description: If you have ideas about how to implement this feature, please share them + placeholder: | + This could be implemented by... + Possible technical approach... + Required changes... + validations: + required: false + + - type: dropdown + id: priority + attributes: + label: Priority + description: How important is this feature to you? + options: + - Nice to have + - Would be useful + - Important + - Critical + validations: + required: true + + - type: dropdown + id: breaking + attributes: + label: Breaking Change + description: Would this feature require breaking changes to the API or existing functionality? + options: + - "No" + - "Yes" + - "Unsure" + validations: + required: true + + - type: textarea + id: mockups + attributes: + label: Mockups/Examples + description: If applicable, add mockups, screenshots, or examples from other systems + placeholder: Drag and drop images or paste links here... + validations: + required: false + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Add any other context, links, or references about the feature request + placeholder: Related to... / Similar to... / Reference documentation... + validations: + required: false + + - type: checkboxes + id: checklist + attributes: + label: Pre-submission Checklist + description: Please confirm the following + options: + - label: I have searched existing issues to ensure this feature hasn't been requested + required: true + - label: I have clearly described the feature and its benefits + required: true + - label: I am willing to help test this feature if implemented + required: false + - label: I am willing to contribute to the implementation + required: false diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..7fdda1f --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,35 @@ +name: Build and Push Docker Image + +on: + push: + tags: + - 'v*' # triggers only on version tags like v1.0.0, v25.04, etc. + +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 Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract version from tag + run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + + - name: Build and push Docker image with two tags + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + mattintech/kcapdemoserver:latest + mattintech/kcapdemoserver:${{ env.VERSION }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e440308 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Use Python 3.11 slim image as base +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + FLASK_APP=src/run.py + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better layer caching +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create necessary directories for runtime +RUN mkdir -p src/app/static/images src/app/static/barcodes src/app/instance + +# Expose port 5555 +EXPOSE 5555 + +# Run the application +CMD ["python", "src/run.py"] diff --git a/README.md b/README.md index a725579..4a7dc62 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,70 @@ This project is a simple Flask API for demonstrating AR content retrieval for ba ## Setup +### Option 1: Docker Hub (Recommended) + +Pull and run the pre-built image from Docker Hub: + +```bash +# Pull the latest image +docker pull mattintech/kcapdemoserver:latest + +# Run the container +docker run -p 5555:5000 \ + -e SECRET_KEY=your-secret-key-here \ + -e DATABASE_URL=sqlite:///data/kcap_demo.db \ + -v $(pwd)/data:/app/data \ + mattintech/kcapdemoserver:latest +``` + +Or use a specific version: + +```bash +docker pull mattintech/kcapdemoserver:v1.0.0 +docker run -p 5555:5000 --env-file .env mattintech/kcapdemoserver:v1.0.0 +``` + +### Option 2: Docker Compose + +1. **Clone the Repository:** + +```bash +git clone https://github.com/mattintech/KCAPDemoServer.git +cd KCAPDemoServer +``` + +2. **Configure Environment:** + +Copy `.env.example` to `.env` and update with your Azure AD credentials: + +```bash +cp .env.example .env +``` + +Edit `.env` and set your Azure AD configuration values. + +3. **Run with Docker Compose:** + +```bash +docker-compose up -d +``` + +The application will be available at `http://localhost:5555` + +4. **View Logs:** + +```bash +docker-compose logs -f +``` + +5. **Stop the Application:** + +```bash +docker-compose down +``` + +### Option 3: Local Python Setup + 1. **Clone the Repository:** ```bash @@ -49,23 +113,26 @@ cd pip install -r requirements.txt ``` -3. **Run the Server:** +3. **Configure Environment:** + +Copy `.env.example` to `.env` and update with your configuration. + +4. **Run the Server:** ```bash -python app.py +python src/run.py ``` -The application will automatically create the necessary directories and initialize the products.json file if it doesn't exist. +The application will automatically create the necessary directories and initialize the database if it doesn't exist. -4. **Access the Admin Interface:** +### Accessing the Application -Open your browser and navigate to: +**Admin Interface:** ``` http://localhost:5555/admin ``` -5. **Test the Endpoints:** - +**API Endpoints:** * Login: `GET http://localhost:5555/login` * Content Fields: `GET http://localhost:5555/arcontentfields` * AR Info (Example): `GET http://localhost:5555/arinfo?barcode=123456` @@ -105,6 +172,18 @@ The following barcode types are available: └── generate_barcode.html # Barcode generation page ``` +## Docker Hub + +Pre-built Docker images are automatically published to Docker Hub on each release: + +**Repository**: [mattintech/kcapdemoserver](https://hub.docker.com/r/mattintech/kcapdemoserver) + +**Available Tags**: +- `latest` - Most recent release +- `v1.0.0`, `v1.0.1`, etc. - Specific version releases + +For maintainers: See [DOCKER_HUB_SETUP.md](DOCKER_HUB_SETUP.md) for information on configuring automated builds. + ## Implementation Notes * The application is designed as a single server hosting both the API endpoints and admin interface. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..189a88d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +version: '3.8' + +services: + web: + build: . + container_name: kcap-demo-server + ports: + - "5555:5555" + environment: + - FLASK_DEBUG=1 + env_file: + - .env + volumes: + # Mount static directories for persistent storage + - ./src/app/static/images:/app/src/app/static/images + - ./src/app/static/barcodes:/app/src/app/static/barcodes + # Mount instance directory for database persistence + - ./src/app/instance:/app/src/app/instance + restart: unless-stopped diff --git a/src/app/blueprints/auth/routes.py b/src/app/blueprints/auth/routes.py index 08ab072..999704f 100644 --- a/src/app/blueprints/auth/routes.py +++ b/src/app/blueprints/auth/routes.py @@ -1,14 +1,20 @@ -"""Authentication routes for Entra ID login/logout""" +"""Authentication routes for Entra ID login/logout and no-auth mode""" import secrets -from flask import session, redirect, url_for, request, current_app, render_template, flash +from flask import session, redirect, url_for, request, current_app, render_template, flash, abort from . import auth_bp -from app.services.msal_service import MSALService from app.models.user import UserModel @auth_bp.route('/login') def login(): - """Initiate Entra ID login flow""" + """Initiate Entra ID login flow or redirect to role selection in no-auth mode""" + # Check if we're in no-auth mode + if current_app.config.get('AUTH_MODE') == 'none': + return redirect(url_for('auth.select_role')) + + # Entra ID mode + from app.services.msal_service import MSALService + # Generate state token for CSRF protection state = secrets.token_urlsafe(16) session['auth_state'] = state @@ -23,9 +29,51 @@ def login(): return redirect(auth_url) +@auth_bp.route('/select-role', methods=['GET', 'POST']) +def select_role(): + """Role selection page for no-auth mode""" + # Only available in no-auth mode + if current_app.config.get('AUTH_MODE') != 'none': + current_app.logger.warning(f"Attempted to access role selection in {current_app.config.get('AUTH_MODE')} mode") + abort(403, description="Role selection is only available in no-auth mode") + + if request.method == 'POST': + role = request.form.get('role', 'user') + + # Validate role + if role not in ['admin', 'user']: + role = 'user' + + # Create a mock user for testing + # Use a consistent ID based on role for session persistence + user_id = f"test-{role}-user" + + # Store user in session + session['user'] = { + 'id': user_id, + 'email': f'{role}@test.local', + 'name': f'Test {role.capitalize()}', + 'role': role + } + + current_app.logger.info(f"No-auth mode: User selected {role} role") + + # Redirect to the originally requested page or home + next_url = session.pop('next', None) + if next_url: + return redirect(next_url) + + return redirect(url_for('main.index')) + + return render_template('select_role.html') + + @auth_bp.route('/callback') def callback(): """Handle the callback from Entra ID after authentication""" + # Import MSAL service only when needed (Entra ID mode) + from app.services.msal_service import MSALService + # Verify state to prevent CSRF state = request.args.get('state') if state != session.get('auth_state'): @@ -104,6 +152,14 @@ def logout(): # Clear session session.clear() + # Check if we're in no-auth mode + if current_app.config.get('AUTH_MODE') == 'none': + # Just redirect to home in no-auth mode + return redirect(url_for('main.index')) + + # Entra ID mode - logout from Azure AD + from app.services.msal_service import MSALService + # Remove from MSAL cache MSALService.remove_account() diff --git a/src/app/config.py b/src/app/config.py index 64b2c8b..01387f0 100644 --- a/src/app/config.py +++ b/src/app/config.py @@ -28,6 +28,10 @@ class Config: SESSION_USE_SIGNER = True SESSION_FILE_DIR = os.path.join(DATA_FOLDER, 'flask_session') + # Authentication Mode + # Options: 'entra' (Entra ID/Azure AD) or 'none' (no authentication) + AUTH_MODE = os.environ.get('AUTH_MODE', 'entra').lower() + # Entra ID (Azure AD) Configuration AZURE_CLIENT_ID = os.environ.get('AZURE_CLIENT_ID') AZURE_CLIENT_SECRET = os.environ.get('AZURE_CLIENT_SECRET') diff --git a/src/app/decorators/auth.py b/src/app/decorators/auth.py index 5c3b27e..8b662f5 100644 --- a/src/app/decorators/auth.py +++ b/src/app/decorators/auth.py @@ -4,17 +4,36 @@ from flask import session, redirect, url_for, request, abort, current_app from app.models.user import UserModel +def _ensure_no_auth_user(): + """ + Helper function to ensure a user exists in session when AUTH_MODE is 'none'. + If no user in session, redirect to role selection page. + """ + if current_app.config.get('AUTH_MODE') == 'none' and 'user' not in session: + # Redirect to role selection page + session['next'] = request.url + return redirect(url_for('auth.select_role')) + return None + + def login_required(f): """ Decorator to require user authentication. Redirects to login if user is not authenticated. + In 'none' auth mode, redirects to role selection if no session exists. """ @wraps(f) def decorated_function(*args, **kwargs): - if 'user' not in session: - # Store the requested URL to redirect back after login + # Check if we're in no-auth mode and need to set up a user + if current_app.config.get('AUTH_MODE') == 'none': + redirect_response = _ensure_no_auth_user() + if redirect_response: + return redirect_response + elif 'user' not in session: + # Entra ID mode - redirect to login session['next'] = request.url return redirect(url_for('auth.login')) + return f(*args, **kwargs) return decorated_function @@ -26,7 +45,13 @@ def admin_required(f): """ @wraps(f) def decorated_function(*args, **kwargs): - if 'user' not in session: + # Check if we're in no-auth mode and need to set up a user + if current_app.config.get('AUTH_MODE') == 'none': + redirect_response = _ensure_no_auth_user() + if redirect_response: + return redirect_response + elif 'user' not in session: + # Entra ID mode - redirect to login session['next'] = request.url return redirect(url_for('auth.login')) @@ -47,7 +72,13 @@ def tenant_access_required(f): """ @wraps(f) def decorated_function(*args, **kwargs): - if 'user' not in session: + # Check if we're in no-auth mode and need to set up a user + if current_app.config.get('AUTH_MODE') == 'none': + redirect_response = _ensure_no_auth_user() + if redirect_response: + return redirect_response + elif 'user' not in session: + # Entra ID mode - redirect to login session['next'] = request.url return redirect(url_for('auth.login')) @@ -76,7 +107,13 @@ def settings_access_required(f): """ @wraps(f) def decorated_function(*args, **kwargs): - if 'user' not in session: + # Check if we're in no-auth mode and need to set up a user + if current_app.config.get('AUTH_MODE') == 'none': + redirect_response = _ensure_no_auth_user() + if redirect_response: + return redirect_response + elif 'user' not in session: + # Entra ID mode - redirect to login session['next'] = request.url return redirect(url_for('auth.login')) diff --git a/src/app/models/user.py b/src/app/models/user.py index 55a3ba3..372b193 100644 --- a/src/app/models/user.py +++ b/src/app/models/user.py @@ -67,8 +67,22 @@ class UserModel: return None @staticmethod - def get_by_id(user_id: int) -> Optional[Dict]: - """Get user by ID""" + def get_by_id(user_id) -> Optional[Dict]: + """Get user by ID (supports both integer IDs and string test IDs for no-auth mode)""" + # Handle test user IDs for no-auth mode + if isinstance(user_id, str) and user_id.startswith('test-'): + # Mock test user + role = 'admin' if 'admin' in user_id else 'user' + return { + 'id': user_id, + 'email': f'{role}@test.local', + 'azure_oid': None, + 'name': f'Test {role.capitalize()}', + 'role': role, + 'created_at': None, + 'updated_at': None + } + with get_db() as conn: cursor = conn.cursor() cursor.execute( @@ -211,13 +225,17 @@ class UserModel: return [row[0] for row in cursor.fetchall()] @staticmethod - def has_access_to_tenant(user_id: int, tenant_id: str) -> bool: + def has_access_to_tenant(user_id, tenant_id: str) -> bool: """Check if user has access to a specific tenant""" # Admin has access to everything user = UserModel.get_by_id(user_id) if user and user['role'] == UserModel.ROLE_ADMIN: return True + # Test users in no-auth mode don't have tenant associations + if isinstance(user_id, str) and user_id.startswith('test-'): + return False + # Check if user owns the tenant with get_db() as conn: cursor = conn.cursor() @@ -228,7 +246,7 @@ class UserModel: return cursor.fetchone() is not None @staticmethod - def is_admin(user_id: int) -> bool: + def is_admin(user_id) -> bool: """Check if user has admin role""" user = UserModel.get_by_id(user_id) return user and user['role'] == UserModel.ROLE_ADMIN diff --git a/src/app/templates/layout.html b/src/app/templates/layout.html index 262405e..81ee272 100644 --- a/src/app/templates/layout.html +++ b/src/app/templates/layout.html @@ -72,6 +72,15 @@ {% endif %}