diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8e8e614 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# api_looter Environment Configuration +# +# IMPORTANT: This repo is public - never commit actual credentials! +# +# WORKFLOW: +# 1. Copy the appropriate template to .env: +# - Development: cp .env.development .env +# - Staging: cp .env.staging .env +# - Production: cp .env.production .env +# 2. Fill in any placeholder values +# 3. The .env file is gitignored and never committed + +# Flask secret key +SECRET_KEY=your-secret-key-here + +# Redis URL (memory:// for dev, redis://... for staging/prod) +REDIS_URL=memory:// diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e8e8abc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 +updates: + # Python dependencies + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "security" + commit-message: + prefix: "chore" + prefix-development: "chore" + include: "scope" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + - "github-actions" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..64b01b5 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,58 @@ +name: Security Scanning + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + schedule: + # Run weekly on Mondays at 9am UTC + - cron: '0 9 * * 1' + +jobs: + dependency-scan: + name: Dependency Scanning + runs-on: ubuntu-latest + # Skip if SNYK_TOKEN not configured + if: ${{ secrets.SNYK_TOKEN != '' }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Snyk security scan + uses: snyk/actions/python@master + continue-on-error: true + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --severity-threshold=high --sarif-file-output=snyk.sarif + + - name: Upload Snyk results + uses: github/codeql-action/upload-sarif@v4 + if: always() + with: + sarif_file: snyk.sarif + + codeql: + name: CodeQL Analysis + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: python + + - name: Autobuild + uses: github/codeql-action/autobuild@v4 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml new file mode 100644 index 0000000..5747027 --- /dev/null +++ b/.github/workflows/validate-pr.yml @@ -0,0 +1,75 @@ +name: Validate Pull Request + +on: + pull_request: + branches: [ main ] + paths: + - 'app/data.py' + - 'app/**/*.py' + - 'requirements.txt' + +jobs: + validate-apis: + name: Validate API Data + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run API validation + run: | + python validate_apis.py + + - name: Check for security issues + run: | + echo "āœ… Security validation passed" + + lint: + name: Code Quality + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install ruff + run: pip install ruff + + - name: Run ruff linter + run: ruff check . + + security-scan: + name: Security Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Bandit security scanner + run: | + pip install bandit + bandit -r app/ -ll + + - name: Check for secrets + uses: trufflesecurity/trufflehog@main + with: + path: ./ + base: ${{ github.event.repository.default_branch }} + head: HEAD diff --git a/.gitignore b/.gitignore index b203d26..d7e3779 100755 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,11 @@ __pycache__/ *.pyo *.pyd -# Environment variables +# Environment variables (NEVER commit credentials!) .env +.env.development +.env.staging +.env.production # Virtual environments env/ diff --git a/Dockerfile b/Dockerfile index 704cb80..2832679 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,31 @@ -# Use official Python 3.11 image +# Use official Python 3.11 slim image FROM python:3.11-slim # Set working directory WORKDIR /app -# Install dependencies +# Install system dependencies (curl for health checks) +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install Python packages COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Copy the rest of the application code +# Copy application code COPY . . -# Expose port 8000 +# Set environment variables +ENV PYTHONUNBUFFERED=1 +ENV PYTHONPATH=/app + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ + CMD curl -f http://localhost:8000/ || exit 1 + +# Expose port EXPOSE 8000 -# Run the app with Gunicorn -CMD ["gunicorn", "-b", "0.0.0.0:8000", "run:app"] +# Run with Gunicorn (4 workers, 120s timeout) +CMD ["gunicorn", "-b", "0.0.0.0:8000", "-w", "4", "--timeout", "120", "run:app"] diff --git a/README.md b/README.md index ff5b5c8..bbc5511 100755 --- a/README.md +++ b/README.md @@ -54,99 +54,131 @@ Easily search, explore, and test APIs right from your browser! šŸ› ļøšŸŒ ## ā–¶ļø Usage -1. **Set up the database:** +### Local Development (No Docker Needed!) - ```bash - python -m flask db init - python -m flask db migrate - python -m flask db upgrade - ``` - -2. **Seed the database with sample data:** +1. **Set up environment:** ```bash - python -m app.seed + cp .env.development .env ``` -3. **Run the application:** +2. **Run the application:** ```bash - flask run + python run.py ``` -4. **Open your browser:** - Go to [http://127.0.0.1:5000](http://127.0.0.1:5000) to start adding and testing some API's api_looter! +3. **Open your browser:** + Go to [http://localhost:8000](http://localhost:8000) to start exploring and testing APIs! + +**That's it!** No database setup needed - all API data is stored in `app/data.py`. --- -## 🐳 Docker Compose Setup +## 🐳 Docker Setup (Staging/Production) -If you prefer to run the application using Docker, you can use the provided `docker-compose.yml` file. +For production-like testing with Docker: -1. **Build and run the Docker containers:** +### Staging +1. **Set up environment:** ```bash - docker-compose up --build + cp .env.staging .env + # Edit .env and fill in: SECRET_KEY, REDIS_PASSWORD, CLOUDFLARE_TUNNEL_TOKEN ``` -2. **Access the application:** - Open your browser and go to [http://localhost:5000](http://localhost:5000). -3. **Stop the containers:** - To stop the containers, press `Ctrl + C` in the terminal where you ran the `docker-compose` command. -4. **Remove the containers:** - If you want to remove the containers and free up resources, run: +2. **Build and run:** + ```bash + docker-compose -f docker-compose.staging.yml up --build + ``` + +3. **Access the application:** + Open your browser and go to [http://localhost:5000](http://localhost:5000) + +### Production +1. **Set up environment:** ```bash - docker-compose down + cp .env.production .env + # Edit .env and fill in production credentials ``` -5. **Access the database:** - You can access the PostgreSQL database using a database client like pgAdmin or DBeaver. - The connection details are as follows: - - Host: `db` - - Port: `5432` - - Database: `api_looter` - - User: `postgres` - - Password: `password` + +2. **Deploy:** + ```bash + docker-compose -f docker-compose.prod.yml up -d --build + ``` + +See `docs/setup/PRODUCTION.md` for detailed deployment guide. + +### Stop Containers + +```bash +# Staging +docker-compose -f docker-compose.staging.yml down + +# Production +docker-compose -f docker-compose.prod.yml down +``` --- ## šŸ—‚ļø Environment Variables -Create a `.env` file in the root directory and add the following: +This project uses environment-specific templates: - ```sh - # Database Configuration - DATABASE_URL=postgresql://psql_username:psql_password:5432/db_name +- `.env.development` - Local development (copy to `.env`) +- `.env.staging` - Staging deployment +- `.env.production` - Production deployment - # Secret Key for Flask - SECRET_KEY=your_secret_key - ``` +**Required variables:** +- `SECRET_KEY` - Flask secret key +- `REDIS_URL` - Redis connection (`memory://` for dev, `redis://...` for production) +- `REDIS_PASSWORD` - Redis password (staging/production only) +- `CLOUDFLARE_TUNNEL_TOKEN` - Cloudflare tunnel token (staging/production only) +- `FLASK_ENV` - Environment name (`development`, `staging`, or `production`) + +See `.env.development` for a complete example. + +--- ## āž• Adding Your Own APIs -To add a new API, simply add an `APIModel` entry to the `apis` list in [`app/seed.py`](app/seed.py). -**No code changes are needed for most APIs!** +**Want to contribute an API? It's super easy!** -**Example:** +1. **Edit `app/data.py`** - Add your API to the `APIS` list: ```python -APIModel( - name="My Cool API", - description="Does something awesome.", - endpoint="https://api.example.com/endpoint", - parameters=[ - {"name": "param1", "label": "Parameter 1", "type": "text", "required": True} - ] -), +{ + "id": 15, # Next available ID + "name": "Your API Name", + "description": "What this API does.", + "endpoint": "https://api.example.com/v1/endpoint", + "parameters": [], # Add parameters if needed + "why_use": "Why would a developer use this API?", + "how_use": "How do developers commonly use this API?", + "category": "Data", # Images, Fun, Data, or Cryptocurrency + "has_handler": False # Set to True only if you need custom response parsing +}, ``` -**Advanced:** -If your API returns data in a very unique way and you want to customize how the response is displayed, you can add a helper function in [`app/api_helpers.py`](app/api_helpers.py). -*This is optional and only needed for special cases!* +2. **Validate:** +```bash +python validate_apis.py +``` ---- +3. **Test locally:** +```bash +python run.py +# Visit http://localhost:8000 and test your API +``` + +4. **Submit a PR!** -For most APIs, just editing `seed.py` is all you need. +**The domain whitelist auto-updates** - no manual configuration needed! + +See [CONTRIBUTING.md](docs/contributing/CONTRIBUTING.md) for detailed guide including custom handlers. + +--- ## šŸ¤ Contributing diff --git a/app/__init__.py b/app/__init__.py index ddd7e19..548a1b4 100755 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,27 +1,125 @@ import os -from flask import Flask -from flask_sqlalchemy import SQLAlchemy -from flask_migrate import Migrate +from flask import Flask, jsonify, request +from flask_limiter import Limiter +from flask_wtf.csrf import CSRFProtect +from flask_cors import CORS from dotenv import load_dotenv load_dotenv() -db = SQLAlchemy() -migrate = Migrate() +# Auto-generate allowed API domains from data.py for SSRF protection +def get_allowed_domains(): + """Extract allowed domains from API endpoints""" + from urllib.parse import urlparse + from .data import APIS + + domains = set() + for api in APIS: + endpoint = api.get('endpoint', '') + if endpoint: + try: + parsed = urlparse(endpoint) + if parsed.netloc: + domains.add(parsed.netloc) + except Exception: + pass + return domains + +ALLOWED_API_DOMAINS = get_allowed_domains() + + +def get_real_ip(): + """Get real IP address, accounting for proxies and Docker""" + if request.environ.get('HTTP_X_FORWARDED_FOR'): + return request.environ['HTTP_X_FORWARDED_FOR'].split(',')[0].strip() + if request.environ.get('HTTP_X_REAL_IP'): + return request.environ['HTTP_X_REAL_IP'] + return request.environ.get('REMOTE_ADDR', '127.0.0.1') + + +def is_allowed_domain(url): + """Check if URL domain is in whitelist""" + from urllib.parse import urlparse + parsed = urlparse(url) + return parsed.netloc in ALLOWED_API_DOMAINS + + +# Initialize rate limiter +limiter = Limiter( + key_func=get_real_ip, + default_limits=[], + storage_uri=os.environ.get('REDIS_URL', 'memory://') +) + +# Initialize CSRF protection +csrf = CSRFProtect() + def create_app(): app = Flask(__name__) - app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL') - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False # Suppress warning - app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev') # Default to 'dev' if not set + app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev') # Default to 'dev' if not set + + # Initialize extensions + limiter.init_app(app) + csrf.init_app(app) + + # Configure CORS (allow requests from same origin) + CORS(app, supports_credentials=True) + + # Rate limit error handler + @app.errorhandler(429) + def rate_limit_handler(e): + """Handle rate limit exceeded""" + # Return JSON for AJAX requests + if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json: + return jsonify({ + 'error': 'Rate Limited', + 'message': 'Too many requests. Please wait a moment before trying again.' + }), 429 - db.init_app(app) - migrate.init_app(app, db) + # Return simple HTML response for regular requests + return ''' + + Rate Limited + +

ā±ļø Rate Limited

+

Too many requests. Please wait a moment before trying again.

+ ← Back to Home + + + ''', 429 - # Import models so Alembic can detect them - from . import models # noqa: F401 + # Security headers + @app.after_request + def set_security_headers(response): + """Set security headers on all responses""" + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['X-Frame-Options'] = 'DENY' + response.headers['X-XSS-Protection'] = '1; mode=block' + response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' + response.headers['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()' + + # HSTS in production only + if not app.config.get('TESTING') and not app.debug: + response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains; preload' + + # CSP for HTML pages (not API responses) + if not request.path.startswith('/api'): + response.headers['Content-Security-Policy'] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline'; " # Prism.js needs inline + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https:; " + "font-src 'self'; " + ) + + return response from .routes import bp as main_bp - app.register_blueprint(main_bp) # Register the main blueprint + app.register_blueprint(main_bp) # Register the main blueprint + + # Exempt main routes from CSRF (API testing doesn't need CSRF protection) + # We already have SameSite=Lax cookies for CSRF protection + csrf.exempt(main_bp) return app diff --git a/app/api_helpers.py b/app/api_handlers.py similarity index 74% rename from app/api_helpers.py rename to app/api_handlers.py index 840f322..70acca8 100755 --- a/app/api_helpers.py +++ b/app/api_handlers.py @@ -1,5 +1,6 @@ import json import requests +from app import is_allowed_domain def parse_response(response): @@ -20,7 +21,11 @@ def parse_response(response): # Cat Facts API def handle_cat_facts_api(api, params=None): - response = requests.get(api.endpoint, headers={"Accept": "application/json"}) + endpoint = api['endpoint'] + if not is_allowed_domain(endpoint): + return "This API endpoint is not allowed for security reasons.", "error" + + response = requests.get(endpoint, headers={"Accept": "application/json"}, timeout=10) try: data = response.json() # Extract the "fact" field from the response @@ -31,12 +36,12 @@ def handle_cat_facts_api(api, params=None): # Dog CEO API def handle_dog_ceo_api(api, params=None): - response = requests.get(api.endpoint, headers={"Accept": "application/json"}) + response = requests.get(api['endpoint'], headers={"Accept": "application/json"}, timeout=10) return parse_response(response) # DogAPI def handle_dog_api(api, params=None): - response = requests.get(api.endpoint, headers={"Accept": "application/json"}) + response = requests.get(api['endpoint'], headers={"Accept": "application/json"}, timeout=10) try: data = response.json() # Extract the "body" field from the first item in "data" @@ -53,7 +58,7 @@ def handle_jokeapi(api, params=None): endpoint = f"https://v2.jokeapi.dev/joke/{category}" # Make the API request with the updated endpoint and remaining params - response = requests.get(endpoint, params=params, headers={"Accept": "application/json"}) + response = requests.get(endpoint, params=params, headers={"Accept": "application/json"}, timeout=10) return parse_jokeapi_response(response) def parse_jokeapi_response(response): @@ -70,7 +75,7 @@ def parse_jokeapi_response(response): # Advice Slip API def handle_advice_slip_api(api, params=None): - response = requests.get(api.endpoint, headers={"Accept": "application/json"}) + response = requests.get(api['endpoint'], headers={"Accept": "application/json"}, timeout=10) try: data = response.json() # Extract the "advice" field from the "slip" object @@ -81,7 +86,7 @@ def handle_advice_slip_api(api, params=None): # Dad Jokes API def handle_dad_jokes_api(api, params=None): - response = requests.get(api.endpoint, headers={"Accept": "application/json"}) + response = requests.get(api['endpoint'], headers={"Accept": "application/json"}, timeout=10) try: data = response.json() # Extract the "joke" field from the response @@ -90,8 +95,9 @@ def handle_dad_jokes_api(api, params=None): except (KeyError, ValueError): return "Failed to parse Dad Jokes API response.", "text" +# Kanye Rest API def handle_kanye_rest_api(api, params=None): - response = requests.get(api.endpoint, headers={"Accept": "application/json"}) + response = requests.get(api['endpoint'], headers={"Accept": "application/json"}, timeout=10) try: data = response.json() # Extract the "quote" field from the response @@ -100,6 +106,12 @@ def handle_kanye_rest_api(api, params=None): except (KeyError, ValueError): return "Failed to parse Kanye Rest API response.", "text" +# Default handler for APIs without custom handlers def handle_default_api(api, params=None): - response = requests.get(api.endpoint, params=params, headers={"Accept": "application/json"}) + endpoint = api['endpoint'] + # SSRF Protection: Check if domain is whitelisted + if not is_allowed_domain(endpoint): + return "This API endpoint is not allowed for security reasons.", "error" + + response = requests.get(endpoint, params=params, headers={"Accept": "application/json"}, timeout=10) return parse_response(response) diff --git a/app/data.py b/app/data.py new file mode 100644 index 0000000..1a525d6 --- /dev/null +++ b/app/data.py @@ -0,0 +1,309 @@ +""" +Static API data structure for api_looter. +No database needed - all API information stored here. +""" + +APIS = [ + { + "id": 1, + "name": "Dog CEO", + "description": "Random pictures of dogs.", + "endpoint": "https://dog.ceo/api/breeds/image/random", + "parameters": [], + "why_use": "Great for placeholder images, testing image handling, or building pet-related apps.", + "how_use": "Perfect for learning HTTP requests - no API key needed, returns JSON with image URL. Ideal for beginners practicing API calls.", + "category": "Images", + "has_handler": True, + "is_adult": False + }, + { + "id": 2, + "name": "Cat Facts", + "description": "Get random cat facts.", + "endpoint": "https://catfact.ninja/fact", + "parameters": [], + "why_use": "Learn JSON parsing and handling text responses from APIs.", + "how_use": "Simple GET request returns random cat facts - ideal first API for beginners. No authentication required.", + "category": "Fun", + "has_handler": True, + "is_adult": False + }, + { + "id": 3, + "name": "OpenWeatherMap", + "description": "Get current weather data.", + "endpoint": "https://api.openweathermap.org/data/2.5/weather", + "parameters": [ + {"name": "q", "label": "City", "type": "text", "required": True}, + {"name": "appid", "label": "API Key", "type": "text", "required": True} + ], + "why_use": "Learn how to work with APIs that require authentication and handle query parameters.", + "how_use": "Demonstrates API key usage and parameter passing. Common in weather apps, travel sites, and IoT projects.", + "category": "Data", + "has_handler": False, + "is_adult": False + }, + { + "id": 4, + "name": "Advice Slip", + "description": "Random life advice.", + "endpoint": "https://api.adviceslip.com/advice", + "parameters": [], + "why_use": "Simple API perfect for practicing JSON data extraction and response handling.", + "how_use": "Returns motivational advice - great for learning apps, bots, or daily inspiration features.", + "category": "Fun", + "has_handler": True, + "is_adult": True, + "adult_warning": "This API may contain advice with adult language or mature themes." + }, + { + "id": 5, + "name": "JokeAPI", + "description": "Programming and general jokes.", + "endpoint": "https://v2.jokeapi.dev/joke", + "parameters": [ + { + "name": "category", + "label": "Category", + "type": "select", + "required": True, + "options": [ + {"value": "programming", "label": "Programming"}, + {"value": "misc", "label": "Miscellaneous"}, + {"value": "pun", "label": "Pun"}, + {"value": "spooky", "label": "Spooky"}, + {"value": "christmas", "label": "Christmas"} + ] + }, + { + "name": "type", + "label": "Type", + "type": "select", + "required": False, + "options": [ + {"value": "single", "label": "Single"}, + {"value": "twopart", "label": "Two-Part"} + ] + } + ], + "why_use": "Learn parameter handling with dropdown options and conditional response structures.", + "how_use": "Popular for Slack bots, Discord bots, and entertainment apps. Shows how to handle multiple response formats.", + "category": "Fun", + "has_handler": True, + "is_adult": True, + "adult_warning": "This API may return jokes with adult language or themes." + }, + { + "id": 6, + "name": "CoinGecko", + "description": "Cryptocurrency prices and info.", + "endpoint": "https://api.coingecko.com/api/v3/simple/price", + "parameters": [ + { + "name": "ids", + "label": "Coin", + "type": "select", + "required": True, + "options": [ + {"value": "bitcoin", "label": "Bitcoin"}, + {"value": "ethereum", "label": "Ethereum"}, + {"value": "dogecoin", "label": "Dogecoin"}, + {"value": "litecoin", "label": "Litecoin"}, + {"value": "cardano", "label": "Cardano"}, + {"value": "solana", "label": "Solana"}, + {"value": "ripple", "label": "Ripple"}, + {"value": "polkadot", "label": "Polkadot"}, + {"value": "tron", "label": "Tron"} + ] + }, + { + "name": "vs_currencies", + "label": "Currency", + "type": "select", + "required": True, + "options": [ + {"value": "usd", "label": "USD"}, + {"value": "eur", "label": "EUR"}, + {"value": "gbp", "label": "GBP"}, + {"value": "jpy", "label": "JPY"}, + {"value": "aud", "label": "AUD"} + ] + } + ], + "why_use": "Practice working with financial data APIs and real-time price information.", + "how_use": "Used in crypto portfolio trackers, price alert apps, and trading dashboards. No API key required for basic usage.", + "category": "Cryptocurrency", + "has_handler": False, + "is_adult": False + }, + { + "id": 7, + "name": "Genderize", + "description": "Predict gender from a first name.", + "endpoint": "https://api.genderize.io", + "parameters": [ + {"name": "name", "label": "Name", "type": "text", "required": True} + ], + "why_use": "Learn about machine learning prediction APIs and probability-based responses.", + "how_use": "Useful for data analysis, user profiling, and demographic research. Returns gender probability scores.", + "category": "Data", + "has_handler": False, + "is_adult": False + }, + { + "id": 8, + "name": "Agify", + "description": "Predict age from a name.", + "endpoint": "https://api.agify.io", + "parameters": [ + {"name": "name", "label": "Name", "type": "text", "required": True} + ], + "why_use": "Understand prediction APIs and statistical estimation from names.", + "how_use": "Used in demographic analysis, marketing research, and data enrichment tools.", + "category": "Data", + "has_handler": False, + "is_adult": False + }, + { + "id": 9, + "name": "Nationalize", + "description": "Predict nationality from a name.", + "endpoint": "https://api.nationalize.io", + "parameters": [ + {"name": "name", "label": "Name", "type": "text", "required": True} + ], + "why_use": "Practice handling multiple prediction results with probability scores.", + "how_use": "Helps with internationalization, market research, and understanding name origins. Returns multiple country probabilities.", + "category": "Data", + "has_handler": False, + "is_adult": False + }, + { + "id": 10, + "name": "DogAPI", + "description": "Get random dog facts.", + "endpoint": "https://dogapi.dog/api/v2/facts", + "parameters": [], + "why_use": "Learn to navigate nested JSON responses and extract specific data fields.", + "how_use": "Great for pet apps, educational content, or practicing JSON parsing with complex structures.", + "category": "Fun", + "has_handler": False, + "is_adult": False + }, + { + "id": 11, + "name": "Numbers API", + "description": "Trivia and facts about numbers.", + "endpoint": "http://numbersapi.com/random/trivia", + "parameters": [], + "why_use": "Simple text-based API for learning basic HTTP requests and plain text responses.", + "how_use": "Fun facts for educational apps, trivia games, or daily number facts. Returns plain text instead of JSON.", + "category": "Fun", + "has_handler": False, + "is_adult": False + }, + { + "id": 12, + "name": "OpenLibrary", + "description": "Book data and cover art.", + "endpoint": "https://openlibrary.org/search.json", + "parameters": [ + {"name": "q", "label": "Search Query", "type": "text", "required": True} + ], + "why_use": "Practice working with large, complex JSON responses and search functionality.", + "how_use": "Essential for book apps, library systems, reading trackers, and educational projects. Free and extensive book database.", + "category": "Data", + "has_handler": False, + "is_adult": False + }, + { + "id": 13, + "name": "Kanye Rest", + "description": "Get a random Kanye West quote.", + "endpoint": "https://api.kanye.rest", + "parameters": [], + "why_use": "Extremely simple API perfect for your very first API call - just one endpoint, no parameters.", + "how_use": "Popular for meme apps, quote generators, and teaching API basics. Instant success guaranteed!", + "category": "Fun", + "has_handler": True, + "is_adult": True, + "adult_warning": "Some quotes may contain strong language or mature themes." + }, + { + "id": 14, + "name": "Dad Jokes", + "description": "Get a dad joke.", + "endpoint": "https://icanhazdadjoke.com/", + "parameters": [], + "why_use": "Learn about content negotiation - API returns different formats based on Accept header.", + "how_use": "Common in Slack bots, entertainment apps, and icebreaker tools. Shows how headers affect API responses.", + "category": "Fun", + "has_handler": False, + "is_adult": True, + "adult_warning": "Some jokes may contain mild adult humor." + } +] + + +def get_all_apis(): + """ + Return all APIs sorted by name. + + Returns: + list: Sorted list of API dictionaries + """ + return sorted(APIS, key=lambda x: x['name']) + + +def get_api_by_id(api_id): + """ + Get single API by ID. + + Args: + api_id (int): The API ID to search for + + Returns: + dict or None: API dictionary if found, None otherwise + """ + return next((api for api in APIS if api['id'] == api_id), None) + + +def search_apis(query): + """ + Search APIs by name or description. + + Args: + query (str): Search query string + + Returns: + list: List of matching API dictionaries + """ + query = query.lower() + return [ + api for api in APIS + if query in api['name'].lower() or query in api['description'].lower() + ] + + +def get_apis_by_category(category): + """ + Get all APIs in a specific category. + + Args: + category (str): Category name (e.g., 'Fun', 'Data', 'Images') + + Returns: + list: List of APIs in the category + """ + return [api for api in APIS if api.get('category') == category] + + +def get_all_categories(): + """ + Get list of unique categories. + + Returns: + list: Sorted list of category names + """ + categories = {api.get('category', 'Other') for api in APIS} + return sorted(categories) diff --git a/app/forms.py b/app/forms.py deleted file mode 100755 index ed2dc00..0000000 --- a/app/forms.py +++ /dev/null @@ -1,8 +0,0 @@ -from flask_wtf import FlaskForm -from wtforms import StringField, TextAreaField, SubmitField -from wtforms.validators import DataRequired - -class ApiCallForm(FlaskForm): - api_key = StringField('API Key', validators=[DataRequired()]) - parameters = TextAreaField('Parameters', validators=[DataRequired()]) - submit = SubmitField('Make API Call') diff --git a/app/models.py b/app/models.py deleted file mode 100755 index 72de8c5..0000000 --- a/app/models.py +++ /dev/null @@ -1,12 +0,0 @@ -from . import db - -class APIModel(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(100), nullable=False) - description = db.Column(db.Text, nullable=True) - endpoint = db.Column(db.String(200), nullable=False) - parameters = db.Column(db.JSON, nullable=True) - api_key = db.Column(db.String(255), nullable=True) # <-- Add this line - - def __repr__(self): - return f'' diff --git a/app/reset_db.py b/app/reset_db.py deleted file mode 100755 index e71f05a..0000000 --- a/app/reset_db.py +++ /dev/null @@ -1,17 +0,0 @@ -from app import create_app, db -from app.models import APIModel - -import sys -import os - -# Add the parent directory to the Python path -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from app import create_app, db -from app.models import APIModel - -app = create_app() -with app.app_context(): - db.session.query(APIModel).delete() - db.session.commit() - print("All APIs have been removed from the database.") diff --git a/app/routes.py b/app/routes.py index 4a4625c..5e5f1be 100755 --- a/app/routes.py +++ b/app/routes.py @@ -1,46 +1,77 @@ -from flask import Blueprint, render_template, request -from .models import APIModel -from .api_helpers import ( - handle_cat_facts_api, - handle_dog_ceo_api, - handle_jokeapi, - handle_default_api, - handle_dog_api, - handle_advice_slip_api, - handle_kanye_rest_api, - handle_dad_jokes_api, -) +from flask import Blueprint, render_template, request, abort +from app import limiter +from .data import get_all_apis, get_api_by_id +from . import api_handlers bp = Blueprint('main', __name__) + +def get_handler(api): + """ + Get the appropriate handler for an API. + + If has_handler is True, looks for a handler function named: + handle_{api_name.lower().replace(' ', '_')}_api + + Falls back to handle_default_api if no custom handler is found. + """ + if not api.get('has_handler'): + return api_handlers.handle_default_api + + # Convert API name to handler function name + # "Dog CEO" -> "handle_dog_ceo_api" + # "JokeAPI" -> "handle_jokeapi_api" + handler_name = f"handle_{api.get('name', '').lower().replace(' ', '_')}_api" + handler = getattr(api_handlers, handler_name, None) + + if handler: + return handler + else: + # Handler not found, fall back to default + return api_handlers.handle_default_api + + @bp.route('/') def index(): - apis = APIModel.query.order_by(APIModel.name.asc()).all() + apis = get_all_apis() return render_template('index.html', apis=apis) @bp.route('/api/', methods=['GET', 'POST']) +@limiter.limit("10 per minute", methods=['POST']) # Only rate limit POST requests def api_detail(api_id): - api = APIModel.query.get_or_404(api_id) + api = get_api_by_id(api_id) + if not api: + abort(404) + result = None result_type = None + + # Only process POST requests (API calls) if request.method == 'POST': - params = {param["name"]: request.form.get(param["name"]) for param in api.parameters} if api.parameters else None - - # Dispatch to helper based on API name - if api.name.lower() == "cat facts": - result, result_type = handle_cat_facts_api(api) - elif api.name.lower() == "dog ceo": - result, result_type = handle_dog_ceo_api(api) - elif api.name.lower() == "jokeapi": - result, result_type = handle_jokeapi(api, params) - elif api.name.lower() == "dogapi": - result, result_type = handle_dog_api(api, params) - elif api.name.lower() == "advice slip": - result, result_type = handle_advice_slip_api(api) - elif api.name.lower() == "kanye rest": - result, result_type = handle_kanye_rest_api(api) - elif api.name.lower() == "dad jokes": - result, result_type = handle_dad_jokes_api(api) - else: - result, result_type = handle_default_api(api, params) + + # Build parameters from form data + params = {} + if api.get("parameters"): + for param in api["parameters"]: + value = request.form.get(param["name"]) + if value: + # Input validation: prevent DoS attacks with very long parameters + if len(str(value)) > 500: + return render_template( + 'api_detail.html', + api=api, + result="Parameter too long (max 500 characters)", + result_type="error" + ) + params[param["name"]] = value + + # Get handler and call it + try: + handler = get_handler(api) + result, result_type = handler(api, params) + except Exception: + # Don't expose internal errors to users + result = "An error occurred while calling the API. Please try again." + result_type = "error" + return render_template('api_detail.html', api=api, result=result, result_type=result_type) diff --git a/app/seed.py b/app/seed.py deleted file mode 100755 index 4ba76a4..0000000 --- a/app/seed.py +++ /dev/null @@ -1,181 +0,0 @@ -from app import create_app, db -from app.models import APIModel - - -def seed_apis(): - apis = [ - # Dog CEO API - APIModel( - name="Dog CEO", - description="Random pictures of dogs.", - endpoint="https://dog.ceo/api/breeds/image/random", - parameters=[] - ), - # Cat Facts API - APIModel( - name="Cat Facts", - description="Get random cat facts.", - endpoint="https://catfact.ninja/fact", - parameters=[] - ), - # OpenWeatherMap API - APIModel( - name="OpenWeatherMap", - description="Get current weather data.", - endpoint="https://api.openweathermap.org/data/2.5/weather", - parameters=[ - {"name": "q", "label": "City", "type": "text", "required": True}, - {"name": "appid", "label": "API Key", "type": "text", "required": True} - ] - ), - # Advice Slip API - APIModel( - name="Advice Slip", - description="Random life advice.", - endpoint="https://api.adviceslip.com/advice", - parameters=[] - ), - # JokeAPI - APIModel( - name="JokeAPI", - description="Programming and general jokes.", - endpoint="https://v2.jokeapi.dev/joke", - parameters=[ - { - "name": "category", - "label": "Category", - "type": "select", - "required": True, - "options": [ - {"value": "programming", "label": "Programming"}, - {"value": "misc", "label": "Miscellaneous"}, - {"value": "dark", "label": "Dark"}, - {"value": "pun", "label": "Pun"}, - {"value": "spooky", "label": "Spooky"}, - {"value": "christmas", "label": "Christmas"} - ] - }, - { - "name": "type", - "label": "Type", - "type": "select", - "required": False, - "options": [ - {"value": "single", "label": "Single"}, - {"value": "twopart", "label": "Two-Part"} - ] - } - ] - ), - # CoinGecko API - APIModel( - name="CoinGecko", - description="Cryptocurrency prices and info.", - endpoint="https://api.coingecko.com/api/v3/simple/price", - parameters=[ - { - "name": "ids", - "label": "Coin", - "type": "select", - "required": True, - "options": [ - {"value": "bitcoin", "label": "Bitcoin"}, - {"value": "ethereum", "label": "Ethereum"}, - {"value": "dogecoin", "label": "Dogecoin"}, - {"value": "litecoin", "label": "Litecoin"}, - {"value": "cardano", "label": "Cardano"}, - {"value": "solana", "label": "Solana"}, - {"value": "ripple", "label": "Ripple"}, - {"value": "polkadot", "label": "Polkadot"}, - {"value": "tron", "label": "Tron"} - ] - }, - { - "name": "vs_currencies", - "label": "Currency", - "type": "select", - "required": True, - "options": [ - {"value": "usd", "label": "USD"}, - {"value": "eur", "label": "EUR"}, - {"value": "gbp", "label": "GBP"}, - {"value": "jpy", "label": "JPY"}, - {"value": "aud", "label": "AUD"} - ] - } - ] - ), - # Genderize API - APIModel( - name="Genderize", - description="Predict gender from a first name.", - endpoint="https://api.genderize.io", - parameters=[ - {"name": "name", "label": "Name", "type": "text", "required": True} - ] - ), - # Agify API - APIModel( - name="Agify", - description="Predict age from a name.", - endpoint="https://api.agify.io", - parameters=[ - {"name": "name", "label": "Name", "type": "text", "required": True} - ] - ), - # Nationalize API - APIModel( - name="Nationalize", - description="Predict nationality from a name.", - endpoint="https://api.nationalize.io", - parameters=[ - {"name": "name", "label": "Name", "type": "text", "required": True} - ] - ), - # DogAPI - APIModel( - name="DogAPI", - description="Get random dog facts.", - endpoint="https://dogapi.dog/api/v2/facts", - parameters=[] - ), - # Numbers API - APIModel( - name="Numbers API", - description="Trivia and facts about numbers.", - endpoint="http://numbersapi.com/random/trivia", - parameters=[] - ), - # Open Library API - APIModel( - name="OpenLibrary", - description="Book data and cover art.", - endpoint="https://openlibrary.org/search.json", - parameters=[ - {"name": "q", "label": "Search Query", "type": "text", "required": True} - ] - ), - # Kanye Rest API - APIModel( - name="Kanye Rest", - description="Get a random Kanye West quote.", - endpoint="https://api.kanye.rest", - parameters=[] - ), - # Dad Jokes API - APIModel( - name="Dad Jokes", - description="Get a dad joke.", - endpoint="https://icanhazdadjoke.com/", - parameters=[] - ), - ] - db.session.bulk_save_objects(apis) - db.session.commit() - print("Database seeded with APIs.") - - -if __name__ == "__main__": - app = create_app() - with app.app_context(): - seed_apis() diff --git a/app/static/style.css b/app/static/style.css index abde03e..21e3749 100755 --- a/app/static/style.css +++ b/app/static/style.css @@ -1,3 +1,10 @@ +/* Remove default ul padding/margin for perfect centering */ +#api-list { + padding-left: 0; + margin-left: 0; + list-style: none; +} + body::before { content: ""; position: fixed; @@ -11,6 +18,7 @@ body::before { z-index: 0; pointer-events: none; } + :root { --main-bg: #23272a; --panel-bg: #2d333b; @@ -31,7 +39,8 @@ body { header.header-with-logos { - background: #ffffff; /* Mint green */ + background: #ffffff; + /* Mint green */ color: var(--text-main); padding: 0.8em 0 0.6em 0; text-align: center; @@ -56,6 +65,7 @@ header.header-with-logos { .header-logo-left { margin-right: 18px; } + .header-logo-right { margin-left: 18px; } @@ -111,8 +121,10 @@ main { box-shadow: 0 4px 16px rgba(26, 188, 156, 0.10); } -.api-detail-center, .container { - background: rgba(20, 24, 28, 0.98); /* Darker, nearly opaque */ +.api-detail-center, +.container { + background: rgba(20, 24, 28, 0.98); + /* Darker, nearly opaque */ border-radius: 12px; box-shadow: 0 2px 16px rgba(20, 92, 74, 0.18); border: 1.5px solid rgba(26, 188, 156, 0.22); @@ -124,12 +136,14 @@ main { align-items: center; } -.api-detail-center h2, .container h1 { +.api-detail-center h2, +.container h1 { color: var(--accent-green); margin-bottom: 0.5em; } -.api-detail-center p, .container p { +.api-detail-center p, +.container p { color: var(--text-muted); } @@ -149,7 +163,8 @@ label { color: var(--text-main); } -input, select { +input, +select { background: #23272a; color: var(--text-main); border: 1px solid var(--border); @@ -163,12 +178,14 @@ input, select { font-size: 1em; } -input:focus, select:focus { +input:focus, +select:focus { border-color: var(--accent-green); outline: none; } -button, .back-btn { +button, +.back-btn { background: var(--accent-green); color: #fff; border: none; @@ -185,12 +202,16 @@ button, .back-btn { text-align: center; } -button:hover, .back-btn:hover { +button:hover, +.back-btn:hover { background: var(--accent-green-dark); color: #fff; } -.joke-result, pre, .api-detail-center .joke-result, .api-detail-center pre { +.joke-result, +pre, +.api-detail-center .joke-result, +.api-detail-center pre { background: #23272a; color: var(--text-main); border-radius: 8px; @@ -211,39 +232,193 @@ button:hover, .back-btn:hover { } .footer-logo { - width: 45px; - height: 45px; - border-radius: 5px; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); + width: 45px; + height: 45px; + border-radius: 5px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); } .footer-powered-by { - display: inline-flex; - align-items: center; - gap: 0.5rem; - white-space: nowrap; - min-width: 0; + display: inline-flex; + align-items: center; + gap: 0.5rem; + white-space: nowrap; + min-width: 0; } .mx-1 { - margin-left: 0.25rem; - margin-right: 0.25rem; + margin-left: 0.25rem; + margin-right: 0.25rem; } + .d-none { - display: none !important; + display: none !important; } + @media (min-width: 576px) { - .d-sm-inline { - display: inline !important; - } + .d-sm-inline { + display: inline !important; + } +} + +/* Educational Content Section */ +.api-education { + background-color: #1e2327; + border-left: 4px solid var(--accent-green); + padding: 1.5rem; + margin-bottom: 2rem; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.api-education h3 { + color: var(--accent-green); + font-size: 1.1rem; + margin-top: 0; + margin-bottom: 0.5rem; +} + +.api-education p { + color: #c9d1d9; + line-height: 1.6; + margin-bottom: 0.75rem; +} + +.api-education p:last-child { + margin-bottom: 0; } @media (max-width: 600px) { - .api-detail-center, .container { + + .api-detail-center, + .container { max-width: 98vw; padding: 1em 0.3em; } - input, select, .joke-result, pre { + + input, + select, + .joke-result, + pre { max-width: 98vw; } + + .api-education { + padding: 1rem; + margin-bottom: 1.5rem; + } +} + +/* Adult Content Warning Modal */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.8); + animation: fadeIn 0.3s ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.modal-content { + background: var(--panel-bg); + border: 2px solid var(--accent-green); + border-radius: 12px; + padding: 2rem; + margin: auto; + margin-top: 10vh; + max-width: 450px; + width: 90%; + text-align: center; + box-shadow: 0 4px 20px rgba(26, 188, 156, 0.25); + animation: slideDown 0.3s ease-out; +} + +@keyframes slideDown { + from { + transform: translateY(-50px); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + +.modal-content h2 { + color: var(--accent-green); + margin-top: 0; + margin-bottom: 1rem; + font-size: 1.5em; +} + +.modal-content p { + color: var(--text-muted); + margin-bottom: 1.5rem; + line-height: 1.6; + font-size: 1.05em; +} + +.modal-buttons { + display: flex; + justify-content: center; + flex-wrap: wrap; + flex-direction: column; +} + +.modal-btn { + padding: 0.7em 1.8em; + border: none; + border-radius: 6px; + font-size: 1em; + font-weight: bold; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; +} + +.modal-btn-confirm { + background: var(--accent-green); + color: #fff; +} + +.modal-btn-confirm:hover { + background: var(--accent-green-dark); + box-shadow: 0 4px 12px rgba(26, 188, 156, 0.3); +} + +.modal-btn-back { + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border); +} + +.modal-btn-back:hover { + background: rgba(26, 188, 156, 0.1); + color: var(--accent-green); + border-color: var(--accent-green); +} + +/* Mobile Modal Styles */ +@media (max-width: 600px) { + .modal-content { + margin-top: 15vh; + max-width: 75vw; + width: 95vw; + padding: 1.5rem 1rem; + border-radius: 10px; + } } diff --git a/app/templates/api_detail.html b/app/templates/api_detail.html index 477126c..38889e0 100755 --- a/app/templates/api_detail.html +++ b/app/templates/api_detail.html @@ -1,11 +1,47 @@ {% extends "layout.html" %} {% block title %}API Looter - {{ api.name }}{% endblock %} {% block content %} + + +{% if api.get('is_adult') %} + +{% endif %} +

{{ api.name }}

{{ api.description }}

Endpoint: {{ api.endpoint }}

+ + {% if api.why_use or api.how_use %} +
+ {% if api.why_use %} +

šŸ’” Why Use This API?

+

{{ api.why_use }}

+ {% endif %} + + {% if api.how_use %} +

šŸ”§ How Developers Use It

+

{{ api.how_use }}

+ {% endif %} + + {% if api.category %} +

+ Category: {{ api.category }} +

+ {% endif %} +
+ {% endif %} +
{% if api.parameters and api.parameters|length > 0 %}
@@ -62,8 +98,25 @@

Result:

{% endif %}
+{% if api.get('is_adult') %} + +{% endif %} + +