diff --git a/soma/.env.example b/soma/.env.example new file mode 100644 index 0000000..fd4f9eb --- /dev/null +++ b/soma/.env.example @@ -0,0 +1,47 @@ +# SOMA Environment Variables +# Copy to .env on your server. Never commit .env to any repository. + +# ─── App ────────────────────────────────────────────────────────────── +# Generate with: openssl rand -hex 32 +NEXTAUTH_SECRET= +NEXTAUTH_URL=https://yourdomain.com +ALLOW_REGISTRATION=true +# Generate with: openssl rand -hex 32 +JWT_SECRET= + +# ─── Database ───────────────────────────────────────────────────────── +DATABASE_URL=postgresql+asyncpg://soma:somapass@postgres:5432/somadb +POSTGRES_USER=soma +POSTGRES_PASSWORD=somapass +POSTGRES_DB=somadb + +# ─── Redis ──────────────────────────────────────────────────────────── +REDIS_URL=redis://redis:6379/0 + +# ─── LLM — Personal (internal Docker network, NEVER changes) ────────── +# Personal data stays on your server. This must always point to Ollama. +OLLAMA_BASE_URL=http://ollama:11434 +OLLAMA_MODEL=phi3:mini + +# ─── LLM — Documents (book/PDF queries only, no personal context) ───── +GEMINI_API_KEY= + +# ─── LLM — Research (web queries only, no personal context) ─────────── +PERPLEXITY_API_KEY= + +# ─── LLM — General (factual queries only, no personal context) ──────── +GROQ_API_KEY= + +# ─── Email ──────────────────────────────────────────────────────────── +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION=ap-south-1 +SES_FROM_EMAIL=noreply@yourdomain.com +REPORT_TO_EMAIL=your@gmail.com + +# ─── Config ─────────────────────────────────────────────────────────── +SOMA_CONFIG_PATH=/app/config.yaml + +# ─── Rate Limiting ──────────────────────────────────────────────────── +LOGIN_RATE_LIMIT_ATTEMPTS=5 +LOGIN_RATE_LIMIT_WINDOW=900 \ No newline at end of file diff --git a/soma/Makefile b/soma/Makefile new file mode 100644 index 0000000..9e78009 --- /dev/null +++ b/soma/Makefile @@ -0,0 +1,70 @@ +.PHONY: up down logs restart build migrate seed ssl backup ollama-pull shell-backend shell-db + +# Default: start all containers +up: + docker compose up -d + +# Start and rebuild images +build: + docker compose up -d --build + +# Stop all containers +down: + docker compose down + +# Stop and remove all data volumes (DESTRUCTIVE — prompts for confirmation) +destroy: + @echo "WARNING: This will delete ALL data including the database. Type YES to confirm:" + @read confirm && [ "$$confirm" = "YES" ] || (echo "Aborted." && exit 1) + docker compose down -v + +# View logs (all services, follow) +logs: + docker compose logs -f + +# View logs for a specific service: make logs-backend +logs-%: + docker compose logs -f $* + +# Restart a service: make restart-backend +restart-%: + docker compose restart $* + +# Run database migrations +migrate: + docker compose exec backend alembic upgrade head + +# Seed tasks from markdown file +# Usage: make seed FILE=/path/to/life_plan.md +seed: + docker compose exec backend python /app/scripts/seed_from_markdown.py --file $(FILE) + +# Obtain/renew SSL certificate +# Usage: make ssl DOMAIN=rk.singularraritylabs.com EMAIL=your@email.com +ssl: + docker compose stop nginx + certbot certonly --standalone -d $(DOMAIN) --email $(EMAIL) --agree-tos --non-interactive + docker compose start nginx + +# Manual database backup to S3 +backup: + docker compose exec backend bash /app/scripts/backup_db.sh + +# Pull Ollama model (run once after first deploy) +ollama-pull: + docker compose exec ollama ollama pull phi3:mini + +# Shell access +shell-backend: + docker compose exec backend bash + +shell-db: + docker compose exec postgres psql -U soma -d somadb + +# Health check +health: + curl -s http://localhost/health | python3 -m json.tool + +# Show container status +status: + docker compose ps \ No newline at end of file diff --git a/soma/README.md b/soma/README.md new file mode 100644 index 0000000..d1549a6 --- /dev/null +++ b/soma/README.md @@ -0,0 +1 @@ +see docs/TID.md \ No newline at end of file diff --git a/soma/backend/Dockerfile b/soma/backend/Dockerfile new file mode 100644 index 0000000..4e7c129 --- /dev/null +++ b/soma/backend/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.11-slim + +# System deps +RUN apt-get update && apt-get install -y \ + gcc \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install Python dependencies before copying app code +# (layer cache: dependencies change less often than code) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create uploads directory +RUN mkdir -p /app/uploads + +# Non-root user for security +RUN addgroup --system appgroup && adduser --system --group appgroup +RUN chown -R appgroup:appgroup /app +USER appgroup + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] \ No newline at end of file diff --git a/soma/backend/alembic.ini b/soma/backend/alembic.ini new file mode 100644 index 0000000..5ca78c7 --- /dev/null +++ b/soma/backend/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = driver://user:pass@localhost/dbname + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/soma/backend/app/__init__.py b/soma/backend/app/__init__.py new file mode 100644 index 0000000..773b1e5 --- /dev/null +++ b/soma/backend/app/__init__.py @@ -0,0 +1,3 @@ +# SOMA Backend +# Phase 0 completion signal — this file's existence indicates the skeleton is ready. +# Bot 1 and Bot 2 may now begin parallel work. \ No newline at end of file diff --git a/soma/backend/requirements.txt b/soma/backend/requirements.txt new file mode 100644 index 0000000..c61362e --- /dev/null +++ b/soma/backend/requirements.txt @@ -0,0 +1,22 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.0 +sqlalchemy[asyncio]==2.0.35 +alembic==1.13.0 +asyncpg==0.29.0 +redis[hiredis]==5.0.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +pyotp==2.9.0 +qrcode[pil]==7.4.2 +pdfplumber==0.11.0 +ollama==0.3.0 +google-generativeai==0.8.0 +groq==0.9.0 +httpx==0.27.0 +apscheduler==3.10.4 +boto3==1.34.0 +python-multipart==0.0.9 +pydantic-settings==2.4.0 +chromadb==0.5.0 +psycopg2-binary==2.9.9 +pytz==2024.1 \ No newline at end of file diff --git a/soma/config.example.yaml b/soma/config.example.yaml new file mode 100644 index 0000000..3c97142 --- /dev/null +++ b/soma/config.example.yaml @@ -0,0 +1,95 @@ +# SOMA Configuration +# Copy to config.yaml and fill in your values. +# This file drives all personal behavior. Keep config.yaml private. + +app: + name: "SOMA" + tagline: "Self-Organizing Mastery Architecture" + url: "https://yourdomain.com" + timezone: "Asia/Kolkata" # pytz timezone string + +user: + display_name: "Your Name" # shown on dashboard greeting + email: "your@email.com" + +llm: + personal: + provider: "ollama" # DO NOT CHANGE — personal data only + model: "phi3:mini" # phi3:mini recommended for t3.small RAM budget + base_url: "http://ollama:11434" + document: + provider: "gemini" + model: "gemini-1.5-flash-latest" + research: + provider: "perplexity" + model: "llama-3.1-sonar-large-128k-online" + general: + provider: "groq" + model: "llama-3.3-70b-versatile" + +email: + provider: "ses" # ses | smtp + from_address: "noreply@yourdomain.com" + reports: + daily_time: "07:00" # IST, 24h format + weekly_day: "friday" + weekly_time: "18:00" # IST + monthly_day: 1 + monthly_time: "08:00" # IST + +kanban: + project_tags: + - name: "general" + color: "#6B7280" + # Add your project tags: + # - name: "openclaw" + # color: "#3b82f6" + # - name: "mak" + # color: "#a855f7" + # - name: "rarity-media" + # color: "#f97316" + # - name: "japan" + # color: "#ef4444" + # - name: "germany" + # color: "#22c55e" + # - name: "personal" + # color: "#14b8a6" + # - name: "habits" + # color: "#eab308" + seed_file: "" # absolute path to your life plan .md file + +gamification: + enabled: true + xp: + task_low: 25 + task_medium: 50 + task_high: 100 + task_critical: 200 + journal_entry: 30 + journal_streak_bonus: 100 # awarded every 7-day streak + habit_log: 20 + habit_streak_bonus: 300 # awarded every 30-day streak + +habits: + tracked: [] + # Uncomment and customize: + # - name: "japanese" # must match habit_name used in API calls + # display: "Japanese Study" # shown in UI + # icon: "🗾" + # daily_xp: 20 + # - name: "guitar" + # display: "Guitar Practice" + # icon: "🎸" + # daily_xp: 20 + # - name: "exercise" + # display: "Exercise" + # icon: "💪" + # daily_xp: 20 + +reports: + include_quotes: true + quote_pool: + - "Small daily improvements over time lead to stunning results." + - "The quality of your life is determined by the quality of your daily habits." + - "Success is the sum of small efforts, repeated day in and day out." + - "Don't count the days. Make the days count." \ No newline at end of file diff --git a/soma/docker-compose.override.yml.example b/soma/docker-compose.override.yml.example new file mode 100644 index 0000000..306d70f --- /dev/null +++ b/soma/docker-compose.override.yml.example @@ -0,0 +1,21 @@ +# Copy to docker-compose.override.yml for local development +# This file is gitignored + +version: "3.9" + +services: + backend: + volumes: + - ./backend:/app # live reload in development + environment: + - PYTHONDONTWRITEBYTECODE=1 + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + + frontend: + volumes: + - ./frontend:/app # live reload in development + - /app/node_modules # preserve container node_modules + - /app/.next # preserve build cache + environment: + - NODE_ENV=development + command: npm run dev \ No newline at end of file diff --git a/soma/docker-compose.yml b/soma/docker-compose.yml new file mode 100644 index 0000000..4908dd9 --- /dev/null +++ b/soma/docker-compose.yml @@ -0,0 +1,153 @@ +version: "3.9" + +networks: + soma_network: + driver: bridge + +volumes: + postgres_data: + redis_data: + ollama_data: + chroma_data: + uploads_data: + +services: + + nginx: + image: nginx:alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + - /etc/letsencrypt:/etc/letsencrypt:ro + depends_on: + frontend: + condition: service_started + backend: + condition: service_healthy + networks: + - soma_network + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + restart: unless-stopped + environment: + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} + - NEXTAUTH_URL=${NEXTAUTH_URL} + - BACKEND_URL=http://backend:8000 + - NEXT_PUBLIC_API_URL=/api/v1 + depends_on: + backend: + condition: service_healthy + networks: + - soma_network + + backend: + build: + context: ./backend + dockerfile: Dockerfile + restart: unless-stopped + environment: + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=${REDIS_URL} + - JWT_SECRET=${JWT_SECRET} + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434} + - OLLAMA_MODEL=${OLLAMA_MODEL:-phi3:mini} + - GEMINI_API_KEY=${GEMINI_API_KEY} + - PERPLEXITY_API_KEY=${PERPLEXITY_API_KEY} + - GROQ_API_KEY=${GROQ_API_KEY} + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} + - AWS_REGION=${AWS_REGION:-ap-south-1} + - SES_FROM_EMAIL=${SES_FROM_EMAIL} + - REPORT_TO_EMAIL=${REPORT_TO_EMAIL} + - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true} + - LOGIN_RATE_LIMIT_ATTEMPTS=${LOGIN_RATE_LIMIT_ATTEMPTS:-5} + - LOGIN_RATE_LIMIT_WINDOW=${LOGIN_RATE_LIMIT_WINDOW:-900} + - SOMA_CONFIG_PATH=/app/config.yaml + volumes: + - ./config.yaml:/app/config.yaml:ro + - uploads_data:/app/uploads + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - soma_network + + postgres: + image: postgres:15-alpine + restart: unless-stopped + environment: + - POSTGRES_USER=${POSTGRES_USER:-soma} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-somapass} + - POSTGRES_DB=${POSTGRES_DB:-somadb} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-soma} -d ${POSTGRES_DB:-somadb}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - soma_network + + redis: + image: redis:7-alpine + restart: unless-stopped + command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - soma_network + + ollama: + image: ollama/ollama + restart: unless-stopped + volumes: + - ollama_data:/root/.ollama + environment: + - OLLAMA_HOST=0.0.0.0 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + networks: + - soma_network + + chromadb: + image: chromadb/chroma + restart: unless-stopped + volumes: + - chroma_data:/chroma/.chroma/index + environment: + - IS_PERSISTENT=TRUE + - ANONYMIZED_TELEMETRY=FALSE + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/heartbeat"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + networks: + - soma_network \ No newline at end of file diff --git a/soma/docs/.gitkeep b/soma/docs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/soma/frontend/Dockerfile b/soma/frontend/Dockerfile new file mode 100644 index 0000000..a6c2722 --- /dev/null +++ b/soma/frontend/Dockerfile @@ -0,0 +1,33 @@ +# Stage 1: Dependencies +FROM node:20-alpine AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --only=production + +# Stage 2: Build +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED 1 +RUN npm run build + +# Stage 3: Runner +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV production +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT 3000 + +CMD ["node", "server.js"] \ No newline at end of file diff --git a/soma/frontend/package.json b/soma/frontend/package.json new file mode 100644 index 0000000..303c393 --- /dev/null +++ b/soma/frontend/package.json @@ -0,0 +1,56 @@ +{ + "name": "soma-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "next": "14.2.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "next-auth": "5.0.0-beta", + "next-pwa": "5.6.0", + "@tanstack/react-query": "5.0.0", + "zustand": "4.5.0", + "framer-motion": "11.0.0", + "react-hot-toast": "2.4.0", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@tiptap/react": "^2.4.0", + "@tiptap/starter-kit": "^2.4.0", + "@tiptap/extension-placeholder": "^2.4.0", + "eventsource-parser": "^1.1.2", + "date-fns": "^3.6.0", + "lucide-react": "^0.400.0", + "clsx": "^2.1.1", + "tailwind-merge": "^2.3.0", + "class-variance-authority": "^0.7.0", + "@radix-ui/react-dialog": "^1.1.0", + "@radix-ui/react-dropdown-menu": "^2.1.0", + "@radix-ui/react-select": "^2.1.0", + "@radix-ui/react-sheet": "^1.0.0", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-toast": "^1.2.0", + "@radix-ui/react-tooltip": "^1.1.0", + "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-skeleton": "^1.0.0" + }, + "devDependencies": { + "typescript": "5.4.0", + "@types/node": "^20.0.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "tailwindcss": "3.4.0", + "postcss": "^8.4.38", + "autoprefixer": "^10.4.19", + "@tailwindcss/typography": "^0.5.13", + "eslint": "^8.57.0", + "eslint-config-next": "14.2.0" + } +} \ No newline at end of file diff --git a/soma/nginx/nginx.conf b/soma/nginx/nginx.conf new file mode 100644 index 0000000..b865049 --- /dev/null +++ b/soma/nginx/nginx.conf @@ -0,0 +1,101 @@ +worker_processes auto; +error_log /var/log/nginx/error.log warn; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + client_max_body_size 50M; # PDF uploads up to 50MB + + # Gzip + gzip on; + gzip_types text/plain application/json application/javascript text/css; + + # Security headers applied to all responses + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(self), geolocation=()" always; + + upstream frontend { + server frontend:3000; + } + + upstream backend { + server backend:8000; + } + + # HTTP → HTTPS redirect + server { + listen 80; + server_name _; + return 301 https://$host$request_uri; + } + + # Main HTTPS server + server { + listen 443 ssl http2; + server_name _; # replace with your domain + + ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:10m; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; font-src 'self';" always; + + # Health check — no auth, no log + location = /health { + proxy_pass http://backend; + proxy_set_header Host $host; + access_log off; + } + + # Backend API — all /api/v1/* routes + location /api/v1/ { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # SSE streaming — disable buffering + proxy_set_header Connection ''; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 300s; # long timeout for SSE streams + } + + # Frontend — everything else + location / { + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + # Static assets cache + location /_next/static/ { + proxy_pass http://frontend; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + } +} \ No newline at end of file diff --git a/soma/nginx/ssl/.gitkeep b/soma/nginx/ssl/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/soma/scripts/admin_recovery.py b/soma/scripts/admin_recovery.py new file mode 100644 index 0000000..cf01756 --- /dev/null +++ b/soma/scripts/admin_recovery.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +admin_recovery.py + +Emergency TOTP reset tool. Run ONLY via SSH on the EC2 instance. +This script is NOT exposed via any HTTP endpoint. +It resets the TOTP secret for the admin account, prints a new QR code +and backup codes, which must be scanned in Google Authenticator. + +Usage (on EC2): + docker compose exec backend python /app/scripts/admin_recovery.py + +Or directly: + ssh ubuntu@your-ec2-ip + cd ~/soma + docker compose exec backend python /app/scripts/admin_recovery.py +""" + +import asyncio +import sys +import os +import secrets + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + + +async def reset_totp(): + from app.database import AsyncSessionLocal + from app.models.user import User + from app.config import settings + from sqlalchemy import select + import pyotp + import qrcode + import io + + async with AsyncSessionLocal() as db: + # Find the admin user (single-tenant: only one user exists) + result = await db.execute(select(User).limit(1)) + user = result.scalar_one_or_none() + + if not user: + print("ERROR: No user found in database. Run the setup flow first.") + sys.exit(1) + + print(f"\nResetting TOTP for: {user.email}") + print("=" * 60) + + # Confirm action + confirm = input("Type 'RESET' to confirm TOTP reset: ").strip() + if confirm != "RESET": + print("Aborted.") + sys.exit(0) + + # Generate new TOTP secret + new_secret = pyotp.random_base32() + + # Generate new backup codes (8 codes, 8 chars each) + backup_codes = [secrets.token_hex(4).upper() for _ in range(8)] + + # Generate QR code URL + totp = pyotp.TOTP(new_secret) + provisioning_uri = totp.provisioning_uri( + name=user.email, + issuer_name="SOMA" + ) + + # Print QR code as ASCII art (requires 'qrcode' library) + try: + qr = qrcode.QRCode(version=1, box_size=1, border=1) + qr.add_data(provisioning_uri) + qr.make(fit=True) + print("\nScan this QR code with Google Authenticator / Authy:") + print("=" * 60) + qr.print_ascii(invert=True) + print("=" * 60) + except Exception: + print(f"\nProvisioning URI (paste into authenticator app):") + print(provisioning_uri) + + print(f"\nManual entry secret: {new_secret}") + print("\nBACKUP CODES (save these offline — one-time use):") + print("-" * 40) + for i, code in enumerate(backup_codes, 1): + print(f" {i:2}. {code}") + print("-" * 40) + + # Verify the new TOTP code before saving + print("\nEnter a TOTP code from your authenticator app to verify setup:") + test_code = input("6-digit code: ").strip() + if not totp.verify(test_code, valid_window=1): + print("ERROR: TOTP code verification failed. TOTP NOT reset.") + sys.exit(1) + + # Save to database + user.totp_secret = new_secret + user.totp_backup_codes = backup_codes # stored as plain text; hash in production if desired + + # Invalidate all existing sessions + from app.models.user import AuthSession + from sqlalchemy import update + await db.execute( + update(AuthSession) + .where(AuthSession.user_id == user.id) + .values(is_active=False) + ) + + await db.commit() + + print("\nTOTP reset successful. All existing sessions have been invalidated.") + print("Log in at your SOMA URL to start a new session.") + + +if __name__ == '__main__': + asyncio.run(reset_totp()) \ No newline at end of file diff --git a/soma/scripts/backup_db.sh b/soma/scripts/backup_db.sh new file mode 100644 index 0000000..89e63b7 --- /dev/null +++ b/soma/scripts/backup_db.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# backup_db.sh +# Dumps SOMA PostgreSQL database, compresses it, uploads to S3 with timestamp. +# Run inside the backend container or on the EC2 host with Docker access. +# +# Required environment variables (from .env): +# POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB +# AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION +# S3_BACKUP_BUCKET (set this in .env — not in TID default, add it) +# +# Usage: bash /app/scripts/backup_db.sh + +set -euo pipefail + +TIMESTAMP=$(date +"%Y%m%d_%H%M%S") +BACKUP_DIR="/tmp/soma_backups" +FILENAME="somadb_${TIMESTAMP}.sql.gz" +FILEPATH="${BACKUP_DIR}/${FILENAME}" + +# Defaults if env not set +POSTGRES_USER="${POSTGRES_USER:-soma}" +POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-somapass}" +POSTGRES_DB="${POSTGRES_DB:-somadb}" +POSTGRES_HOST="${POSTGRES_HOST:-postgres}" +S3_BUCKET="${S3_BACKUP_BUCKET:-soma-backups}" + +mkdir -p "${BACKUP_DIR}" + +echo "[backup] Starting database dump at ${TIMESTAMP}" + +# Dump and compress +PGPASSWORD="${POSTGRES_PASSWORD}" pg_dump \ + -h "${POSTGRES_HOST}" \ + -U "${POSTGRES_USER}" \ + -d "${POSTGRES_DB}" \ + --no-password \ + --format=plain \ + --clean \ + --if-exists \ + | gzip > "${FILEPATH}" + +FILESIZE=$(du -sh "${FILEPATH}" | cut -f1) +echo "[backup] Dump complete: ${FILENAME} (${FILESIZE})" + +# Upload to S3 +if command -v aws &> /dev/null; then + S3_KEY="backups/${TIMESTAMP:0:6}/${FILENAME}" + aws s3 cp "${FILEPATH}" "s3://${S3_BUCKET}/${S3_KEY}" \ + --sse AES256 \ + --region "${AWS_REGION:-ap-south-1}" + echo "[backup] Uploaded to s3://${S3_BUCKET}/${S3_KEY}" +else + echo "[backup] WARNING: aws CLI not found. Backup saved locally at ${FILEPATH}" + echo "[backup] Install aws CLI or add boto3 call to upload manually." +fi + +# Keep only last 7 local backups +find "${BACKUP_DIR}" -name "somadb_*.sql.gz" -mtime +7 -delete + +echo "[backup] Done." \ No newline at end of file diff --git a/soma/scripts/seed_from_markdown.py b/soma/scripts/seed_from_markdown.py new file mode 100644 index 0000000..6672b51 --- /dev/null +++ b/soma/scripts/seed_from_markdown.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +seed_from_markdown.py + +Reads a Markdown file, extracts all unchecked task items (- [ ] lines), +parses optional metadata from the same line or the preceding heading, +and inserts them as tasks into the SOMA database. + +Usage: + python seed_from_markdown.py --file /path/to/life_plan.md [--dry-run] + +Format recognized: + # Project Name + ## Sub-section + - [ ] Task title @tag #priority due:2026-04-01 + - [x] Already done task (skipped) + +Metadata extraction: + @tag → maps to project_tag (e.g., @openclaw, @mak) + #priority → maps to priority (low|medium|high|critical) + due:YYYY-MM-DD → maps to due_date + Default tag: derived from nearest parent heading (slugified, max 50 chars) + Default priority: medium +""" + +import argparse +import re +import sys +import os +import asyncio +from datetime import date + +# Add parent directory to path so we can import app modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + + +def parse_markdown_tasks(filepath: str) -> list[dict]: + """Parse all unchecked [ ] tasks from a markdown file.""" + tasks = [] + current_tag = "general" + current_section = "" + + with open(filepath, 'r', encoding='utf-8') as f: + lines = f.readlines() + + for line in lines: + line = line.rstrip() + + # Track headings for default tag + heading_match = re.match(r'^#{1,3}\s+(.+)$', line) + if heading_match: + heading_text = heading_match.group(1).strip() + # Slugify heading: lowercase, replace spaces/special chars with - + slug = re.sub(r'[^a-z0-9]+', '-', heading_text.lower()).strip('-')[:50] + # Map common heading words to known tags + tag_map = { + 'openclaw': 'openclaw', 'mak': 'mak', 'rarity': 'rarity-media', + 'japan': 'japan', 'germany': 'germany', 'personal': 'personal', + 'habit': 'habits', 'retreat': 'retreat' + } + mapped = False + for keyword, tag in tag_map.items(): + if keyword in heading_text.lower(): + current_tag = tag + mapped = True + break + if not mapped: + current_tag = slug if slug else "general" + current_section = heading_text + continue + + # Match unchecked task: - [ ] or * [ ] + task_match = re.match(r'^\s*[-*]\s+\[ \]\s+(.+)$', line) + if not task_match: + continue + + raw_title = task_match.group(1).strip() + + # Extract metadata from title + tag = current_tag + priority = "medium" + due_date = None + + # @tag + tag_match = re.search(r'@(\S+)', raw_title) + if tag_match: + tag = tag_match.group(1) + raw_title = raw_title.replace(tag_match.group(0), '').strip() + + # #priority + priority_match = re.search(r'#(low|medium|high|critical)', raw_title, re.IGNORECASE) + if priority_match: + priority = priority_match.group(1).lower() + raw_title = raw_title.replace(priority_match.group(0), '').strip() + + # due:YYYY-MM-DD + due_match = re.search(r'due:(\d{4}-\d{2}-\d{2})', raw_title) + if due_match: + try: + due_date = due_match.group(1) + date.fromisoformat(due_date) # validate + except ValueError: + due_date = None + raw_title = raw_title.replace(due_match.group(0), '').strip() + + # Clean up extra whitespace + title = re.sub(r'\s+', ' ', raw_title).strip() + if not title: + continue + + # XP based on priority + xp_map = {'low': 25, 'medium': 50, 'high': 100, 'critical': 200} + + tasks.append({ + 'title': title, + 'description': f"Imported from: {current_section}" if current_section else None, + 'status': 'backlog', + 'priority': priority, + 'project_tag': tag, + 'due_date': due_date, + 'xp_reward': xp_map[priority], + 'position': 0, + 'checklist': [], + }) + + return tasks + + +async def insert_tasks(tasks: list[dict], dry_run: bool = False): + """Insert parsed tasks into the database.""" + from app.database import AsyncSessionLocal + from app.models.task import Task + import uuid + + if dry_run: + print(f"DRY RUN: Would insert {len(tasks)} tasks:") + for i, t in enumerate(tasks, 1): + print(f" {i:3}. [{t['priority'].upper():8}] [{t['project_tag']:20}] {t['title']}") + return + + async with AsyncSessionLocal() as db: + inserted = 0 + skipped = 0 + for i, task_data in enumerate(tasks): + # Set position based on insertion order within each status+tag + task_data['position'] = i + task = Task(id=uuid.uuid4(), **task_data) + db.add(task) + inserted += 1 + + await db.commit() + print(f"Inserted {inserted} tasks, skipped {skipped}.") + + +def main(): + parser = argparse.ArgumentParser(description='Seed SOMA tasks from a Markdown file') + parser.add_argument('--file', required=True, help='Path to the Markdown file') + parser.add_argument('--dry-run', action='store_true', help='Parse only, do not insert') + args = parser.parse_args() + + if not os.path.exists(args.file): + print(f"Error: File not found: {args.file}", file=sys.stderr) + sys.exit(1) + + print(f"Parsing: {args.file}") + tasks = parse_markdown_tasks(args.file) + print(f"Found {len(tasks)} unchecked tasks") + + if not tasks: + print("No tasks found. Check that the file contains '- [ ] ' lines.") + sys.exit(0) + + asyncio.run(insert_tasks(tasks, dry_run=args.dry_run)) + + +if __name__ == '__main__': + main() \ No newline at end of file