Deliver Step 1: Backend cleanup — secrets scrubbed, is_admin column, Telegram optional, crypto service, v4-v7 migrations, .env.example, setup.sh
This commit is contained in:
parent
8e630233f6
commit
cb892564d9
11 changed files with 212 additions and 37 deletions
19
.env.example
19
.env.example
|
|
@ -1,6 +1,13 @@
|
||||||
DB_PASSWORD=change-me-strong-password
|
# === REQUIRED (generated automatically by setup.sh) ===
|
||||||
SECRET_KEY=change-me-jwt-secret-key
|
SECRET_KEY=
|
||||||
STRAVA_CLIENT_ID=
|
|
||||||
STRAVA_CLIENT_SECRET=
|
# === APP URL (change for production) ===
|
||||||
POLAR_CLIENT_ID=
|
BASE_URL=http://localhost
|
||||||
POLAR_CLIENT_SECRET=
|
|
||||||
|
# === DATABASE (defaults work out of the box) ===
|
||||||
|
DB_USER=fitness
|
||||||
|
DB_NAME=fitness_rewards
|
||||||
|
|
||||||
|
# Everything else — Strava, Polar, Garmin, Fitbit, Telegram,
|
||||||
|
# USDA, Groq, etc. — is configured through the app UI or your AI agent.
|
||||||
|
# No container restart needed.
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -7,3 +7,6 @@ node_modules/
|
||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Excluded from open-source (scraped third-party content)
|
||||||
|
content/
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,7 @@
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ENV DATABASE_URL=postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards
|
|
||||||
ENV SECRET_KEY=8915f64daf616b1b1ea5cdc8242ec924adf263c045bef378d9cb880787ba4386
|
|
||||||
ENV STRAVA_CLIENT_ID=211907
|
|
||||||
ENV STRAVA_CLIENT_SECRET=d1e48251327a3760acd6dcf4129ec59350dc6b71
|
|
||||||
ENV POLAR_CLIENT_ID=13c4d547-4056-4b55-8412-a1cce84e887e
|
|
||||||
ENV POLAR_CLIENT_SECRET=29890119-2267-4a72-8888-e67ab0c0bf60
|
|
||||||
ENV BASE_URL=https://fitness.littlefake.com
|
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ class Settings(BaseSettings):
|
||||||
telegram_chat_id: str = ""
|
telegram_chat_id: str = ""
|
||||||
|
|
||||||
# App
|
# App
|
||||||
base_url: str = "https://fitness.littlefake.com"
|
base_url: str = "http://localhost"
|
||||||
timezone: str = "Asia/Bangkok"
|
timezone: str = "Asia/Bangkok"
|
||||||
|
|
||||||
model_config = {"env_file": ".env", "extra": "ignore"}
|
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,8 @@ async def run_migrations():
|
||||||
"""))
|
"""))
|
||||||
|
|
||||||
# v3: Seed keto point rules (fast_completed, keto_day, meal_logged)
|
# v3: Seed keto point rules (fast_completed, keto_day, meal_logged)
|
||||||
|
# NOTE: Keto rules below are from v2. v3+ migrations (is_admin, integration_configs,
|
||||||
|
# meal plans) are added after this block.
|
||||||
await conn.execute(text("""
|
await conn.execute(text("""
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
|
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
|
||||||
|
|
@ -157,6 +159,118 @@ async def run_migrations():
|
||||||
END $$;
|
END $$;
|
||||||
"""))
|
"""))
|
||||||
|
|
||||||
|
# v4: Add is_admin column to users
|
||||||
|
await conn.execute(text("""
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE;
|
||||||
|
EXCEPTION WHEN undefined_table THEN NULL;
|
||||||
|
END $$;
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# v4.1: Grant admin to first registered user if no admin exists
|
||||||
|
await conn.execute(text("""
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM users WHERE is_admin = TRUE) THEN
|
||||||
|
UPDATE users SET is_admin = TRUE
|
||||||
|
WHERE id = (SELECT id FROM users ORDER BY created_at ASC LIMIT 1);
|
||||||
|
END IF;
|
||||||
|
EXCEPTION WHEN undefined_table THEN NULL;
|
||||||
|
WHEN undefined_column THEN NULL;
|
||||||
|
END $$;
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# v5: Create integration_configs table
|
||||||
|
await conn.execute(text("""
|
||||||
|
CREATE TABLE IF NOT EXISTS integration_configs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
provider VARCHAR(50) NOT NULL,
|
||||||
|
config_key VARCHAR(100) NOT NULL,
|
||||||
|
config_value TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, provider, config_key)
|
||||||
|
);
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# v6: Create meal plan tables
|
||||||
|
await conn.execute(text("""
|
||||||
|
CREATE TABLE IF NOT EXISTS meal_plans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
diet_type VARCHAR(50),
|
||||||
|
daily_calories INTEGER,
|
||||||
|
daily_protein_g INTEGER,
|
||||||
|
daily_carbs_g INTEGER,
|
||||||
|
daily_fat_g INTEGER,
|
||||||
|
restrictions JSONB DEFAULT '[]',
|
||||||
|
duration_days INTEGER NOT NULL,
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
"""))
|
||||||
|
|
||||||
|
await conn.execute(text("""
|
||||||
|
CREATE TABLE IF NOT EXISTS meal_plan_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
plan_id UUID NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE,
|
||||||
|
day_number INTEGER NOT NULL,
|
||||||
|
meal_type VARCHAR(20) NOT NULL,
|
||||||
|
food_name VARCHAR(300) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
calories INTEGER,
|
||||||
|
protein_g DECIMAL(6,1),
|
||||||
|
carbs_g DECIMAL(6,1),
|
||||||
|
fat_g DECIMAL(6,1),
|
||||||
|
fiber_g DECIMAL(6,1),
|
||||||
|
serving_size VARCHAR(100),
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
"""))
|
||||||
|
|
||||||
|
await conn.execute(text("""
|
||||||
|
CREATE TABLE IF NOT EXISTS meal_compliance (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
plan_id UUID NOT NULL REFERENCES meal_plans(id),
|
||||||
|
plan_item_id UUID,
|
||||||
|
compliance_date DATE NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
substitution TEXT,
|
||||||
|
food_log_id UUID,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
"""))
|
||||||
|
|
||||||
|
await conn.execute(text("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_meal_plan_items_day ON meal_plan_items(plan_id, day_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_meal_compliance_date ON meal_compliance(user_id, compliance_date);
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# v7: Seed meal plan point rules
|
||||||
|
await conn.execute(text("""
|
||||||
|
DO $$ BEGIN
|
||||||
|
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
|
||||||
|
SELECT gen_random_uuid(), u.id, 'meal_plan_followed', 40, 'per_event', TRUE
|
||||||
|
FROM users u
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM point_rules pr
|
||||||
|
WHERE pr.user_id = u.id AND pr.category = 'meal_plan_followed'
|
||||||
|
);
|
||||||
|
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
|
||||||
|
SELECT gen_random_uuid(), u.id, 'meal_plan_partial', 20, 'per_event', TRUE
|
||||||
|
FROM users u
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM point_rules pr
|
||||||
|
WHERE pr.user_id = u.id AND pr.category = 'meal_plan_partial'
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN undefined_table THEN NULL;
|
||||||
|
END $$;
|
||||||
|
"""))
|
||||||
|
|
||||||
|
|
||||||
async def seed_resources():
|
async def seed_resources():
|
||||||
"""Seed the resource library with curated fitness programs."""
|
"""Seed the resource library with curated fitness programs."""
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ class User(Base):
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
timezone: Mapped[str] = mapped_column(String(50), default="Asia/Bangkok")
|
timezone: Mapped[str] = mapped_column(String(50), default="Asia/Bangkok")
|
||||||
|
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
profile: Mapped["UserProfile"] = relationship(back_populates="user", uselist=False, lazy="selectin")
|
profile: Mapped["UserProfile"] = relationship(back_populates="user", uselist=False, lazy="selectin")
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ from app.config import settings
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/support", tags=["support"])
|
router = APIRouter(prefix="/api/support", tags=["support"])
|
||||||
|
|
||||||
ADMIN_USERNAME = "scot"
|
# Admin check uses is_admin column on User model (first registered user gets admin=True)
|
||||||
MAX_MESSAGES_PER_DAY = 10
|
MAX_MESSAGES_PER_DAY = 10
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -105,7 +105,7 @@ def format_telegram_message(user_name: str, message: str, context: dict) -> str:
|
||||||
lines.append(f"Last workout: {last}")
|
lines.append(f"Last workout: {last}")
|
||||||
|
|
||||||
lines.append(f"\n💬 \"{message}\"")
|
lines.append(f"\n💬 \"{message}\"")
|
||||||
lines.append(f"\n👉 https://fitness.littlefake.com/support/admin")
|
lines.append(f"\n👉 {settings.base_url}/support/admin")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
|
||||||
42
backend/app/services/crypto.py
Normal file
42
backend/app/services/crypto.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""Fernet encryption for integration credentials.
|
||||||
|
|
||||||
|
Derives a Fernet-compatible key from the app SECRET_KEY using HKDF.
|
||||||
|
The SECRET_KEY can be any format (hex string, random bytes, passphrase).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
import base64
|
||||||
|
|
||||||
|
_fernet_instance: Fernet | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_fernet_key(secret_key: str) -> bytes:
|
||||||
|
"""Derive a Fernet-compatible key from the app SECRET_KEY."""
|
||||||
|
hkdf = HKDF(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=b"diligence-integration-encryption",
|
||||||
|
info=b"fernet-key",
|
||||||
|
)
|
||||||
|
raw = hkdf.derive(secret_key.encode())
|
||||||
|
return base64.urlsafe_b64encode(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def get_fernet(secret_key: str) -> Fernet:
|
||||||
|
global _fernet_instance
|
||||||
|
if _fernet_instance is None:
|
||||||
|
_fernet_instance = Fernet(_derive_fernet_key(secret_key))
|
||||||
|
return _fernet_instance
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_value(secret_key: str, plaintext: str) -> str:
|
||||||
|
"""Encrypt a plaintext string. Returns base64-encoded ciphertext."""
|
||||||
|
return get_fernet(secret_key).encrypt(plaintext.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_value(secret_key: str, ciphertext: str) -> str:
|
||||||
|
"""Decrypt a ciphertext string. Returns plaintext."""
|
||||||
|
return get_fernet(secret_key).decrypt(ciphertext.encode()).decode()
|
||||||
|
|
@ -5,7 +5,7 @@ import httpx
|
||||||
from app.schemas.food import FoodSearchResult
|
from app.schemas.food import FoodSearchResult
|
||||||
|
|
||||||
OFF_BASE = "https://world.openfoodfacts.org"
|
OFF_BASE = "https://world.openfoodfacts.org"
|
||||||
USER_AGENT = "FitnessRewards/1.0 (fitness.littlefake.com)"
|
USER_AGENT = "Diligence/1.0"
|
||||||
|
|
||||||
|
|
||||||
async def lookup_barcode(barcode: str) -> FoodSearchResult | None:
|
async def lookup_barcode(barcode: str) -> FoodSearchResult | None:
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,9 @@ services:
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards
|
- BASE_URL=${BASE_URL:-http://localhost}
|
||||||
- SECRET_KEY=8915f64daf616b1b1ea5cdc8242ec924adf263c045bef378d9cb880787ba4386
|
|
||||||
- STRAVA_CLIENT_ID=211907
|
|
||||||
- STRAVA_CLIENT_SECRET=d1e48251327a3760acd6dcf4129ec59350dc6b71
|
|
||||||
- POLAR_CLIENT_ID=13c4d547-4056-4b55-8412-a1cce84e887e
|
|
||||||
- POLAR_CLIENT_SECRET=29890119-2267-4a72-8888-e67ab0c0bf60
|
|
||||||
- GROQ_API_KEY=${GROQ_API_KEY}
|
|
||||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
|
||||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
|
|
||||||
- BASE_URL=https://fitness.littlefake.com
|
|
||||||
depends_on:
|
depends_on:
|
||||||
fitness-db:
|
fitness-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -36,21 +28,31 @@ services:
|
||||||
retries: 10
|
retries: 10
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
|
|
||||||
|
mcp-connector:
|
||||||
|
build: ./mcp-connector
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- FITNESS_API_URL=http://backend:8000
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
|
||||||
fitness-db:
|
fitness-db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=fitness
|
- POSTGRES_USER=${DB_USER:-fitness}
|
||||||
- POSTGRES_DB=fitness_rewards
|
- POSTGRES_DB=${DB_NAME:-fitness_rewards}
|
||||||
- POSTGRES_HOST_AUTH_METHOD=trust
|
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||||
volumes:
|
volumes:
|
||||||
- fitness_db_data:/var/lib/postgresql/data
|
- fitness_db_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U fitness -d fitness_rewards"]
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-fitness}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
fitness_db_data:
|
fitness_db_data:
|
||||||
|
|
|
||||||
19
setup.sh
Executable file
19
setup.sh
Executable file
|
|
@ -0,0 +1,19 @@
|
||||||
|
#!/bin/bash
|
||||||
|
echo "💪 Diligence — Setup"
|
||||||
|
|
||||||
|
if [ -f .env ]; then
|
||||||
|
echo ".env already exists. Delete it first to regenerate."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SECRET=$(openssl rand -hex 32)
|
||||||
|
cp .env.example .env
|
||||||
|
sed -i "s/^SECRET_KEY=.*/SECRET_KEY=${SECRET}/" .env
|
||||||
|
|
||||||
|
echo "✅ .env created with random SECRET_KEY"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " docker compose up -d"
|
||||||
|
echo " Open http://localhost"
|
||||||
|
echo " Register your account"
|
||||||
|
echo " Configure integrations via Settings or your AI agent"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue