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:
Claude 2026-06-15 14:29:53 +00:00
parent 8e630233f6
commit cb892564d9
11 changed files with 212 additions and 37 deletions

View file

@ -1,20 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
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"]

View file

@ -29,7 +29,7 @@ class Settings(BaseSettings):
telegram_chat_id: str = ""
# App
base_url: str = "https://fitness.littlefake.com"
base_url: str = "http://localhost"
timezone: str = "Asia/Bangkok"
model_config = {"env_file": ".env", "extra": "ignore"}

View file

@ -137,6 +137,8 @@ async def run_migrations():
"""))
# 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("""
DO $$ BEGIN
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
@ -157,6 +159,118 @@ async def run_migrations():
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():
"""Seed the resource library with curated fitness programs."""

View file

@ -19,6 +19,7 @@ class User(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
timezone: Mapped[str] = mapped_column(String(50), default="Asia/Bangkok")
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships
profile: Mapped["UserProfile"] = relationship(back_populates="user", uselist=False, lazy="selectin")

View file

@ -24,7 +24,7 @@ from app.config import settings
logger = logging.getLogger(__name__)
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
@ -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"\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)

View 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()

View file

@ -5,7 +5,7 @@ import httpx
from app.schemas.food import FoodSearchResult
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: