Преглед изворни кода

TG-11: Implement secure local SQLite sessions, bcrypt login, and Llama3.1 chat streaming endpoint

FerRo988 пре 3 дана
родитељ
комит
458c00aa4b
7 измењених фајлова са 206 додато и 55 уклоњено
  1. 23 0
      DOCUMENTATION_CHECKLIST.md
  2. 42 27
      PROJECT_CONTEXT.md
  3. 65 5
      database.py
  4. 39 15
      main.py
  5. 0 1
      requirements.txt
  6. 32 7
      static/script.js
  7. 5 0
      user_stories.md

+ 23 - 0
DOCUMENTATION_CHECKLIST.md

@@ -0,0 +1,23 @@
+# DOCUMENTATION_CHECKLIST.md
+
+This file tracks information that Antigravity (the AI) must provide to the Tech Lead (User) for the final project documentation.
+
+## Technical Document Requirements
+- [ ] **Installation & Configuration:** Step-by-step guide for a clean Ubuntu 24.04 VM.
+- [ ] **Tech Stack Rationale:** Why FastAPI, SQLite/PostgreSQL, etc.
+- [ ] **LLM Selection:** Explain which local model is used (e.g., Llama 3.1 8B), why it was chosen, and its quantization level.
+- [ ] **Agent Permissions:** Explain how and why Antigravity model permissions were configured.
+- [ ] **Infrastructure Diagram:** Description/code for a diagram showing how app components communicate locally.
+- [ ] **Privacy Proof:** Explanation of how we verified that no user data leaves the server.
+- [ ] **Agent Reflection:** Summary of what Antigravity struggled with and how those issues were handled.
+
+## User Manual Requirements
+- [ ] **Account Management:** How to register and log in.
+- [ ] **Nutritional Queries:** How to get info on specific foods.
+- [ ] **Food Combinations:** How to build and save dietary lists.
+- [ ] **AI Chat:** How to interact with the nutrition AI.
+- [ ] **Menu Proposals:** How to generate menus with constraints.
+
+## Sprint Metrics & Links
+- [ ] **Implementation Plans:** Links to all relevant artifacts.
+- [ ] **Taiga Task IDs:** Mapping of work to TG-<ID> commits.

+ 42 - 27
PROJECT_CONTEXT.md

@@ -1,32 +1,47 @@
 # PROJECT_CONTEXT.md
 
-## Project Overview
-LocalFoodAI is a local food AI that provides complete nutritional information on foods and can generate menu proposals based on user specifications. It runs entirely on a local Ubuntu 24.04 VM (8 vCPU, 30 GB RAM, no GPU). No user data leaves the server. The backend is Python-based.
+## 1. Vision Statement
+LocalFoodAI is a local food AI that provides full nutritional value information on any food and can generate complete menu proposals based on the user's specification.
 
-## Tech Stack
-- **Operating System:** Ubuntu 24.04 (VM)
-- **Backend:** Python 3.11+
-- **Database:** SQLite (local, no cloud)
-- **Local LLM:** Qwen 3.5 9B (quantized via Ollama, Q4_K_M or equivalent)
-  - CPU-only compatible
-  - Fits in 30 GB RAM with quantization
-  - Instruction-following tuned
-  - Open-source license (compatible with student projects)
-- **Local Web Search Tool:** SearXNG (fully local, anonymous)
-- **Version Control:** Git via Gogs on git.btshub.lu
-- **CI / Deployment:** Antigravity Agent Manager handles task execution
-- **LLM Hosting:** Ollama local instance, no cloud APIs
+### Key Features:
+- **User Accounts:** Create an account and log in.
+- **Nutritional Info:** Complete data (macros, minerals, vitamins, amino acids, etc.) for any food.
+- **Food Combinations:** Nutritional overview for user-specified quantities/combinations.
+- **Nutrient Search:** Search for specific nutrients and get a sortable list of foods.
+- **Saved Lists:** Store and edit food combinations in named lists.
+- **Menu Proposals:** AI-generated menus based on nutritional goals and constraints (e.g., allergies).
+- **Nutrition Chat:** Competent AI interaction for any nutrition-related queries.
+- **Local Web Search:** Anonymous tool for gathering missing info without leaving the server.
 
-## Rules & Constraints
-- **No external APIs or cloud services** for computation or data fetching
-- **All data and computation must remain on the local VM**
-- **All commits must be traceable to a Taiga Task ID**
-- Antigravity must **read this file before starting any task** to avoid hallucinating cloud-based solutions
-- Model and backend selection must fit **VM constraints** (CPU-only, RAM limit)
+### Constraints:
+- **Privacy:** No user data leaves the server. All computation is local.
+- **Open Source:** Public repo on `https://git.btshub.lu/LocalFoodAI_<IAM>`. No confidential data in Git.
+- **Environment:** Ubuntu 24.04 VM (8 vCPUs, 30 GB RAM, no dedicated GPU).
+- **Backend:** Python/FastAPI with local database (SQLite/PostgreSQL).
+- **LLM:** Quantized local models (via Ollama) optimized for CPU.
 
-## Best Practices
-- Use quantized models for CPU efficiency
-- Verify all AI-generated Python or database logic before approving commits
-- Test database queries and prompt logic locally before integrating
-- Attach all artifacts (Implementation Plans, task lists, browser recordings) to the corresponding Taiga task
-- Always include the TG-<ID> prefix in commit messages
+## 2. Roles
+- **Product Owner & Tech Lead:** User (Roni) - Interviews customer, writes Taiga backlog, directs Antigravity.
+- **Development Team:** Antigravity (AI) - Writes code, generates implementation plans, verifies logic.
+- **Customer & Scrum Master:** Teacher (Gilles Everling) - Stakeholder and authority on "Done".
+
+## 3. Workflow & Scrum Rules
+- **Sprint Duration:** 1 week.
+- **Meetings:** 
+  - Daily Scrum (Wiki documentation: Accomplishments, Next steps, Obstacles).
+  - Thursday: Sprint Review, Sprint Retrospective, Sprint Planning.
+- **Backlog Management:** User Stories in Taiga, broken into Technical Tasks.
+- **Review Policy:** "Request Review" (Antigravity must not auto-proceed). User must approve Python/DB logic.
+- **Commit Format:** Every commit message must start with `TG-<task_id>: <description>`.
+- **Taiga-Gogs Integration:** Webhooks track progress via commit messages.
+
+## 4. Documentation Requirements (For Final Grade)
+- **Technical Document:** Installation/config guide, tech stack explanation, LLM selection rationale, diagram of local communication, proof of 100% data privacy.
+- **User Manual:** Non-developer guide on how to use all features.
+- **Antigravity Reflection:** Explain model usage, agent permissions, and how struggles were handled.
+
+## 5. VM Access
+- **Host:** 192.168.130.171
+- **User:** roni
+- **Password:** BTSai123
+- **Root Password:** BTSai123

+ 65 - 5
database.py

@@ -1,6 +1,8 @@
 import sqlite3
 import os
 import logging
+import secrets
+from datetime import datetime, timedelta
 from typing import Optional, Dict, Any
 
 logger = logging.getLogger(__name__)
@@ -28,6 +30,16 @@ def create_tables():
             created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
         )
         ''')
+
+        # Create sessions table for database-backed tokens
+        cursor.execute('''
+        CREATE TABLE IF NOT EXISTS sessions (
+            token TEXT PRIMARY KEY,
+            user_id INTEGER NOT NULL,
+            expires_at TIMESTAMP NOT NULL,
+            FOREIGN KEY (user_id) REFERENCES users (id)
+        )
+        ''')
         
         conn.commit()
         conn.close()
@@ -49,8 +61,8 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
         logger.error(f"Database error fetching user: {e}")
         return None
 
-def create_user(username: str, password_hash: str) -> bool:
-    """Creates a user securely. Returns True if successful, False if username exists."""
+def create_user(username: str, password_hash: str) -> Optional[int]:
+    """Creates a user securely. Returns user_id if successful, None if username exists."""
     try:
         conn = get_db_connection()
         cursor = conn.cursor()
@@ -58,12 +70,60 @@ def create_user(username: str, password_hash: str) -> bool:
             "INSERT INTO users (username, password_hash) VALUES (?, ?)",
             (username, password_hash)
         )
+        user_id = cursor.lastrowid
         conn.commit()
         conn.close()
-        return True
+        return user_id
     except sqlite3.IntegrityError:
-        # Prevent duplicate usernames explicitly
-        return False
+        return None
     except Exception as e:
         logger.error(f"Database error during user creation: {e}")
         raise
+
+def create_session(user_id: int) -> str:
+    """Create a secure 32-character session token in the DB valid for 24h"""
+    token = secrets.token_urlsafe(32)
+    expires_at = datetime.now() + timedelta(hours=24)
+    
+    try:
+        conn = get_db_connection()
+        cursor = conn.cursor()
+        cursor.execute(
+            "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)",
+            (token, user_id, expires_at)
+        )
+        conn.commit()
+        conn.close()
+        return token
+    except Exception as e:
+        logger.error(f"Error creating session: {e}")
+        raise
+
+def get_user_from_token(token: str) -> Optional[Dict[str, Any]]:
+    """Verify a session token and return the associated user data if valid and not expired"""
+    try:
+        conn = get_db_connection()
+        cursor = conn.cursor()
+        # Find user if token exists and hasn't expired
+        cursor.execute('''
+            SELECT users.* FROM users
+            JOIN sessions ON users.id = sessions.user_id
+            WHERE sessions.token = ? AND sessions.expires_at > ?
+        ''', (token, datetime.now()))
+        row = cursor.fetchone()
+        conn.close()
+        return dict(row) if row else None
+    except Exception as e:
+        logger.error(f"Database error verifying token: {e}")
+        return None
+
+def delete_session(token: str):
+    """Securely remove a session token when the user logs out"""
+    try:
+        conn = get_db_connection()
+        cursor = conn.cursor()
+        cursor.execute("DELETE FROM sessions WHERE token = ?", (token,))
+        conn.commit()
+        conn.close()
+    except Exception as e:
+        logger.error(f"Error deleting session: {e}")

+ 39 - 15
main.py

@@ -1,14 +1,14 @@
 import json
 import logging
 import httpx
+import bcrypt
 from contextlib import asynccontextmanager
-from fastapi import FastAPI, HTTPException
-from database import create_tables, create_user, get_user_by_username
-from passlib.context import CryptContext
+from fastapi import FastAPI, HTTPException, Depends, Header
+from database import create_tables, create_user, get_user_by_username, create_session, get_user_from_token, delete_session
 from fastapi.responses import HTMLResponse, StreamingResponse
 from fastapi.staticfiles import StaticFiles
 from pydantic import BaseModel
-from typing import List, Generator
+from typing import List, Generator, Optional
 
 logging.basicConfig(level=logging.INFO)
 logger = logging.getLogger(__name__)
@@ -20,13 +20,17 @@ async def lifespan(app: FastAPI):
 
 app = FastAPI(title="LocalFoodAI Chat", lifespan=lifespan)
 
-pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+# Use direct bcrypt for better environment compatibility
+def get_password_hash(password: str):
+    # Hash requires bytes
+    pwd_bytes = password.encode('utf-8')
+    salt = bcrypt.gensalt()
+    hashed = bcrypt.hashpw(pwd_bytes, salt)
+    return hashed.decode('utf-8')
 
-def get_password_hash(password):
-    return pwd_context.hash(password)
-
-def verify_password(plain_password, hashed_password):
-    return pwd_context.verify(plain_password, hashed_password)
+def verify_password(plain_password: str, hashed_password: str):
+    # bcrypt.checkpw handles verification
+    return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
 
 class UserCreate(BaseModel):
     username: str
@@ -36,6 +40,16 @@ class UserLogin(BaseModel):
     username: str
     password: str
 
+async def get_current_user(authorization: Optional[str] = Header(None)):
+    if not authorization or not authorization.startswith("Bearer "):
+        raise HTTPException(status_code=401, detail="Authentication required")
+    
+    token = authorization.split(" ")[1]
+    user = get_user_from_token(token)
+    if not user:
+        raise HTTPException(status_code=401, detail="Invalid or expired session")
+    return user
+
 OLLAMA_URL = "http://localhost:11434/api/chat"
 MODEL_NAME = "llama3.1:8b"
 
@@ -66,11 +80,13 @@ async def register_user(user: UserCreate):
         raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
     
     hashed_password = get_password_hash(user.password)
-    success = create_user(user.username.strip(), hashed_password)
-    if not success:
+    user_id = create_user(user.username.strip(), hashed_password)
+    if not user_id:
         raise HTTPException(status_code=400, detail="Username already exists")
     
-    return {"message": "User registered successfully"}
+    # Auto-login after registration
+    token = create_session(user_id)
+    return {"message": "User registered successfully", "token": token, "username": user.username.strip()}
 
 @app.post("/api/login")
 async def login_user(user: UserLogin):
@@ -81,10 +97,18 @@ async def login_user(user: UserLogin):
     if not verify_password(user.password, db_user["password_hash"]):
         raise HTTPException(status_code=401, detail="Invalid username or password")
     
-    return {"status": "success", "username": db_user["username"]}
+    token = create_session(db_user["id"])
+    return {"status": "success", "username": db_user["username"], "token": token}
+
+@app.post("/api/logout")
+async def logout(authorization: Optional[str] = Header(None)):
+    if authorization and authorization.startswith("Bearer "):
+        token = authorization.split(" ")[1]
+        delete_session(token)
+    return {"message": "Logged out successfully"}
 
 @app.post("/chat")
-async def chat_endpoint(request: ChatRequest):
+async def chat_endpoint(request: ChatRequest, current_user: dict = Depends(get_current_user)):
     """Proxy chat requests to the local Ollama instance with streaming support"""
     payload = {
         "model": MODEL_NAME,

+ 0 - 1
requirements.txt

@@ -1,5 +1,4 @@
 fastapi>=0.100.0
 uvicorn>=0.23.0
 httpx>=0.24.0
-passlib[bcrypt]>=1.7.4
 bcrypt>=4.0.1

+ 32 - 7
static/script.js

@@ -54,13 +54,23 @@ document.addEventListener('DOMContentLoaded', () => {
         const loadingId = addTypingIndicator();
 
         try {
+            const token = localStorage.getItem('localFoodToken');
             // Fetch response from backend
             const response = await fetch('/chat', {
                 method: 'POST',
-                headers: { 'Content-Type': 'application/json' },
+                headers: { 
+                    'Content-Type': 'application/json',
+                    'Authorization': `Bearer ${token}`
+                },
                 body: JSON.stringify({ messages: chatHistory })
             });
 
+            if (response.status === 401) {
+                setLoggedOutState();
+                addMessage('system', 'Your session has expired. Please log in again.');
+                return;
+            }
+
             if (!response.ok) {
                 throw new Error(`HTTP error! status: ${response.status}`);
             }
@@ -206,12 +216,14 @@ document.addEventListener('DOMContentLoaded', () => {
 
     // Check session on load
     const savedUser = localStorage.getItem('localFoodUser');
-    if (savedUser) {
-        setLoggedInState(savedUser);
+    const savedToken = localStorage.getItem('localFoodToken');
+    if (savedUser && savedToken) {
+        setLoggedInState(savedUser, savedToken);
     }
 
-    function setLoggedInState(username) {
+    function setLoggedInState(username, token) {
         localStorage.setItem('localFoodUser', username);
+        localStorage.setItem('localFoodToken', token);
         userGreeting.textContent = `Welcome, ${username}`;
         
         authScreen.classList.add('fade-out');
@@ -223,8 +235,21 @@ document.addEventListener('DOMContentLoaded', () => {
         }, 500);
     }
 
-    function setLoggedOutState() {
+    async function setLoggedOutState() {
+        const token = localStorage.getItem('localFoodToken');
+        if (token) {
+            try {
+                await fetch('/api/logout', {
+                    method: 'POST',
+                    headers: { 'Authorization': `Bearer ${token}` }
+                });
+            } catch (err) {
+                console.error("Error during logout:", err);
+            }
+        }
+
         localStorage.removeItem('localFoodUser');
+        localStorage.removeItem('localFoodToken');
         chatApp.style.display = 'none';
         chatApp.classList.remove('fade-in');
         
@@ -277,7 +302,7 @@ document.addEventListener('DOMContentLoaded', () => {
             if (!response.ok) {
                 loginError.textContent = data.detail || 'Login failed.';
             } else {
-                setLoggedInState(data.username);
+                setLoggedInState(data.username, data.token);
             }
         } catch (err) {
             loginError.textContent = 'Server error. Is the backend running?';
@@ -318,7 +343,7 @@ document.addEventListener('DOMContentLoaded', () => {
             } else {
                 regSuccess.textContent = 'Account created! Logging in...';
                 setTimeout(() => {
-                    setLoggedInState(username);
+                    setLoggedInState(data.username, data.token);
                 }, 1000);
             }
         } catch (err) {

+ 5 - 0
user_stories.md

@@ -3,6 +3,11 @@
 **Vision Statement:**
 A local food AI that provides full nutritional value information on any food and can generate complete menu proposals based on the user's specification.
 
+**Development Rules:**
+- **Commit Format:** Every commit MUST use the format: `TG-<task_id>: <description>`.
+- **Review Policy:** Antigravity must generate an Implementation Plan and wait for Tech Lead (User) approval before writing code.
+- **Privacy:** 100% local. No external API calls. No data leaves the VM.
+
 _Note: Sprints 1–3 covered initial VM setup, Ollama framework installation, Gogs repository creation, and the first AI chat interface prototype. Each Sprint below totals exactly 10 points._
 
 ---