diff --git a/DB_CONNECTION_FIX.md b/DB_CONNECTION_FIX.md new file mode 100644 index 0000000..a2089e2 --- /dev/null +++ b/DB_CONNECTION_FIX.md @@ -0,0 +1,86 @@ +# PostgreSQL Authentication Fix + +## Issue Description + +The server was failing to connect to the PostgreSQL database with the following error: + +``` +[OEDB] [ERROR] Failed to connect to PostgreSQL database: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL: Peer authentication failed for user "cipherbliss" +``` + +This error occurs because the application was trying to connect to PostgreSQL using Unix socket connections, which default to "peer" authentication (using the operating system username). However, the database was configured to use password authentication, as evidenced by the fact that DBeaver could connect using the password from the `.env` file. + +## Solution + +The fix involves modifying the database connection logic in `oedb/utils/db.py` to: + +1. Force TCP/IP connections instead of Unix socket connections by setting the host to "localhost" when it's empty +2. Add appropriate connection parameters for local connections +3. Improve debug logging to help with troubleshooting + +These changes ensure that the application uses password authentication instead of peer authentication when connecting to the database. + +## Testing the Fix + +A test script has been created to verify that the database connection works with the new changes: + +```bash +./test_db_connection.py +``` + +This script attempts to connect to the database using the same connection logic as the main application and reports whether the connection was successful. + +## Technical Details + +### Changes Made + +1. Modified `db_connect()` function to set host to "localhost" when it's empty: + ```python + # If host is empty, set it to localhost to force TCP/IP connection + # instead of Unix socket (which uses peer authentication) + if not host: + host = "localhost" + ``` + +2. Added appropriate connection parameters for local connections: + ```python + # For localhost connections, add additional parameters to ensure proper connection + if host in ('localhost', '127.0.0.1'): + logger.debug("Using TCP/IP connection with additional parameters") + return psycopg2.connect( + dbname=dbname, + host=host, + password=password, + user=user, + options="-c client_encoding=utf8 -c statement_timeout=3000", + connect_timeout=3, + application_name="oedb", + # Disable SSL for local connections + sslmode='disable') + ``` + +3. Improved debug logging to help with troubleshooting: + ```python + logger.debug(f"Connecting to database: {dbname} on {host} as user {user}") + ``` + +### Why This Works + +By setting the host to "localhost" when it's empty, we force psycopg2 to use TCP/IP connections instead of Unix socket connections. TCP/IP connections typically use password authentication, which is what the database is configured to use. + +The additional connection parameters for local connections help ensure that the connection is established correctly and provide useful information for debugging. + +## Additional Notes + +If you continue to experience authentication issues, check the following: + +1. Make sure your `.env` file contains the correct database credentials: + ``` + DB_NAME=oedb + DB_USER=cipherbliss + POSTGRES_PASSWORD=your_password + ``` + +2. Verify that your PostgreSQL server is configured to allow password authentication for the specified user. + +3. Check the PostgreSQL logs for more detailed error messages. \ No newline at end of file diff --git a/backend.py b/backend.py index 1038224..e891b79 100644 --- a/backend.py +++ b/backend.py @@ -20,6 +20,7 @@ from oedb.middleware.headers import HeaderMiddleware from oedb.resources.event import event from oedb.resources.stats import StatsResource from oedb.resources.search import EventSearch +from oedb.resources.root import root def create_app(): """ @@ -43,10 +44,11 @@ def create_app(): # Add routes logger.info("Setting up API routes") + app.add_route('/', root) # Handle root requests + app.add_route('/event/search', event_search) # Handle event search requests app.add_route('/event/{id}', event) # Handle single event requests app.add_route('/event', event) # Handle event collection requests app.add_route('/stats', stats) # Handle stats requests - app.add_route('/event/search', event_search) # Handle event search requests logger.success("Application initialized successfully") return app diff --git a/oedb/resources/event.py b/oedb/resources/event.py index 74bf223..950d541 100644 --- a/oedb/resources/event.py +++ b/oedb/resources/event.py @@ -7,6 +7,7 @@ import re import falcon import psycopg2 import psycopg2.extras +import psycopg2.errors from oedb.models.event import BaseEvent from oedb.utils.db import db_connect from oedb.utils.serialization import dumps @@ -219,7 +220,7 @@ class EventResource(BaseEvent): event_sort=event_sort, limit=limit) logger.debug(f"Executing SQL: {sql}") cur.execute(sql) - resp.body = dumps(self.rows_to_collection(cur.fetchall(), geom_only)) + resp.text = dumps(self.rows_to_collection(cur.fetchall(), geom_only)) resp.status = falcon.HTTP_200 logger.success(f"Successfully processed GET request to /event") else: @@ -229,16 +230,24 @@ class EventResource(BaseEvent): e = cur.fetchone() if e is not None: - resp.body = dumps(self.row_to_feature(e)) + resp.text = dumps(self.row_to_feature(e)) resp.status = falcon.HTTP_200 logger.success(f"Successfully processed GET request to /event/{id}") else: resp.status = falcon.HTTP_404 logger.warning(f"Event not found: {id}") + except psycopg2.errors.InsufficientPrivilege as e: + logger.error(f"Permission denied for database table: {e}") + resp.status = falcon.HTTP_500 + resp.text = dumps({ + "error": "Database permission error", + "message": "The server does not have permission to access the required database tables. Please contact the administrator to fix this issue.", + "details": str(e) + }) except Exception as e: logger.error(f"Error processing GET request: {e}") resp.status = falcon.HTTP_500 - resp.body = dumps({"error": str(e)}) + resp.text = dumps({"error": str(e)}) finally: db.close() @@ -260,29 +269,29 @@ class EventResource(BaseEvent): j = json.loads(body) except Exception as e: logger.error(f"Invalid JSON or bad encoding: {e}") - resp.body = 'invalid json or bad encoding' + resp.text = 'invalid json or bad encoding' resp.status = falcon.HTTP_400 return - resp.body = '' + resp.text = '' if "properties" not in j: - resp.body = resp.body + "missing 'properties' elements\n" + resp.text = resp.text + "missing 'properties' elements\n" j['properties'] = dict() if "geometry" not in j: - resp.body = resp.body + "missing 'geometry' elements\n" + resp.text = resp.text + "missing 'geometry' elements\n" j['geometry'] = None if "when" not in j['properties'] and ("start" not in j['properties'] or "stop" not in j['properties']): - resp.body = resp.body + "missing 'when' or 'start/stop' in properties\n" + resp.text = resp.text + "missing 'when' or 'start/stop' in properties\n" j['properties']['when'] = None if "type" not in j['properties']: - resp.body = resp.body + "missing 'type' of event in properties\n" + resp.text = resp.text + "missing 'type' of event in properties\n" j['properties']['type'] = None if "what" not in j['properties']: - resp.body = resp.body + "missing 'what' in properties\n" + resp.text = resp.text + "missing 'what' in properties\n" j['properties']['what'] = None if "type" in j and j['type'] != 'Feature': - resp.body = resp.body + 'geojson must be "type":"Feature" only\n' - if id is None and resp.body != '': + resp.text = resp.text + 'geojson must be "type":"Feature" only\n' + if id is None and resp.text != '': resp.status = falcon.HTTP_400 resp.set_header('Content-type', 'text/plain') return @@ -319,7 +328,7 @@ class EventResource(BaseEvent): geometry = dumps(j['geometry']) h = self.maybe_insert_geometry(geometry, cur) if len(h) > 1 and h[1] is False: - resp.body = "invalid geometry: %s\n" % h[2] + resp.text = "invalid geometry: %s\n" % h[2] resp.status = falcon.HTTP_400 resp.set_header('Content-type', 'text/plain') return @@ -361,11 +370,11 @@ class EventResource(BaseEvent): AND e.events_when=tstzrange(coalesce(%s, lower(s.events_when)),coalesce(%s,upper(s.events_when)),%s) AND e.events_geo=coalesce(%s, s.events_geo);""", (id, j['properties']['what'], event_start, event_stop, bounds, h[0])) dupe = cur.fetchone() - resp.body = """{"duplicate":"%s"}""" % (dupe[0]) + resp.text = """{"duplicate":"%s"}""" % (dupe[0]) resp.status = '409 Conflict with event %s' % dupe[0] logger.warning(f"Duplicate event: {dupe[0]}") else: - resp.body = """{"id":"%s"}""" % (e[0]) + resp.text = """{"id":"%s"}""" % (e[0]) if id is None: resp.status = falcon.HTTP_201 logger.success(f"Event created with ID: {e[0]}") @@ -455,7 +464,7 @@ class EventResource(BaseEvent): except Exception as e: logger.error(f"Error deleting event {id}: {e}") resp.status = falcon.HTTP_500 - resp.body = dumps({"error": str(e)}) + resp.text = dumps({"error": str(e)}) db.rollback() finally: cur.close() diff --git a/oedb/resources/root.py b/oedb/resources/root.py new file mode 100644 index 0000000..fe16e5b --- /dev/null +++ b/oedb/resources/root.py @@ -0,0 +1,49 @@ +""" +Root resource for the OpenEventDatabase. +""" + +import falcon +from oedb.utils.serialization import dumps +from oedb.utils.logging import logger + +class RootResource: + """ + Resource for the root endpoint. + Handles the / endpoint. + """ + + def on_get(self, req, resp): + """ + Handle GET requests to the / endpoint. + Returns a JSON response with available routes and a welcome message. + + Args: + req: The request object. + resp: The response object. + """ + logger.info("Processing GET request to /") + + try: + # Create a response with available routes and a welcome message + response = { + "message": "Welcome to the OpenEventDatabase API!", + "available_routes": { + "/": "This endpoint - provides information about available routes", + "/event": "Get events matching specified criteria", + "/event/{id}": "Get a specific event by ID", + "/event/search": "Search for events using a GeoJSON geometry", + "/stats": "Get statistics about the database" + } + } + + # Set the response body and status + resp.text = dumps(response) + resp.status = falcon.HTTP_200 + logger.success("Successfully processed GET request to /") + except Exception as e: + logger.error(f"Error processing GET request to /: {e}") + resp.status = falcon.HTTP_500 + resp.text = dumps({"error": str(e)}) + +# Create a global instance of RootResource +root = RootResource() \ No newline at end of file diff --git a/oedb/resources/search.py b/oedb/resources/search.py index 3db9659..fb6c378 100644 --- a/oedb/resources/search.py +++ b/oedb/resources/search.py @@ -33,7 +33,7 @@ class EventSearch(BaseEvent): if 'geometry' not in j: logger.warning("Request body missing 'geometry' field") resp.status = falcon.HTTP_400 - resp.body = json.dumps({"error": "Request body must contain a 'geometry' field"}) + resp.text = json.dumps({"error": "Request body must contain a 'geometry' field"}) return # Pass the query with the geometry to event.on_get @@ -44,8 +44,8 @@ class EventSearch(BaseEvent): except json.JSONDecodeError as e: logger.error(f"Error decoding JSON: {e}") resp.status = falcon.HTTP_400 - resp.body = json.dumps({"error": "Invalid JSON in request body"}) + resp.text = json.dumps({"error": "Invalid JSON in request body"}) except Exception as e: logger.error(f"Error processing POST request to /event/search: {e}") resp.status = falcon.HTTP_500 - resp.body = json.dumps({"error": str(e)}) \ No newline at end of file + resp.text = json.dumps({"error": str(e)}) \ No newline at end of file diff --git a/oedb/resources/stats.py b/oedb/resources/stats.py index 9647724..c44dcda 100644 --- a/oedb/resources/stats.py +++ b/oedb/resources/stats.py @@ -45,13 +45,13 @@ class StatsResource: cur.execute("SELECT row_to_json(stat) from (SELECT events_what as what, left(max(upper(events_when))::text,19) as last, count(*) as count, array_agg(distinct(regexp_replace(regexp_replace(events_tags ->> 'source','^(http://|https://)',''),'/.*',''))) as source from (select * from events order by lastupdate desc limit 10000) as last group by 1 order by 2 desc) as stat;") recent = cur.fetchall() - resp.body = dumps(dict(events_count=count, last_updated=last, uptime=uptime, db_uptime=pg_uptime, recent=recent)) + resp.text = dumps(dict(events_count=count, last_updated=last, uptime=uptime, db_uptime=pg_uptime, recent=recent)) resp.status = falcon.HTTP_200 logger.success("Successfully processed GET request to /stats") except Exception as e: logger.error(f"Error processing GET request to /stats: {e}") resp.status = falcon.HTTP_500 - resp.body = dumps({"error": str(e)}) + resp.text = dumps({"error": str(e)}) finally: cur.close() db.close() \ No newline at end of file diff --git a/oedb/utils/db.py b/oedb/utils/db.py index fc23155..e99d3a3 100644 --- a/oedb/utils/db.py +++ b/oedb/utils/db.py @@ -8,6 +8,20 @@ import psycopg2 import psycopg2.extras from oedb.utils.logging import logger +def load_env_from_file(): + """ + Load environment variables from .env file. + This ensures that database connection parameters are properly set. + """ + if os.path.exists('.env'): + logger.info("Loading environment variables from .env file...") + with open('.env', 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#'): + key, value = line.split('=', 1) + os.environ[key] = value + def db_connect(): """ Connect to the PostgreSQL database using environment variables. @@ -15,11 +29,43 @@ def db_connect(): Returns: psycopg2.connection: A connection to the database. """ - return psycopg2.connect( - dbname=os.getenv("DB_NAME", "oedb"), - host=os.getenv("DB_HOST", ""), - password=os.getenv("POSTGRES_PASSWORD", None), - user=os.getenv("DB_USER", "")) + # Load environment variables from .env file + load_env_from_file() + + # Get connection parameters from environment variables + dbname = os.getenv("DB_NAME", "oedb") + host = os.getenv("DB_HOST", "") + password = os.getenv("POSTGRES_PASSWORD", None) + user = os.getenv("DB_USER", "") + + # If host is empty, set it to localhost to force TCP/IP connection + # instead of Unix socket (which uses peer authentication) + if not host: + host = "localhost" + + logger.debug(f"Connecting to database: {dbname} on {host} as user {user}") + + # For localhost connections, add additional parameters to ensure proper connection + if host in ('localhost', '127.0.0.1'): + logger.debug("Using TCP/IP connection with additional parameters") + return psycopg2.connect( + dbname=dbname, + host=host, + password=password, + user=user, + options="-c client_encoding=utf8 -c statement_timeout=3000", + connect_timeout=3, + application_name="oedb", + # Disable SSL for local connections + sslmode='disable') + else: + # For remote connections, use standard parameters + logger.debug("Using standard connection parameters") + return psycopg2.connect( + dbname=dbname, + host=host, + password=password, + user=user) def check_db_connection(): """ diff --git a/requirements.txt b/requirements.txt index e48dd8e..237c207 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -falcon>=3.1.0 +falcon psycopg2-binary geojson gunicorn diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..bd02080 --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Script to start the server in the background and run tests + +echo "Starting the server in the background..." +python3 backend.py > server.log 2>&1 & +SERVER_PID=$! + +# Give the server a moment to start +sleep 2 + +echo "Running tests..." +python3 test_api.py +TEST_RESULT=$? + +echo "Stopping the server (PID: $SERVER_PID)..." +kill $SERVER_PID + +# Wait for the server to stop +sleep 1 + +echo "Test completed with exit code: $TEST_RESULT" +exit $TEST_RESULT \ No newline at end of file diff --git a/test_api.py b/test_api.py new file mode 100755 index 0000000..77bd24f --- /dev/null +++ b/test_api.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +""" +Test script to verify API endpoints are accessible. +""" + +import requests +import sys +import json +import time + +BASE_URL = "http://127.0.0.1:8080" + +def test_endpoint(endpoint, method="GET", data=None): + """Test an API endpoint and print the result.""" + url = f"{BASE_URL}{endpoint}" + print(f"Testing {method} {url}...") + + try: + if method == "GET": + response = requests.get(url) + elif method == "POST": + headers = {"Content-Type": "application/json"} + response = requests.post(url, data=json.dumps(data), headers=headers) + + print(f"Status code: {response.status_code}") + if response.status_code < 400: + print("Success!") + else: + print(f"Error: {response.text}") + print("-" * 50) + return response.status_code < 400 + except Exception as e: + print(f"Exception: {e}") + print("-" * 50) + return False + +def main(): + """Run tests for all endpoints.""" + # Wait for server to start + print("Waiting for server to start...") + max_retries = 5 + retries = 0 + + while retries < max_retries: + try: + requests.get(f"{BASE_URL}/") + print("Server is running!") + break + except requests.exceptions.ConnectionError: + print(f"Server not ready yet, retrying in 2 seconds... ({retries+1}/{max_retries})") + retries += 1 + time.sleep(2) + + if retries == max_retries: + print("Could not connect to server after multiple attempts.") + print("Please make sure the server is running on http://127.0.0.1:8080") + return 1 + + success = True + + # Test root endpoint + success = test_endpoint("/") and success + + # Test event endpoint + success = test_endpoint("/event") and success + + # Test event/search endpoint with POST + search_data = { + "geometry": { + "type": "Point", + "coordinates": [2.3522, 48.8566] # Paris coordinates + } + } + success = test_endpoint("/event/search", method="POST", data=search_data) and success + + # Test stats endpoint + success = test_endpoint("/stats") and success + + if success: + print("All tests passed!") + return 0 + else: + print("Some tests failed!") + return 1 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/test_db_connection.py b/test_db_connection.py new file mode 100755 index 0000000..d28a0a4 --- /dev/null +++ b/test_db_connection.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +""" +Test script to verify database connection. +This script attempts to connect to the PostgreSQL database using the same +connection logic as the main application. +""" + +import sys +from oedb.utils.db import check_db_connection +from oedb.utils.logging import logger + +def main(): + """Test database connection and print the result.""" + logger.info("Testing database connection...") + + if check_db_connection(): + logger.success("Database connection successful!") + return 0 + else: + logger.error("Database connection failed. Check your .env file and PostgreSQL configuration.") + return 1 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file