diff --git a/.env.example b/.env.example index 5484389..9811638 100644 --- a/.env.example +++ b/.env.example @@ -13,11 +13,15 @@ OPENAI_MODEL=gpt-4o-mini # API Configuration MAX_TOKENS=4000 TEMPERATURE=0.7 +# For local development use: http://localhost:3000 +# For production use your domain with HTTPS: https://your-domain.com FRONTEND_URL=http://localhost:3000 # Microsoft Entra ID (Azure AD) # Create an app registration at: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps -# Add redirect URI: http://localhost:3000/auth/callback +# IMPORTANT: Azure requires HTTPS for non-localhost redirect URIs +# For localhost: http://localhost:3000/auth/callback +# For production: https://your-domain.com/auth/callback ENTRA_TENANT_ID=your-tenant-id ENTRA_CLIENT_ID=your-client-id ENTRA_CLIENT_SECRET=your-client-secret diff --git a/.gitea/workflows/ai-chat.yml b/.gitea/workflows/ai-chat.yml new file mode 100644 index 0000000..19b7918 --- /dev/null +++ b/.gitea/workflows/ai-chat.yml @@ -0,0 +1,61 @@ +name: AI Chat (Bartender) + +# WORKFLOW ROUTING: +# This workflow handles FREE-FORM questions/chat (no specific command) +# Other workflows: ai-issue-triage.yml (@codebot triage), ai-comment-reply.yml (specific commands) +# This is the FALLBACK for any @codebot mention that isn't a known command + +on: + issue_comment: + types: [created] + +# CUSTOMIZE YOUR BOT NAME: +# Change '@codebot' in all conditions below to match your config.yml mention_prefix +# Examples: '@bartender', '@uni', '@joey', '@codebot' + +jobs: + ai-chat: + # Only run if comment mentions the bot but NOT a specific command + # This prevents duplicate runs with ai-comment-reply.yml and ai-issue-triage.yml + # CRITICAL: Ignore bot's own comments to prevent infinite loops (bot username: Bartender) + if: | + github.event.comment.user.login != 'Bartender' && + contains(github.event.comment.body, '@codebot') && + !contains(github.event.comment.body, '@codebot triage') && + !contains(github.event.comment.body, '@codebot help') && + !contains(github.event.comment.body, '@codebot explain') && + !contains(github.event.comment.body, '@codebot suggest') && + !contains(github.event.comment.body, '@codebot security') && + !contains(github.event.comment.body, '@codebot summarize') && + !contains(github.event.comment.body, '@codebot changelog') && + !contains(github.event.comment.body, '@codebot explain-diff') && + !contains(github.event.comment.body, '@codebot review-again') && + !contains(github.event.comment.body, '@codebot setup-labels') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/checkout@v4 + with: + repository: Hiddenden/openrabbit + path: .ai-review + token: ${{ secrets.AI_REVIEW_TOKEN }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - run: pip install requests pyyaml + + - name: Run AI Chat + env: + AI_REVIEW_TOKEN: ${{ secrets.AI_REVIEW_TOKEN }} + AI_REVIEW_REPO: ${{ gitea.repository }} + AI_REVIEW_API_URL: https://git.hiddenden.cafe/api/v1 + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + OLLAMA_HOST: ${{ secrets.OLLAMA_HOST }} + SEARXNG_URL: ${{ secrets.SEARXNG_URL }} + run: | + cd .ai-review/tools/ai-review + python main.py comment ${{ gitea.repository }} ${{ gitea.event.issue.number }} "${{ gitea.event.comment.body }}" diff --git a/.gitea/workflows/ai-codebase-review.yml b/.gitea/workflows/ai-codebase-review.yml new file mode 100644 index 0000000..acebac3 --- /dev/null +++ b/.gitea/workflows/ai-codebase-review.yml @@ -0,0 +1,58 @@ +name: AI Codebase Quality Review + +on: +# # Weekly scheduled run +# schedule: +# - cron: "0 0 * * 0" + + # Manual trigger + workflow_dispatch: + inputs: + report_type: + description: "Type of report to generate" + required: false + default: "full" + type: choice + options: + - full + - security + - quick + +jobs: + ai-codebase-review: + runs-on: ubuntu-latest + + steps: + # Checkout the repository + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for analysis + + # Checkout central AI tooling + - uses: actions/checkout@v4 + with: + repository: Hiddenden/openrabbit + path: .ai-review + token: ${{ secrets.AI_REVIEW_TOKEN }} + + # Setup Python + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + # Install dependencies + - run: pip install requests pyyaml + + # Run AI codebase analysis + - name: Run AI Codebase Analysis + env: + AI_REVIEW_TOKEN: ${{ secrets.AI_REVIEW_TOKEN }} + AI_REVIEW_REPO: ${{ gitea.repository }} + AI_REVIEW_API_URL: https://git.hiddenden.cafe/api/v1 + + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + OLLAMA_HOST: ${{ secrets.OLLAMA_HOST }} + run: | + cd .ai-review/tools/ai-review + python main.py codebase ${{ gitea.repository }} diff --git a/.gitea/workflows/ai-comment-reply.yml b/.gitea/workflows/ai-comment-reply.yml new file mode 100644 index 0000000..dc469fe --- /dev/null +++ b/.gitea/workflows/ai-comment-reply.yml @@ -0,0 +1,98 @@ +name: AI Comment Reply + +# WORKFLOW ROUTING: +# This workflow handles SPECIFIC commands: help, explain, suggest, security, summarize, changelog, explain-diff, review-again, setup-labels +# Other workflows: ai-issue-triage.yml (@codebot triage), ai-chat.yml (free-form questions) + +on: + issue_comment: + types: [created] + +# CUSTOMIZE YOUR BOT NAME: +# Change '@codebot' in the 'if' condition below to match your config.yml mention_prefix +# Examples: '@bartender', '@uni', '@joey', '@codebot' + +jobs: + ai-reply: + runs-on: ubuntu-latest + # Only run for specific commands (not free-form chat or triage) + # This prevents duplicate runs with ai-chat.yml and ai-issue-triage.yml + # CRITICAL: Ignore bot's own comments to prevent infinite loops (bot username: Bartender) + if: | + github.event.comment.user.login != 'Bartender' && + (contains(github.event.comment.body, '@codebot help') || + contains(github.event.comment.body, '@codebot explain') || + contains(github.event.comment.body, '@codebot suggest') || + contains(github.event.comment.body, '@codebot security') || + contains(github.event.comment.body, '@codebot summarize') || + contains(github.event.comment.body, '@codebot changelog') || + contains(github.event.comment.body, '@codebot explain-diff') || + contains(github.event.comment.body, '@codebot review-again') || + contains(github.event.comment.body, '@codebot setup-labels')) + steps: + - uses: actions/checkout@v4 + + - uses: actions/checkout@v4 + with: + repository: Hiddenden/openrabbit + path: .ai-review + token: ${{ secrets.AI_REVIEW_TOKEN }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - run: pip install requests pyyaml + + - name: Run AI Comment Response + env: + AI_REVIEW_TOKEN: ${{ secrets.AI_REVIEW_TOKEN }} + AI_REVIEW_API_URL: https://git.hiddenden.cafe/api/v1 + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + OLLAMA_HOST: ${{ secrets.OLLAMA_HOST }} + run: | + cd .ai-review/tools/ai-review + + # Determine if this is a PR or issue comment + IS_PR="${{ gitea.event.issue.pull_request != null }}" + REPO="${{ gitea.repository }}" + ISSUE_NUMBER="${{ gitea.event.issue.number }}" + + # Validate inputs + if [ -z "$REPO" ] || [ -z "$ISSUE_NUMBER" ]; then + echo "Error: Missing required parameters" + exit 1 + fi + + # Validate repository format (owner/repo) + if ! echo "$REPO" | grep -qE '^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$'; then + echo "Error: Invalid repository format: $REPO" + exit 1 + fi + + if [ "$IS_PR" = "true" ]; then + # This is a PR comment - use safe dispatch with minimal event data + # Build minimal event payload (does not include sensitive user data) + EVENT_DATA=$(cat < User.Read (delegated)** permission, clicking **Grant admin consent** afterward so DevDen can read the signed-in user’s profile. + - Open **Certificates & secrets**, create a new client secret, give it a descriptive name, and copy the value immediately (it is hidden after leaving the page). + - Capture the Tenant ID, Client ID, and the client secret value and paste them into your `.env` as `ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, and `ENTRA_CLIENT_SECRET`. If you changed the redirect URI, also update `ENTRA_REDIRECT_URI` to match what you entered in Entra ID. + - Optionally configure application roles and group claims if you want to limit access to specific users/groups, then run `docker compose down && docker compose up -d --build` to ensure the backend reloads the updated secret values. + - Test the login flow by visiting `http://localhost:3000`, clicking **Login with Microsoft**, and confirming you are redirected back with a chat session. If you see a “redirect_uri_mismatch” error, double-check the URI in both the portal and your `.env` file. 4. **Start the services** ```bash diff --git a/auth-callback.html b/auth-callback.html new file mode 100644 index 0000000..fe887c1 --- /dev/null +++ b/auth-callback.html @@ -0,0 +1,47 @@ + + + + + + DevDen - Authentication + + + +
+
+

Completing authentication...

+
+ + + diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 4fd26e7..29122c0 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,3 +1,4 @@ +import logging from datetime import datetime, timedelta, timezone import jwt @@ -13,129 +14,258 @@ from ..models.schemas import ( UserResponse, ) +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('/app/auth.log'), + logging.StreamHandler() + ] +) + +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api/auth", tags=["auth"]) security = HTTPBearer(auto_error=False) def get_msal_app(): """Create MSAL confidential client application""" - if not all( - [ - settings.ENTRA_TENANT_ID, - settings.ENTRA_CLIENT_ID, - settings.ENTRA_CLIENT_SECRET, - ] - ): + logger.info("Checking MSAL configuration") + + required_settings = [ + ("ENTRA_TENANT_ID", settings.ENTRA_TENANT_ID), + ("ENTRA_CLIENT_ID", settings.ENTRA_CLIENT_ID), + ("ENTRA_CLIENT_SECRET", settings.ENTRA_CLIENT_SECRET), + ] + + missing_settings = [name for name, value in required_settings if not value] + if missing_settings: + logger.error(f"Missing required Entra ID settings: {missing_settings}") return None - return msal.ConfidentialClientApplication( - client_id=settings.ENTRA_CLIENT_ID, - client_credential=settings.ENTRA_CLIENT_SECRET, - authority=f"https://login.microsoftonline.com/{settings.ENTRA_TENANT_ID}", - ) + logger.info("All Entra ID settings present, creating MSAL app") + try: + msal_app = msal.ConfidentialClientApplication( + client_id=settings.ENTRA_CLIENT_ID, + client_credential=settings.ENTRA_CLIENT_SECRET, + authority=f"https://login.microsoftonline.com/{settings.ENTRA_TENANT_ID}", + ) + logger.info("MSAL application created successfully") + return msal_app + except Exception as e: + logger.error(f"Failed to create MSAL application: {e}") + return None def create_jwt_token(user_data: dict) -> str: """Create JWT token with user data""" - payload = { - "sub": user_data.get("oid") or user_data.get("sub"), - "name": user_data.get("name"), - "email": user_data.get("preferred_username"), - "exp": datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRY_HOURS), - "iat": datetime.now(timezone.utc), - } - return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) + logger.info("Creating JWT token", { + "user_id": user_data.get("oid") or user_data.get("sub"), + "user_name": user_data.get("name"), + "user_email": user_data.get("preferred_username") + }) + + try: + payload = { + "sub": user_data.get("oid") or user_data.get("sub"), + "name": user_data.get("name"), + "email": user_data.get("preferred_username"), + "exp": datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRY_HOURS), + "iat": datetime.now(timezone.utc), + } + + token = jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) + logger.info("JWT token created successfully", { + "expires_in_hours": settings.JWT_EXPIRY_HOURS, + "algorithm": settings.JWT_ALGORITHM + }) + return token + except Exception as e: + logger.error(f"Failed to create JWT token: {e}") + raise def decode_jwt_token(token: str) -> dict: """Decode and validate JWT token""" + logger.info("Decoding JWT token") + try: payload = jwt.decode( token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM] ) + logger.info("JWT token decoded successfully", { + "user_id": payload.get("sub"), + "user_name": payload.get("name"), + "expires_at": datetime.fromtimestamp(payload.get("exp", 0), timezone.utc).isoformat() + }) return payload - except jwt.ExpiredSignatureError: + except jwt.ExpiredSignatureError as e: + logger.warning("JWT token expired", {"error": str(e)}) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired" ) - except jwt.InvalidTokenError: + except jwt.InvalidTokenError as e: + logger.warning("Invalid JWT token", {"error": str(e)}) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" ) + except Exception as e: + logger.error(f"Unexpected error decoding JWT token: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Token validation failed" + ) async def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), ) -> dict: """Dependency to get current user from JWT token""" + logger.info("Getting current user from credentials") + if not credentials: + logger.warning("No credentials provided") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated" ) + logger.info("Credentials found, decoding token") return decode_jwt_token(credentials.credentials) @router.get("/login", response_model=AuthUrlResponse) async def login(): """Get Microsoft OAuth2 authorization URL""" + logger.info("Login endpoint called") + msal_app = get_msal_app() if not msal_app: + logger.error("MSAL app not available for login") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Authentication not configured. Please set ENTRA_TENANT_ID, ENTRA_CLIENT_ID, and ENTRA_CLIENT_SECRET.", ) - auth_url = msal_app.get_authorization_request_url( - scopes=["User.Read"], redirect_uri=settings.ENTRA_REDIRECT_URI - ) + try: + logger.info("Generating authorization URL", { + "scopes": ["User.Read"], + "redirect_uri": settings.ENTRA_REDIRECT_URI + }) + auth_url = msal_app.get_authorization_request_url( + scopes=["User.Read"], redirect_uri=settings.ENTRA_REDIRECT_URI + ) - return AuthUrlResponse(auth_url=auth_url) + logger.info("Authorization URL generated successfully", { + "url_length": len(auth_url), + "url_start": auth_url[:100] + "..." if len(auth_url) > 100 else auth_url + }) + + return AuthUrlResponse(auth_url=auth_url) + except Exception as e: + logger.error(f"Failed to generate authorization URL: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to generate authorization URL" + ) @router.post("/callback", response_model=AuthCallbackResponse) async def callback(request: AuthCallbackRequest): """Exchange authorization code for tokens""" + logger.info("Callback endpoint called", { + "code_length": len(request.code) if request.code else 0, + "code_start": request.code[:50] + "..." if request.code and len(request.code) > 50 else request.code + }) + msal_app = get_msal_app() if not msal_app: + logger.error("MSAL app not available for callback") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Authentication not configured", ) - result = msal_app.acquire_token_by_authorization_code( - code=request.code, - scopes=["User.Read"], - redirect_uri=settings.ENTRA_REDIRECT_URI, - ) + try: + logger.info("Exchanging authorization code for tokens", { + "scopes": ["User.Read"], + "redirect_uri": settings.ENTRA_REDIRECT_URI + }) - if "error" in result: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Authentication failed: {result.get('error_description', result.get('error'))}", + result = msal_app.acquire_token_by_authorization_code( + code=request.code, + scopes=["User.Read"], + redirect_uri=settings.ENTRA_REDIRECT_URI, ) - # Extract user info from ID token claims - id_token_claims = result.get("id_token_claims", {}) + logger.info("Token exchange result", { + "has_access_token": "access_token" in result, + "has_id_token": "id_token" in result, + "has_error": "error" in result, + "error": result.get("error"), + "error_description": result.get("error_description") + }) - # Create our JWT token - token = create_jwt_token(id_token_claims) + if "error" in result: + logger.error("Token exchange failed", { + "error": result.get("error"), + "error_description": result.get("error_description"), + "correlation_id": result.get("correlation_id") + }) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Authentication failed: {result.get('error_description', result.get('error'))}", + ) - return AuthCallbackResponse( - token=token, - user=UserResponse( - id=id_token_claims.get("oid") or id_token_claims.get("sub"), - name=id_token_claims.get("name"), - email=id_token_claims.get("preferred_username"), - ), - ) + # Extract user info from ID token claims + id_token_claims = result.get("id_token_claims", {}) + logger.info("ID token claims extracted", { + "claims_keys": list(id_token_claims.keys()), + "user_id": id_token_claims.get("oid") or id_token_claims.get("sub"), + "user_name": id_token_claims.get("name"), + "user_email": id_token_claims.get("preferred_username") + }) + + # Create our JWT token + token = create_jwt_token(id_token_claims) + + response_data = AuthCallbackResponse( + token=token, + user=UserResponse( + id=id_token_claims.get("oid") or id_token_claims.get("sub"), + name=id_token_claims.get("name"), + email=id_token_claims.get("preferred_username"), + ), + ) + + logger.info("Callback completed successfully", { + "user_id": response_data.user.id, + "user_name": response_data.user.name + }) + + return response_data + + except HTTPException: + # Re-raise HTTP exceptions as-is + raise + except Exception as e: + logger.error(f"Unexpected error in callback: {e}", {"traceback": str(e)}) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error during authentication" + ) @router.get("/me", response_model=UserResponse) async def me(current_user: dict = Depends(get_current_user)): """Get current user info""" + logger.info("Me endpoint called", { + "user_id": current_user.get("sub"), + "user_name": current_user.get("name") + }) + return UserResponse( id=current_user.get("sub"), name=current_user.get("name"), @@ -146,12 +276,15 @@ async def me(current_user: dict = Depends(get_current_user)): @router.post("/logout") async def logout(): """Logout (client should clear token)""" + logger.info("Logout endpoint called") return {"message": "Logged out successfully"} @router.get("/status") async def auth_status(): """Check if authentication is configured""" + logger.info("Auth status endpoint called") + configured = all( [ settings.ENTRA_TENANT_ID, @@ -159,4 +292,14 @@ async def auth_status(): settings.ENTRA_CLIENT_SECRET, ] ) + + status_info = { + "configured": configured, + "has_tenant_id": bool(settings.ENTRA_TENANT_ID), + "has_client_id": bool(settings.ENTRA_CLIENT_ID), + "has_client_secret": bool(settings.ENTRA_CLIENT_SECRET), + } + + logger.info("Auth status checked", status_info) + return {"configured": configured} diff --git a/backend/app/main.py b/backend/app/main.py index 847c8bb..98e0e13 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,14 @@ from .config import settings from .services.provider_manager import provider_manager # Setup logging -logging.basicConfig(level=logging.INFO) +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('/app/devden.log'), + logging.StreamHandler() + ] +) logger = logging.getLogger(__name__) app = FastAPI( @@ -30,9 +37,25 @@ app.include_router(auth.router) app.include_router(chat.router) +@app.exception_handler(Exception) +async def global_exception_handler(request, exc): + """Global exception handler to log all errors""" + logger.error(f"Unhandled exception: {exc}", { + "url": str(request.url), + "method": request.method, + "headers": dict(request.headers), + "traceback": str(exc) + }) + return JSONResponse( + status_code=500, + content={"detail": "Internal server error"} + ) + + @app.get("/health") async def health_check(): """Health check endpoint""" + logger.info("Health check requested") return JSONResponse( content={ "status": "healthy", @@ -41,6 +64,18 @@ async def health_check(): ) +@app.get("/logs") +async def get_logs(): + """Get recent log entries (for debugging)""" + try: + with open('/app/devden.log', 'r') as f: + lines = f.readlines()[-50:] # Last 50 lines + return {"logs": lines} + except Exception as e: + logger.error(f"Failed to read logs: {e}") + return {"error": "Failed to read logs"} + + @app.on_event("startup") async def startup_event(): logger.info("DevDen API starting up...") diff --git a/default.conf b/default.conf new file mode 100644 index 0000000..78f2c5b --- /dev/null +++ b/default.conf @@ -0,0 +1,56 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Proxy API requests to backend + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + + # Support for SSE (Server-Sent Events) + proxy_buffering off; + proxy_cache off; + } + + # OAuth callback endpoint - redirect to root with query params + location = /auth/callback { + try_files /auth-callback.html =404; + } + + # Proxy other auth requests to backend + location /auth/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Enable SPA routing - try to serve the file, then directory, then fallback to index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets for better performance + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 4bd8642..3519eda 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,11 +25,11 @@ services: - DEFAULT_PROVIDER=${DEFAULT_PROVIDER:-claude} - CLAUDE_MODEL=${CLAUDE_MODEL:-claude-3-5-sonnet-20241022} - OPENAI_MODEL=${OPENAI_MODEL:-gpt-4-turbo-preview} - - FRONTEND_URL=http://localhost:3000 + - FRONTEND_URL=https://devden.hiddenden.cafe - ENTRA_TENANT_ID=${ENTRA_TENANT_ID} - ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID} - ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET} - - ENTRA_REDIRECT_URI=${ENTRA_REDIRECT_URI:-http://localhost:3000/auth/callback} + - ENTRA_REDIRECT_URI=https://devden.hiddenden.cafe/auth/callback - JWT_SECRET=${JWT_SECRET:-change-this-in-production} - JWT_EXPIRY_HOURS=${JWT_EXPIRY_HOURS:-24} env_file: diff --git a/get-docker.sh b/get-docker.sh new file mode 100644 index 0000000..9a7bddb --- /dev/null +++ b/get-docker.sh @@ -0,0 +1,764 @@ +#!/bin/sh +set -e +# Docker Engine for Linux installation script. +# +# This script is intended as a convenient way to configure docker's package +# repositories and to install Docker Engine, This script is not recommended +# for production environments. Before running this script, make yourself familiar +# with potential risks and limitations, and refer to the installation manual +# at https://docs.docker.com/engine/install/ for alternative installation methods. +# +# The script: +# +# - Requires `root` or `sudo` privileges to run. +# - Attempts to detect your Linux distribution and version and configure your +# package management system for you. +# - Doesn't allow you to customize most installation parameters. +# - Installs dependencies and recommendations without asking for confirmation. +# - Installs the latest stable release (by default) of Docker CLI, Docker Engine, +# Docker Buildx, Docker Compose, containerd, and runc. When using this script +# to provision a machine, this may result in unexpected major version upgrades +# of these packages. Always test upgrades in a test environment before +# deploying to your production systems. +# - Isn't designed to upgrade an existing Docker installation. When using the +# script to update an existing installation, dependencies may not be updated +# to the expected version, resulting in outdated versions. +# +# Source code is available at https://github.com/docker/docker-install/ +# +# Usage +# ============================================================================== +# +# To install the latest stable versions of Docker CLI, Docker Engine, and their +# dependencies: +# +# 1. download the script +# +# $ curl -fsSL https://get.docker.com -o install-docker.sh +# +# 2. verify the script's content +# +# $ cat install-docker.sh +# +# 3. run the script with --dry-run to verify the steps it executes +# +# $ sh install-docker.sh --dry-run +# +# 4. run the script either as root, or using sudo to perform the installation. +# +# $ sudo sh install-docker.sh +# +# Command-line options +# ============================================================================== +# +# --version +# Use the --version option to install a specific version, for example: +# +# $ sudo sh install-docker.sh --version 23.0 +# +# --channel +# +# Use the --channel option to install from an alternative installation channel. +# The following example installs the latest versions from the "test" channel, +# which includes pre-releases (alpha, beta, rc): +# +# $ sudo sh install-docker.sh --channel test +# +# Alternatively, use the script at https://test.docker.com, which uses the test +# channel as default. +# +# --mirror +# +# Use the --mirror option to install from a mirror supported by this script. +# Available mirrors are "Aliyun" (https://mirrors.aliyun.com/docker-ce), and +# "AzureChinaCloud" (https://mirror.azure.cn/docker-ce), for example: +# +# $ sudo sh install-docker.sh --mirror AzureChinaCloud +# +# --setup-repo +# +# Use the --setup-repo option to configure Docker's package repositories without +# installing Docker packages. This is useful when you want to add the repository +# but install packages separately: +# +# $ sudo sh install-docker.sh --setup-repo +# +# Automatic Service Start +# +# By default, this script automatically starts the Docker daemon and enables the docker +# service after installation if systemd is used as init. +# +# If you prefer to start the service manually, use the --no-autostart option: +# +# $ sudo sh install-docker.sh --no-autostart +# +# Note: Starting the service requires appropriate privileges to manage system services. +# +# ============================================================================== + + +# Git commit from https://github.com/docker/docker-install when +# the script was uploaded (Should only be modified by upload job): +SCRIPT_COMMIT_SHA="f381ee68b32e515bb4dc034b339266aff1fbc460" + +# strip "v" prefix if present +VERSION="${VERSION#v}" + +# The channel to install from: +# * stable +# * test +DEFAULT_CHANNEL_VALUE="stable" +if [ -z "$CHANNEL" ]; then + CHANNEL=$DEFAULT_CHANNEL_VALUE +fi + +DEFAULT_DOWNLOAD_URL="https://download.docker.com" +if [ -z "$DOWNLOAD_URL" ]; then + DOWNLOAD_URL=$DEFAULT_DOWNLOAD_URL +fi + +DEFAULT_REPO_FILE="docker-ce.repo" +if [ -z "$REPO_FILE" ]; then + REPO_FILE="$DEFAULT_REPO_FILE" + # Automatically default to a staging repo fora + # a staging download url (download-stage.docker.com) + case "$DOWNLOAD_URL" in + *-stage*) REPO_FILE="docker-ce-staging.repo";; + esac +fi + +mirror='' +DRY_RUN=${DRY_RUN:-} +REPO_ONLY=${REPO_ONLY:-0} +NO_AUTOSTART=${NO_AUTOSTART:-0} +while [ $# -gt 0 ]; do + case "$1" in + --channel) + CHANNEL="$2" + shift + ;; + --dry-run) + DRY_RUN=1 + ;; + --mirror) + mirror="$2" + shift + ;; + --version) + VERSION="${2#v}" + shift + ;; + --setup-repo) + REPO_ONLY=1 + shift + ;; + --no-autostart) + NO_AUTOSTART=1 + ;; + --*) + echo "Illegal option $1" + ;; + esac + shift $(( $# > 0 ? 1 : 0 )) +done + +case "$mirror" in + Aliyun) + DOWNLOAD_URL="https://mirrors.aliyun.com/docker-ce" + ;; + AzureChinaCloud) + DOWNLOAD_URL="https://mirror.azure.cn/docker-ce" + ;; + "") + ;; + *) + >&2 echo "unknown mirror '$mirror': use either 'Aliyun', or 'AzureChinaCloud'." + exit 1 + ;; +esac + +case "$CHANNEL" in + stable|test) + ;; + *) + >&2 echo "unknown CHANNEL '$CHANNEL': use either stable or test." + exit 1 + ;; +esac + +command_exists() { + command -v "$@" > /dev/null 2>&1 +} + +# version_gte checks if the version specified in $VERSION is at least the given +# SemVer (Maj.Minor[.Patch]), or CalVer (YY.MM) version.It returns 0 (success) +# if $VERSION is either unset (=latest) or newer or equal than the specified +# version, or returns 1 (fail) otherwise. +# +# examples: +# +# VERSION=23.0 +# version_gte 23.0 // 0 (success) +# version_gte 20.10 // 0 (success) +# version_gte 19.03 // 0 (success) +# version_gte 26.1 // 1 (fail) +version_gte() { + if [ -z "$VERSION" ]; then + return 0 + fi + version_compare "$VERSION" "$1" +} + +# version_compare compares two version strings (either SemVer (Major.Minor.Path), +# or CalVer (YY.MM) version strings. It returns 0 (success) if version A is newer +# or equal than version B, or 1 (fail) otherwise. Patch releases and pre-release +# (-alpha/-beta) are not taken into account +# +# examples: +# +# version_compare 23.0.0 20.10 // 0 (success) +# version_compare 23.0 20.10 // 0 (success) +# version_compare 20.10 19.03 // 0 (success) +# version_compare 20.10 20.10 // 0 (success) +# version_compare 19.03 20.10 // 1 (fail) +version_compare() ( + set +x + + yy_a="$(echo "$1" | cut -d'.' -f1)" + yy_b="$(echo "$2" | cut -d'.' -f1)" + if [ "$yy_a" -lt "$yy_b" ]; then + return 1 + fi + if [ "$yy_a" -gt "$yy_b" ]; then + return 0 + fi + mm_a="$(echo "$1" | cut -d'.' -f2)" + mm_b="$(echo "$2" | cut -d'.' -f2)" + + # trim leading zeros to accommodate CalVer + mm_a="${mm_a#0}" + mm_b="${mm_b#0}" + + if [ "${mm_a:-0}" -lt "${mm_b:-0}" ]; then + return 1 + fi + + return 0 +) + +is_dry_run() { + if [ -z "$DRY_RUN" ]; then + return 1 + else + return 0 + fi +} + +is_wsl() { + case "$(uname -r)" in + *microsoft* ) true ;; # WSL 2 + *Microsoft* ) true ;; # WSL 1 + * ) false;; + esac +} + +is_darwin() { + case "$(uname -s)" in + *darwin* ) true ;; + *Darwin* ) true ;; + * ) false;; + esac +} + +deprecation_notice() { + distro=$1 + distro_version=$2 + echo + printf "\033[91;1mDEPRECATION WARNING\033[0m\n" + printf " This Linux distribution (\033[1m%s %s\033[0m) reached end-of-life and is no longer supported by this script.\n" "$distro" "$distro_version" + echo " No updates or security fixes will be released for this distribution, and users are recommended" + echo " to upgrade to a currently maintained version of $distro." + echo + printf "Press \033[1mCtrl+C\033[0m now to abort this script, or wait for the installation to continue." + echo + sleep 10 +} + +get_distribution() { + lsb_dist="" + # Every system that we officially support has /etc/os-release + if [ -r /etc/os-release ]; then + lsb_dist="$(. /etc/os-release && echo "$ID")" + fi + # Returning an empty string here should be alright since the + # case statements don't act unless you provide an actual value + echo "$lsb_dist" +} + +start_docker_daemon() { + # Use systemctl if available (for systemd-based systems) + if command_exists systemctl; then + is_dry_run || >&2 echo "Using systemd to manage Docker service" + if ( + is_dry_run || set -x + $sh_c systemctl enable --now docker.service 2>/dev/null + ); then + is_dry_run || echo "INFO: Docker daemon enabled and started" >&2 + else + is_dry_run || echo "WARNING: unable to enable the docker service" >&2 + fi + else + # No service management available (container environment) + if ! is_dry_run; then + >&2 echo "Note: Running in a container environment without service management" + >&2 echo "Docker daemon cannot be started automatically in this environment" + >&2 echo "The Docker packages have been installed successfully" + fi + fi + >&2 echo +} + +echo_docker_as_nonroot() { + if is_dry_run; then + return + fi + if command_exists docker && [ -e /var/run/docker.sock ]; then + ( + set -x + $sh_c 'docker version' + ) || true + fi + + # intentionally mixed spaces and tabs here -- tabs are stripped by "<<-EOF", spaces are kept in the output + echo + echo "================================================================================" + echo + if version_gte "20.10"; then + echo "To run Docker as a non-privileged user, consider setting up the" + echo "Docker daemon in rootless mode for your user:" + echo + echo " dockerd-rootless-setuptool.sh install" + echo + echo "Visit https://docs.docker.com/go/rootless/ to learn about rootless mode." + echo + fi + echo + echo "To run the Docker daemon as a fully privileged service, but granting non-root" + echo "users access, refer to https://docs.docker.com/go/daemon-access/" + echo + echo "WARNING: Access to the remote API on a privileged Docker daemon is equivalent" + echo " to root access on the host. Refer to the 'Docker daemon attack surface'" + echo " documentation for details: https://docs.docker.com/go/attack-surface/" + echo + echo "================================================================================" + echo +} + +# Check if this is a forked Linux distro +check_forked() { + + # Check for lsb_release command existence, it usually exists in forked distros + if command_exists lsb_release; then + # Check if the `-u` option is supported + set +e + lsb_release -a -u > /dev/null 2>&1 + lsb_release_exit_code=$? + set -e + + # Check if the command has exited successfully, it means we're in a forked distro + if [ "$lsb_release_exit_code" = "0" ]; then + # Print info about current distro + cat <<-EOF + You're using '$lsb_dist' version '$dist_version'. + EOF + + # Get the upstream release info + lsb_dist=$(lsb_release -a -u 2>&1 | tr '[:upper:]' '[:lower:]' | grep -E 'id' | cut -d ':' -f 2 | tr -d '[:space:]') + dist_version=$(lsb_release -a -u 2>&1 | tr '[:upper:]' '[:lower:]' | grep -E 'codename' | cut -d ':' -f 2 | tr -d '[:space:]') + + # Print info about upstream distro + cat <<-EOF + Upstream release is '$lsb_dist' version '$dist_version'. + EOF + else + if [ -r /etc/debian_version ] && [ "$lsb_dist" != "ubuntu" ] && [ "$lsb_dist" != "raspbian" ]; then + if [ "$lsb_dist" = "osmc" ]; then + # OSMC runs Raspbian + lsb_dist=raspbian + else + # We're Debian and don't even know it! + lsb_dist=debian + fi + dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')" + case "$dist_version" in + 13|14|forky) + dist_version="trixie" + ;; + 12) + dist_version="bookworm" + ;; + 11) + dist_version="bullseye" + ;; + 10) + dist_version="buster" + ;; + 9) + dist_version="stretch" + ;; + 8) + dist_version="jessie" + ;; + esac + fi + fi + fi +} + +do_install() { + echo "# Executing docker install script, commit: $SCRIPT_COMMIT_SHA" + + if command_exists docker; then + cat >&2 <<-'EOF' + Warning: the "docker" command appears to already exist on this system. + + If you already have Docker installed, this script can cause trouble, which is + why we're displaying this warning and provide the opportunity to cancel the + installation. + + If you installed the current Docker package using this script and are using it + again to update Docker, you can ignore this message, but be aware that the + script resets any custom changes in the deb and rpm repo configuration + files to match the parameters passed to the script. + + You may press Ctrl+C now to abort this script. + EOF + ( set -x; sleep 20 ) + fi + + user="$(id -un 2>/dev/null || true)" + + sh_c='sh -c' + if [ "$user" != 'root' ]; then + if command_exists sudo; then + sh_c='sudo -E sh -c' + elif command_exists su; then + sh_c='su -c' + else + cat >&2 <<-'EOF' + Error: this installer needs the ability to run commands as root. + We are unable to find either "sudo" or "su" available to make this happen. + EOF + exit 1 + fi + fi + + if is_dry_run; then + sh_c="echo" + fi + + # perform some very rudimentary platform detection + lsb_dist=$( get_distribution ) + lsb_dist="$(echo "$lsb_dist" | tr '[:upper:]' '[:lower:]')" + + if is_wsl; then + echo + echo "WSL DETECTED: We recommend using Docker Desktop for Windows." + echo "Please get Docker Desktop from https://www.docker.com/products/docker-desktop/" + echo + cat >&2 <<-'EOF' + + You may press Ctrl+C now to abort this script. + EOF + ( set -x; sleep 20 ) + fi + + case "$lsb_dist" in + + ubuntu) + if command_exists lsb_release; then + dist_version="$(lsb_release --codename | cut -f2)" + fi + if [ -z "$dist_version" ] && [ -r /etc/lsb-release ]; then + dist_version="$(. /etc/lsb-release && echo "$DISTRIB_CODENAME")" + fi + ;; + + debian|raspbian) + dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')" + case "$dist_version" in + 13) + dist_version="trixie" + ;; + 12) + dist_version="bookworm" + ;; + 11) + dist_version="bullseye" + ;; + 10) + dist_version="buster" + ;; + 9) + dist_version="stretch" + ;; + 8) + dist_version="jessie" + ;; + esac + ;; + + centos|rhel) + if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then + dist_version="$(. /etc/os-release && echo "$VERSION_ID")" + fi + ;; + + *) + if command_exists lsb_release; then + dist_version="$(lsb_release --release | cut -f2)" + fi + if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then + dist_version="$(. /etc/os-release && echo "$VERSION_ID")" + fi + ;; + + esac + + # Check if this is a forked Linux distro + check_forked + + # Print deprecation warnings for distro versions that recently reached EOL, + # but may still be commonly used (especially LTS versions). + case "$lsb_dist.$dist_version" in + centos.8|centos.7|rhel.7) + deprecation_notice "$lsb_dist" "$dist_version" + ;; + debian.buster|debian.stretch|debian.jessie) + deprecation_notice "$lsb_dist" "$dist_version" + ;; + raspbian.buster|raspbian.stretch|raspbian.jessie) + deprecation_notice "$lsb_dist" "$dist_version" + ;; + ubuntu.focal|ubuntu.bionic|ubuntu.xenial|ubuntu.trusty) + deprecation_notice "$lsb_dist" "$dist_version" + ;; + ubuntu.oracular|ubuntu.mantic|ubuntu.lunar|ubuntu.kinetic|ubuntu.impish|ubuntu.hirsute|ubuntu.groovy|ubuntu.eoan|ubuntu.disco|ubuntu.cosmic) + deprecation_notice "$lsb_dist" "$dist_version" + ;; + fedora.*) + if [ "$dist_version" -lt 41 ]; then + deprecation_notice "$lsb_dist" "$dist_version" + fi + ;; + esac + + # Run setup for each distro accordingly + case "$lsb_dist" in + ubuntu|debian|raspbian) + pre_reqs="ca-certificates curl" + apt_repo="deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] $DOWNLOAD_URL/linux/$lsb_dist $dist_version $CHANNEL" + ( + if ! is_dry_run; then + set -x + fi + $sh_c 'apt-get -qq update >/dev/null' + $sh_c "DEBIAN_FRONTEND=noninteractive apt-get -y -qq install $pre_reqs >/dev/null" + $sh_c 'install -m 0755 -d /etc/apt/keyrings' + $sh_c "curl -fsSL \"$DOWNLOAD_URL/linux/$lsb_dist/gpg\" -o /etc/apt/keyrings/docker.asc" + $sh_c "chmod a+r /etc/apt/keyrings/docker.asc" + $sh_c "echo \"$apt_repo\" > /etc/apt/sources.list.d/docker.list" + $sh_c 'apt-get -qq update >/dev/null' + ) + + if [ "$REPO_ONLY" = "1" ]; then + exit 0 + fi + + pkg_version="" + if [ -n "$VERSION" ]; then + if is_dry_run; then + echo "# WARNING: VERSION pinning is not supported in DRY_RUN" + else + # Will work for incomplete versions IE (17.12), but may not actually grab the "latest" if in the test channel + pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/~ce~.*/g' | sed 's/-/.*/g')" + search_command="apt-cache madison docker-ce | grep '$pkg_pattern' | head -1 | awk '{\$1=\$1};1' | cut -d' ' -f 3" + pkg_version="$($sh_c "$search_command")" + echo "INFO: Searching repository for VERSION '$VERSION'" + echo "INFO: $search_command" + if [ -z "$pkg_version" ]; then + echo + echo "ERROR: '$VERSION' not found amongst apt-cache madison results" + echo + exit 1 + fi + if version_gte "18.09"; then + search_command="apt-cache madison docker-ce-cli | grep '$pkg_pattern' | head -1 | awk '{\$1=\$1};1' | cut -d' ' -f 3" + echo "INFO: $search_command" + cli_pkg_version="=$($sh_c "$search_command")" + fi + pkg_version="=$pkg_version" + fi + fi + ( + pkgs="docker-ce${pkg_version%=}" + if version_gte "18.09"; then + # older versions didn't ship the cli and containerd as separate packages + pkgs="$pkgs docker-ce-cli${cli_pkg_version%=} containerd.io" + fi + if version_gte "20.10"; then + pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version" + fi + if version_gte "23.0"; then + pkgs="$pkgs docker-buildx-plugin" + fi + if version_gte "28.2"; then + pkgs="$pkgs docker-model-plugin" + fi + if ! is_dry_run; then + set -x + fi + $sh_c "DEBIAN_FRONTEND=noninteractive apt-get -y -qq install $pkgs >/dev/null" + ) + if [ "$NO_AUTOSTART" != "1" ]; then + start_docker_daemon + fi + echo_docker_as_nonroot + exit 0 + ;; + centos|fedora|rhel) + if [ "$(uname -m)" = "s390x" ]; then + echo "Effective v27.5, please consult RHEL distro statement for s390x support." + exit 1 + fi + repo_file_url="$DOWNLOAD_URL/linux/$lsb_dist/$REPO_FILE" + ( + if ! is_dry_run; then + set -x + fi + if command_exists dnf5; then + $sh_c "dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core" + $sh_c "dnf5 config-manager addrepo --overwrite --save-filename=docker-ce.repo --from-repofile='$repo_file_url'" + + if [ "$CHANNEL" != "stable" ]; then + $sh_c "dnf5 config-manager setopt \"docker-ce-*.enabled=0\"" + $sh_c "dnf5 config-manager setopt \"docker-ce-$CHANNEL.enabled=1\"" + fi + $sh_c "dnf makecache" + elif command_exists dnf; then + $sh_c "dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core" + $sh_c "rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo" + $sh_c "dnf config-manager --add-repo $repo_file_url" + + if [ "$CHANNEL" != "stable" ]; then + $sh_c "dnf config-manager --set-disabled \"docker-ce-*\"" + $sh_c "dnf config-manager --set-enabled \"docker-ce-$CHANNEL\"" + fi + $sh_c "dnf makecache" + else + $sh_c "yum -y -q install yum-utils" + $sh_c "rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo" + $sh_c "yum-config-manager --add-repo $repo_file_url" + + if [ "$CHANNEL" != "stable" ]; then + $sh_c "yum-config-manager --disable \"docker-ce-*\"" + $sh_c "yum-config-manager --enable \"docker-ce-$CHANNEL\"" + fi + $sh_c "yum makecache" + fi + ) + + if [ "$REPO_ONLY" = "1" ]; then + exit 0 + fi + + pkg_version="" + if command_exists dnf; then + pkg_manager="dnf" + pkg_manager_flags="-y -q --best" + else + pkg_manager="yum" + pkg_manager_flags="-y -q" + fi + if [ -n "$VERSION" ]; then + if is_dry_run; then + echo "# WARNING: VERSION pinning is not supported in DRY_RUN" + else + if [ "$lsb_dist" = "fedora" ]; then + pkg_suffix="fc$dist_version" + else + pkg_suffix="el" + fi + pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/\\\\.ce.*/g' | sed 's/-/.*/g').*$pkg_suffix" + search_command="$pkg_manager list --showduplicates docker-ce | grep '$pkg_pattern' | tail -1 | awk '{print \$2}'" + pkg_version="$($sh_c "$search_command")" + echo "INFO: Searching repository for VERSION '$VERSION'" + echo "INFO: $search_command" + if [ -z "$pkg_version" ]; then + echo + echo "ERROR: '$VERSION' not found amongst $pkg_manager list results" + echo + exit 1 + fi + if version_gte "18.09"; then + # older versions don't support a cli package + search_command="$pkg_manager list --showduplicates docker-ce-cli | grep '$pkg_pattern' | tail -1 | awk '{print \$2}'" + cli_pkg_version="$($sh_c "$search_command" | cut -d':' -f 2)" + fi + # Cut out the epoch and prefix with a '-' + pkg_version="-$(echo "$pkg_version" | cut -d':' -f 2)" + fi + fi + ( + pkgs="docker-ce$pkg_version" + if version_gte "18.09"; then + # older versions didn't ship the cli and containerd as separate packages + if [ -n "$cli_pkg_version" ]; then + pkgs="$pkgs docker-ce-cli-$cli_pkg_version containerd.io" + else + pkgs="$pkgs docker-ce-cli containerd.io" + fi + fi + if version_gte "20.10"; then + pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version" + fi + if version_gte "23.0"; then + pkgs="$pkgs docker-buildx-plugin docker-model-plugin" + fi + if ! is_dry_run; then + set -x + fi + $sh_c "$pkg_manager $pkg_manager_flags install $pkgs" + ) + if [ "$NO_AUTOSTART" != "1" ]; then + start_docker_daemon + fi + echo_docker_as_nonroot + exit 0 + ;; + sles) + echo "Effective v27.5, please consult SLES distro statement for s390x support." + exit 1 + ;; + *) + if [ -z "$lsb_dist" ]; then + if is_darwin; then + echo + echo "ERROR: Unsupported operating system 'macOS'" + echo "Please get Docker Desktop from https://www.docker.com/products/docker-desktop" + echo + exit 1 + fi + fi + echo + echo "ERROR: Unsupported distribution '$lsb_dist'" + echo + exit 1 + ;; + esac + exit 1 +} + +# wrapped up in a function so that we have some protection against only getting +# half the file during "curl | sh" +do_install diff --git a/index.html b/index.html index ded0238..bafcf08 100644 --- a/index.html +++ b/index.html @@ -53,6 +53,16 @@ - + + + + diff --git a/script.js b/script.js index 98dfdcf..78d85b0 100644 --- a/script.js +++ b/script.js @@ -1,3 +1,69 @@ +// Enhanced logging utility +class Logger { + constructor() { + this.logs = []; + this.maxLogs = 1000; + } + + log(level, message, data = null) { + const timestamp = new Date().toISOString(); + const logEntry = { + timestamp, + level, + message, + data, + url: window.location.href + }; + + this.logs.push(logEntry); + if (this.logs.length > this.maxLogs) { + this.logs.shift(); + } + + const consoleMethod = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log'; + console[consoleMethod](`[${timestamp}] ${level.toUpperCase()}: ${message}`, data || ''); + + // Save to localStorage for persistence + try { + localStorage.setItem('devden_logs', JSON.stringify(this.logs)); + } catch (e) { + console.warn('Failed to save logs to localStorage:', e); + } + } + + info(message, data = null) { this.log('info', message, data); } + warn(message, data = null) { this.log('warn', message, data); } + error(message, data = null) { this.log('error', message, data); } + + exportLogs() { + const blob = new Blob([JSON.stringify(this.logs, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `devden_logs_${new Date().toISOString().split('T')[0]}.json`; + a.click(); + URL.revokeObjectURL(url); + } + + clearLogs() { + this.logs = []; + localStorage.removeItem('devden_logs'); + } +} + +const logger = new Logger(); + +// Load existing logs from localStorage +try { + const savedLogs = localStorage.getItem('devden_logs'); + if (savedLogs) { + logger.logs = JSON.parse(savedLogs); + logger.info('Loaded existing logs from localStorage', { count: logger.logs.length }); + } +} catch (e) { + logger.warn('Failed to load existing logs from localStorage:', e); +} + const loginScreen = document.getElementById("loginScreen"); const welcomeScreen = document.getElementById("welcomeScreen"); const chatScreen = document.getElementById("chatScreen"); @@ -6,97 +72,173 @@ const welcomeInput = document.getElementById("welcomeInput"); const chatInput = document.getElementById("chatInput"); const loginBtn = document.getElementById("loginBtn"); -const API_URL = "http://localhost:8000"; +// API URL is same as frontend (nginx proxies /api and /auth to backend) +const API_URL = window.location.origin; let isInChat = false; // Auth functions function getToken() { - return localStorage.getItem("devden_token"); + try { + const token = localStorage.getItem("devden_token"); + logger.info('Retrieved token from localStorage', { hasToken: !!token }); + return token; + } catch (error) { + logger.error('Failed to retrieve token from localStorage', error); + return null; + } } function setToken(token) { - localStorage.setItem("devden_token", token); + try { + localStorage.setItem("devden_token", token); + logger.info('Token saved to localStorage'); + } catch (error) { + logger.error('Failed to save token to localStorage', error); + } } function clearToken() { - localStorage.removeItem("devden_token"); + try { + localStorage.removeItem("devden_token"); + logger.info('Token cleared from localStorage'); + } catch (error) { + logger.error('Failed to clear token from localStorage', error); + } } function showLoginScreen() { - loginScreen.classList.remove("hidden"); - welcomeScreen.classList.add("hidden"); - chatScreen.classList.add("hidden"); + try { + loginScreen.classList.remove("hidden"); + welcomeScreen.classList.add("hidden"); + chatScreen.classList.add("hidden"); + logger.info('Switched to login screen'); + } catch (error) { + logger.error('Failed to show login screen', error); + } } function showWelcomeScreen() { - loginScreen.classList.add("hidden"); - welcomeScreen.classList.remove("hidden"); - chatScreen.classList.add("hidden"); - welcomeInput.focus(); + try { + loginScreen.classList.add("hidden"); + welcomeScreen.classList.remove("hidden"); + chatScreen.classList.add("hidden"); + welcomeInput.focus(); + logger.info('Switched to welcome screen'); + } catch (error) { + logger.error('Failed to show welcome screen', error); + } } function switchToChat() { - loginScreen.classList.add("hidden"); - welcomeScreen.classList.add("hidden"); - chatScreen.classList.remove("hidden"); - chatInput.focus(); - isInChat = true; + try { + loginScreen.classList.add("hidden"); + welcomeScreen.classList.add("hidden"); + chatScreen.classList.remove("hidden"); + chatInput.focus(); + isInChat = true; + logger.info('Switched to chat screen'); + } catch (error) { + logger.error('Failed to switch to chat screen', error); + } } async function checkAuth() { + logger.info('Starting auth check'); const token = getToken(); if (!token) { + logger.info('No token found, showing login screen'); showLoginScreen(); return; } try { + logger.info('Making auth check request to /api/auth/me'); const response = await fetch(`${API_URL}/api/auth/me`, { headers: { Authorization: `Bearer ${token}` }, }); + logger.info('Auth check response received', { + status: response.status, + statusText: response.statusText, + ok: response.ok + }); + if (response.ok) { + const userData = await response.json(); + logger.info('Auth check successful', { user: userData }); showWelcomeScreen(); } else { + const errorText = await response.text(); + logger.warn('Auth check failed', { + status: response.status, + statusText: response.statusText, + response: errorText + }); clearToken(); showLoginScreen(); } } catch (error) { - console.error("Auth check failed:", error); + logger.error('Auth check request failed', { + error: error.message, + stack: error.stack + }); showLoginScreen(); } } async function handleLogin() { - loginBtn.disabled = true; - loginBtn.textContent = "Redirecting..."; + logger.info('Login button clicked, starting login process'); try { + loginBtn.disabled = true; + loginBtn.textContent = "Checking auth config..."; + logger.info('Disabled login button and updated text'); + // Check if auth is configured + logger.info('Checking auth configuration via /api/auth/status'); const statusResponse = await fetch(`${API_URL}/api/auth/status`); const statusData = await statusResponse.json(); + logger.info('Auth status response', { + status: statusResponse.status, + configured: statusData.configured + }); + if (!statusData.configured) { - alert( - "Authentication not configured. Please set ENTRA_TENANT_ID, ENTRA_CLIENT_ID, and ENTRA_CLIENT_SECRET in your .env file.", - ); + const errorMsg = "Authentication not configured. Please set ENTRA_TENANT_ID, ENTRA_CLIENT_ID, and ENTRA_CLIENT_SECRET in your .env file."; + logger.error('Auth not configured', { response: statusData }); + alert(errorMsg); loginBtn.disabled = false; loginBtn.textContent = "Sign in with Microsoft"; return; } + loginBtn.textContent = "Getting auth URL..."; + logger.info('Auth configured, requesting login URL from /api/auth/login'); + // Get auth URL and redirect const response = await fetch(`${API_URL}/api/auth/login`); const data = await response.json(); + logger.info('Login URL response', { + status: response.status, + hasAuthUrl: !!data.auth_url, + authUrl: data.auth_url ? data.auth_url.substring(0, 100) + '...' : null + }); + if (data.auth_url) { + loginBtn.textContent = "Redirecting..."; + logger.info('Redirecting to Microsoft OAuth URL'); window.location.href = data.auth_url; } else { throw new Error("No auth URL returned"); } } catch (error) { - console.error("Login failed:", error); + logger.error('Login process failed', { + error: error.message, + stack: error.stack + }); alert("Login failed: " + error.message); loginBtn.disabled = false; loginBtn.textContent = "Sign in with Microsoft"; @@ -104,32 +246,81 @@ async function handleLogin() { } async function handleCallback() { + logger.info('Starting OAuth callback processing', { url: window.location.href }); + const params = new URLSearchParams(window.location.search); const code = params.get("code"); + const sessionState = params.get("session_state"); + const error = params.get("error"); + const errorDescription = params.get("error_description"); - if (!code) return false; + logger.info('Parsed URL parameters', { + hasCode: !!code, + hasSessionState: !!sessionState, + hasError: !!error, + codeLength: code ? code.length : 0, + sessionState: sessionState, + error: error, + errorDescription: errorDescription + }); + + // Check for OAuth errors + if (error) { + logger.error('OAuth error in callback URL', { + error: error, + errorDescription: errorDescription + }); + alert(`Authentication failed: ${error} - ${errorDescription || 'Unknown error'}`); + return false; + } + + if (!code) { + logger.warn('No authorization code found in callback URL'); + return false; + } try { + logger.info('Sending authorization code to backend /api/auth/callback'); const response = await fetch(`${API_URL}/api/auth/callback`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }), }); + logger.info('Callback response received', { + status: response.status, + statusText: response.statusText, + ok: response.ok + }); + if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || "Callback failed"); + const errorData = await response.json(); + logger.error('Callback request failed', { + status: response.status, + error: errorData + }); + throw new Error(errorData.detail || "Callback failed"); } const data = await response.json(); + logger.info('Callback successful', { + hasToken: !!data.token, + hasUser: !!data.user, + user: data.user + }); + setToken(data.token); // Clean up URL + logger.info('Cleaning up URL (removing query parameters)'); window.history.replaceState({}, "", "/"); return true; } catch (error) { - console.error("Callback failed:", error); + logger.error('Callback processing failed', { + error: error.message, + stack: error.stack + }); alert("Authentication failed: " + error.message); return false; } @@ -288,14 +479,158 @@ chatInput.addEventListener("keydown", (e) => { // Initialize async function init() { - // Check for OAuth callback first - const callbackSuccess = await handleCallback(); + logger.info('Application initialization started', { + userAgent: navigator.userAgent, + url: window.location.href, + timestamp: new Date().toISOString() + }); - if (callbackSuccess) { - showWelcomeScreen(); + // Check for CSS loading + const styleLink = document.querySelector('link[rel="stylesheet"]'); + if (styleLink) { + logger.info('CSS link found', { href: styleLink.href }); + styleLink.addEventListener('load', () => { + logger.info('CSS loaded successfully'); + }); + styleLink.addEventListener('error', (e) => { + logger.error('CSS failed to load', { href: styleLink.href, error: e }); + }); } else { - await checkAuth(); + logger.error('CSS link not found in document'); } + + // Check DOM elements + const elements = ['loginScreen', 'welcomeScreen', 'chatScreen', 'chatMessages', 'welcomeInput', 'chatInput', 'loginBtn']; + elements.forEach(id => { + const el = document.getElementById(id); + if (!el) { + logger.error(`Required DOM element not found: ${id}`); + } else { + logger.info(`DOM element found: ${id}`); + } + }); + + try { + // Check for OAuth callback first + logger.info('Checking for OAuth callback parameters'); + const callbackSuccess = await handleCallback(); + + if (callbackSuccess) { + logger.info('OAuth callback processed successfully, showing welcome screen'); + showWelcomeScreen(); + } else { + logger.info('No OAuth callback or callback failed, checking existing auth'); + await checkAuth(); + } + } catch (error) { + logger.error('Initialization failed', { + error: error.message, + stack: error.stack + }); + } + + logger.info('Application initialization completed'); } +// Add global error handler +window.addEventListener('error', (event) => { + logger.error('Global JavaScript error', { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + error: event.error + }); +}); + +window.addEventListener('unhandledrejection', (event) => { + logger.error('Unhandled promise rejection', { + reason: event.reason, + promise: event.promise + }); +}); + +// Debug panel functionality +const debugPanel = document.getElementById('debugPanel'); +const exportLogsBtn = document.getElementById('exportLogsBtn'); +const clearLogsBtn = document.getElementById('clearLogsBtn'); +const viewBackendLogsBtn = document.getElementById('viewBackendLogsBtn'); +const closeDebugBtn = document.getElementById('closeDebugBtn'); +const debugOutput = document.getElementById('debugOutput'); + +function showDebugPanel() { + debugPanel.style.display = 'block'; + logger.info('Debug panel opened'); +} + +function hideDebugPanel() { + debugPanel.style.display = 'none'; + logger.info('Debug panel closed'); +} + +function updateDebugOutput(text) { + debugOutput.textContent = text; + debugOutput.scrollTop = debugOutput.scrollHeight; +} + +// Event listeners for debug panel +exportLogsBtn.addEventListener('click', () => { + logger.exportLogs(); + updateDebugOutput('Frontend logs exported to download'); + logger.info('Logs exported via debug panel'); +}); + +clearLogsBtn.addEventListener('click', () => { + logger.clearLogs(); + updateDebugOutput('Logs cleared'); + logger.info('Logs cleared via debug panel'); +}); + +viewBackendLogsBtn.addEventListener('click', async () => { + try { + updateDebugOutput('Loading backend logs...'); + const response = await fetch(`${API_URL}/logs`); + if (response.ok) { + const data = await response.json(); + if (data.logs) { + updateDebugOutput(data.logs.join('\n')); + } else if (data.error) { + updateDebugOutput(`Error: ${data.error}`); + } + } else { + updateDebugOutput(`Failed to load backend logs: ${response.status}`); + } + } catch (error) { + updateDebugOutput(`Error loading backend logs: ${error.message}`); + logger.error('Failed to load backend logs', error); + } +}); + +closeDebugBtn.addEventListener('click', hideDebugPanel); + +// Keyboard shortcuts +document.addEventListener('keydown', (e) => { + // Ctrl+Shift+L: Export logs + if (e.ctrlKey && e.shiftKey && e.key === 'L') { + e.preventDefault(); + logger.exportLogs(); + logger.info('Logs exported via keyboard shortcut'); + } + + // Ctrl+Shift+D: Toggle debug panel + if (e.ctrlKey && e.shiftKey && e.key === 'D') { + e.preventDefault(); + if (debugPanel.style.display === 'none') { + showDebugPanel(); + } else { + hideDebugPanel(); + } + } + + // Escape: Close debug panel + if (e.key === 'Escape' && debugPanel.style.display !== 'none') { + hideDebugPanel(); + } +}); + init();