Spaces:
Running
Running
AuthorBot commited on
Commit Β·
b38f95c
1
Parent(s): cc73112
Update configurations and prepare for HF deployment
Browse files- Dockerfile +0 -79
- README.md +0 -10
- backend/alembic.ini +1 -37
- backend/alembic/env.py +0 -5
- backend/alembic/versions/001_initial_schema.py +7 -4
- backend/app/config.py +11 -9
- backend/app/core/access/token_crypto.py +3 -3
- backend/app/dependencies.py +7 -14
- backend/app/models/client_access.py +2 -2
- backend/app/models/user.py +3 -3
- backend/requirements.txt +0 -1
Dockerfile
DELETED
|
@@ -1,79 +0,0 @@
|
|
| 1 |
-
# ============================================================
|
| 2 |
-
# AuthorBot RAG API β Dockerfile (HuggingFace Spaces optimized)
|
| 3 |
-
# ============================================================
|
| 4 |
-
# Multi-stage build:
|
| 5 |
-
# Stage 1: Build dependencies (cached layer)
|
| 6 |
-
# Stage 2: Lean production image (~900MB)
|
| 7 |
-
#
|
| 8 |
-
# HuggingFace Spaces constraints:
|
| 9 |
-
# - Must run on port 7860 or configure app_port in README.md
|
| 10 |
-
# - No GPU by default (CPU-only BART + CrossEncoder)
|
| 11 |
-
# - Persistent storage at /data (HF Datasets mount)
|
| 12 |
-
# - Environment vars set via HF Space secrets
|
| 13 |
-
# ============================================================
|
| 14 |
-
|
| 15 |
-
FROM python:3.11-slim AS builder
|
| 16 |
-
|
| 17 |
-
# System dependencies for compiled packages
|
| 18 |
-
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 19 |
-
build-essential \
|
| 20 |
-
libpq-dev \
|
| 21 |
-
curl \
|
| 22 |
-
git \
|
| 23 |
-
&& rm -rf /var/lib/apt/lists/*
|
| 24 |
-
|
| 25 |
-
WORKDIR /app
|
| 26 |
-
|
| 27 |
-
# Copy and install Python dependencies (cached if requirements.txt unchanged)
|
| 28 |
-
COPY backend/requirements.txt .
|
| 29 |
-
RUN pip install --no-cache-dir --upgrade pip && \
|
| 30 |
-
pip install --no-cache-dir -r requirements.txt
|
| 31 |
-
|
| 32 |
-
# ββ Production Stage ββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
-
FROM python:3.11-slim
|
| 34 |
-
|
| 35 |
-
# Install runtime system deps + Redis server (runs inside container)
|
| 36 |
-
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 37 |
-
libpq5 \
|
| 38 |
-
curl \
|
| 39 |
-
redis-server \
|
| 40 |
-
libmagic1 \
|
| 41 |
-
&& rm -rf /var/lib/apt/lists/*
|
| 42 |
-
|
| 43 |
-
WORKDIR /app
|
| 44 |
-
|
| 45 |
-
# Copy installed packages from builder
|
| 46 |
-
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
| 47 |
-
COPY --from=builder /usr/local/bin /usr/local/bin
|
| 48 |
-
|
| 49 |
-
# Copy backend application code
|
| 50 |
-
COPY backend/ .
|
| 51 |
-
|
| 52 |
-
# Create necessary directories (including /data for SQLite persistent storage)
|
| 53 |
-
RUN mkdir -p /app/uploads /app/data/chroma /app/data/geo /app/logs /data
|
| 54 |
-
|
| 55 |
-
# HuggingFace Spaces: run as non-root user (uid 1000)
|
| 56 |
-
RUN adduser --disabled-password --gecos "" --uid 1000 appuser && \
|
| 57 |
-
chown -R appuser:appuser /app /data
|
| 58 |
-
|
| 59 |
-
# Configure Redis to not require root and use /data for persistence
|
| 60 |
-
RUN mkdir -p /data/redis && chown -R appuser:appuser /data/redis
|
| 61 |
-
RUN echo "dir /data/redis\nbind 127.0.0.1\nport 6379\ndaemonize yes\nlogfile /dev/null" > /app/redis.conf
|
| 62 |
-
|
| 63 |
-
USER appuser
|
| 64 |
-
|
| 65 |
-
# Pre-download models at build time (avoids cold-start delays)
|
| 66 |
-
# These are small enough to include in the image
|
| 67 |
-
RUN python -c "from sentence_transformers import CrossEncoder; CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')" || true
|
| 68 |
-
RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')" || true
|
| 69 |
-
|
| 70 |
-
# Health check
|
| 71 |
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
| 72 |
-
CMD curl -f http://localhost:8080/health || exit 1
|
| 73 |
-
|
| 74 |
-
# HuggingFace Spaces port
|
| 75 |
-
EXPOSE 8080
|
| 76 |
-
|
| 77 |
-
# Startup: create redis data dir β start Redis β run Alembic migrations β start Uvicorn
|
| 78 |
-
CMD ["sh", "-c", "mkdir -p /data/redis && redis-server /app/redis.conf && sleep 1 && alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8080 --workers 2 --loop uvloop --http httptools"]
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -1,13 +1,3 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: AuthorBot RAG API
|
| 3 |
-
emoji: β¨
|
| 4 |
-
colorFrom: indigo
|
| 5 |
-
colorTo: purple
|
| 6 |
-
sdk: docker
|
| 7 |
-
pinned: false
|
| 8 |
-
app_port: 8080
|
| 9 |
-
---
|
| 10 |
-
|
| 11 |
# AuthorBot RAG Chatbot SaaS
|
| 12 |
### Production-grade AI book advisor for author websites
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# AuthorBot RAG Chatbot SaaS
|
| 2 |
### Production-grade AI book advisor for author websites
|
| 3 |
|
backend/alembic.ini
CHANGED
|
@@ -1,41 +1,5 @@
|
|
| 1 |
-
[alembic]
|
| 2 |
version_locations = %(here)s/versions
|
| 3 |
script_location = alembic
|
| 4 |
# URL is overridden at runtime by alembic/env.py using get_settings().DATABASE_URL
|
| 5 |
# Do NOT hardcode credentials here β this file is committed to git
|
| 6 |
-
sqlalchemy.url =
|
| 7 |
-
|
| 8 |
-
# Logging configuration
|
| 9 |
-
[loggers]
|
| 10 |
-
keys = root,sqlalchemy,alembic
|
| 11 |
-
|
| 12 |
-
[handlers]
|
| 13 |
-
keys = console
|
| 14 |
-
|
| 15 |
-
[formatters]
|
| 16 |
-
keys = generic
|
| 17 |
-
|
| 18 |
-
[logger_root]
|
| 19 |
-
level = WARN
|
| 20 |
-
handlers = console
|
| 21 |
-
qualname =
|
| 22 |
-
|
| 23 |
-
[logger_sqlalchemy]
|
| 24 |
-
level = WARN
|
| 25 |
-
handlers =
|
| 26 |
-
qualname = sqlalchemy.engine
|
| 27 |
-
|
| 28 |
-
[logger_alembic]
|
| 29 |
-
level = INFO
|
| 30 |
-
handlers =
|
| 31 |
-
qualname = alembic
|
| 32 |
-
|
| 33 |
-
[handler_console]
|
| 34 |
-
class = StreamHandler
|
| 35 |
-
args = (sys.stderr,)
|
| 36 |
-
level = NOTSET
|
| 37 |
-
formatter = generic
|
| 38 |
-
|
| 39 |
-
[formatter_generic]
|
| 40 |
-
format = %(levelname)-5.5s [%(name)s] %(message)s
|
| 41 |
-
datefmt = %H:%M:%S
|
|
|
|
|
|
|
| 1 |
version_locations = %(here)s/versions
|
| 2 |
script_location = alembic
|
| 3 |
# URL is overridden at runtime by alembic/env.py using get_settings().DATABASE_URL
|
| 4 |
# Do NOT hardcode credentials here β this file is committed to git
|
| 5 |
+
sqlalchemy.url = postgresql+asyncpg://placeholder:placeholder@localhost/placeholder
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/alembic/env.py
CHANGED
|
@@ -1,13 +1,8 @@
|
|
| 1 |
"""Author RAG Chatbot SaaS β Alembic Migration Environment."""
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
-
import os
|
| 5 |
-
import sys
|
| 6 |
from logging.config import fileConfig
|
| 7 |
|
| 8 |
-
# Ensure the root directory is in sys.path so 'app' can be imported
|
| 9 |
-
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
| 10 |
-
|
| 11 |
from alembic import context
|
| 12 |
from sqlalchemy import pool
|
| 13 |
from sqlalchemy.ext.asyncio import async_engine_from_config
|
|
|
|
| 1 |
"""Author RAG Chatbot SaaS β Alembic Migration Environment."""
|
| 2 |
|
| 3 |
import asyncio
|
|
|
|
|
|
|
| 4 |
from logging.config import fileConfig
|
| 5 |
|
|
|
|
|
|
|
|
|
|
| 6 |
from alembic import context
|
| 7 |
from sqlalchemy import pool
|
| 8 |
from sqlalchemy.ext.asyncio import async_engine_from_config
|
backend/alembic/versions/001_initial_schema.py
CHANGED
|
@@ -30,7 +30,7 @@ def upgrade() -> None:
|
|
| 30 |
sa.Column("id", sa.String(36), primary_key=True),
|
| 31 |
sa.Column("email", sa.String(255), nullable=False, unique=True),
|
| 32 |
sa.Column("password_hash", sa.String(255), nullable=False),
|
| 33 |
-
sa.Column("role", sa.
|
| 34 |
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
|
| 35 |
sa.Column("failed_login_attempts", sa.Integer, nullable=False, server_default="0"),
|
| 36 |
sa.Column("locked_until", sa.String(50), nullable=True),
|
|
@@ -47,7 +47,7 @@ def upgrade() -> None:
|
|
| 47 |
server_default="Hi! I'm here to help you find your next great read."),
|
| 48 |
sa.Column("fallback_message", sa.String(500), nullable=False,
|
| 49 |
server_default="I want to give you the most accurate answer. Could you rephrase that?"),
|
| 50 |
-
sa.Column("response_style", sa.
|
| 51 |
nullable=False, server_default="balanced"),
|
| 52 |
sa.Column("chatbot_is_active", sa.Boolean, nullable=False, server_default="true"),
|
| 53 |
sa.Column("widget_theme", sa.String(50), nullable=False, server_default="indigo"),
|
|
@@ -138,7 +138,7 @@ def upgrade() -> None:
|
|
| 138 |
sa.Column("id", sa.String(36), primary_key=True),
|
| 139 |
sa.Column("author_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
| 140 |
sa.Column("granted_by", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
|
| 141 |
-
sa.Column("plan", sa.
|
| 142 |
sa.Column("granted_at", sa.DateTime(timezone=True), nullable=False),
|
| 143 |
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
| 144 |
sa.Column("token_hash", sa.String(255), nullable=False, unique=True),
|
|
@@ -273,4 +273,7 @@ def downgrade() -> None:
|
|
| 273 |
op.drop_table("documents")
|
| 274 |
op.drop_table("books")
|
| 275 |
op.drop_table("users")
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
sa.Column("id", sa.String(36), primary_key=True),
|
| 31 |
sa.Column("email", sa.String(255), nullable=False, unique=True),
|
| 32 |
sa.Column("password_hash", sa.String(255), nullable=False),
|
| 33 |
+
sa.Column("role", sa.Enum("author", "superadmin", name="user_role"), nullable=False, server_default="author"),
|
| 34 |
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
|
| 35 |
sa.Column("failed_login_attempts", sa.Integer, nullable=False, server_default="0"),
|
| 36 |
sa.Column("locked_until", sa.String(50), nullable=True),
|
|
|
|
| 47 |
server_default="Hi! I'm here to help you find your next great read."),
|
| 48 |
sa.Column("fallback_message", sa.String(500), nullable=False,
|
| 49 |
server_default="I want to give you the most accurate answer. Could you rephrase that?"),
|
| 50 |
+
sa.Column("response_style", sa.Enum("concise", "balanced", "detailed", name="response_style"),
|
| 51 |
nullable=False, server_default="balanced"),
|
| 52 |
sa.Column("chatbot_is_active", sa.Boolean, nullable=False, server_default="true"),
|
| 53 |
sa.Column("widget_theme", sa.String(50), nullable=False, server_default="indigo"),
|
|
|
|
| 138 |
sa.Column("id", sa.String(36), primary_key=True),
|
| 139 |
sa.Column("author_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
| 140 |
sa.Column("granted_by", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
|
| 141 |
+
sa.Column("plan", sa.Enum("monthly", "quarterly", "semi_annual", "annual", name="subscription_plan"), nullable=False),
|
| 142 |
sa.Column("granted_at", sa.DateTime(timezone=True), nullable=False),
|
| 143 |
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
| 144 |
sa.Column("token_hash", sa.String(255), nullable=False, unique=True),
|
|
|
|
| 273 |
op.drop_table("documents")
|
| 274 |
op.drop_table("books")
|
| 275 |
op.drop_table("users")
|
| 276 |
+
# Drop enums manually (Postgres keeps them after table drops)
|
| 277 |
+
op.execute("DROP TYPE IF EXISTS user_role")
|
| 278 |
+
op.execute("DROP TYPE IF EXISTS response_style")
|
| 279 |
+
op.execute("DROP TYPE IF EXISTS subscription_plan")
|
backend/app/config.py
CHANGED
|
@@ -8,7 +8,7 @@ Import `get_settings()` wherever configuration is needed.
|
|
| 8 |
from functools import lru_cache
|
| 9 |
from typing import Literal
|
| 10 |
|
| 11 |
-
from pydantic import EmailStr, Field, field_validator
|
| 12 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 13 |
|
| 14 |
|
|
@@ -26,19 +26,21 @@ class Settings(BaseSettings):
|
|
| 26 |
APP_NAME: str = "AuthorBot"
|
| 27 |
APP_ENV: Literal["development", "staging", "production"] = "development"
|
| 28 |
DEBUG: bool = False
|
| 29 |
-
SECRET_KEY: str = Field(
|
| 30 |
-
|
|
|
|
|
|
|
| 31 |
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
|
| 32 |
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
| 33 |
MAX_CONCURRENT_SESSIONS: int = 3
|
| 34 |
|
| 35 |
# βββ Database βββββββββββββββββββββββββββββββββββββββββ
|
| 36 |
-
DATABASE_URL:
|
| 37 |
DB_POOL_SIZE: int = 10
|
| 38 |
DB_MAX_OVERFLOW: int = 20
|
| 39 |
|
| 40 |
# βββ Redis ββββββββββββββββββββββββββββββββββββββββββββ
|
| 41 |
-
REDIS_URL:
|
| 42 |
REDIS_DECODE_RESPONSES: bool = True
|
| 43 |
|
| 44 |
# βββ ChromaDB βββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -47,7 +49,7 @@ class Settings(BaseSettings):
|
|
| 47 |
CHROMA_PERSIST_DIR: str = "./chroma_data"
|
| 48 |
|
| 49 |
# βββ OpenAI βββββββββββββββββββββββββββββββββββββββββββ
|
| 50 |
-
OPENAI_API_KEY: str = Field(
|
| 51 |
OPENAI_CHAT_MODEL: str = "gpt-4o" # Never change without approval
|
| 52 |
OPENAI_EMBEDDING_MODEL: str = "text-embedding-3-small"
|
| 53 |
OPENAI_MAX_RETRIES: int = 3
|
|
@@ -81,10 +83,10 @@ class Settings(BaseSettings):
|
|
| 81 |
SMTP_HOST: str = "smtp.gmail.com"
|
| 82 |
SMTP_PORT: int = 465
|
| 83 |
SMTP_USE_SSL: bool = True
|
| 84 |
-
SMTP_USERNAME: EmailStr
|
| 85 |
-
SMTP_PASSWORD: str
|
| 86 |
EMAIL_FROM_NAME: str = "AuthorBot"
|
| 87 |
-
EMAIL_FROM_ADDRESS: EmailStr
|
| 88 |
|
| 89 |
# βββ Rate Limiting ββββββββββββββββββββββββββββββββββββ
|
| 90 |
RATE_LIMIT_CHAT_PER_MINUTE: int = 60
|
|
|
|
| 8 |
from functools import lru_cache
|
| 9 |
from typing import Literal
|
| 10 |
|
| 11 |
+
from pydantic import EmailStr, Field, PostgresDsn, RedisDsn, field_validator
|
| 12 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 13 |
|
| 14 |
|
|
|
|
| 26 |
APP_NAME: str = "AuthorBot"
|
| 27 |
APP_ENV: Literal["development", "staging", "production"] = "development"
|
| 28 |
DEBUG: bool = False
|
| 29 |
+
SECRET_KEY: str = Field(..., min_length=32) # HMAC signing key
|
| 30 |
+
JWT_PRIVATE_KEY: str = Field(...) # RS256 private key (PEM)
|
| 31 |
+
JWT_PUBLIC_KEY: str = Field(...) # RS256 public key (PEM)
|
| 32 |
+
JWT_ALGORITHM: str = "RS256"
|
| 33 |
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
|
| 34 |
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
| 35 |
MAX_CONCURRENT_SESSIONS: int = 3
|
| 36 |
|
| 37 |
# βββ Database βββββββββββββββββββββββββββββββββββββββββ
|
| 38 |
+
DATABASE_URL: PostgresDsn = Field(...)
|
| 39 |
DB_POOL_SIZE: int = 10
|
| 40 |
DB_MAX_OVERFLOW: int = 20
|
| 41 |
|
| 42 |
# βββ Redis ββββββββββββββββββββββββββββββββββββββββββββ
|
| 43 |
+
REDIS_URL: RedisDsn = Field(...)
|
| 44 |
REDIS_DECODE_RESPONSES: bool = True
|
| 45 |
|
| 46 |
# βββ ChromaDB βββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 49 |
CHROMA_PERSIST_DIR: str = "./chroma_data"
|
| 50 |
|
| 51 |
# βββ OpenAI βββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
OPENAI_API_KEY: str = Field(...)
|
| 53 |
OPENAI_CHAT_MODEL: str = "gpt-4o" # Never change without approval
|
| 54 |
OPENAI_EMBEDDING_MODEL: str = "text-embedding-3-small"
|
| 55 |
OPENAI_MAX_RETRIES: int = 3
|
|
|
|
| 83 |
SMTP_HOST: str = "smtp.gmail.com"
|
| 84 |
SMTP_PORT: int = 465
|
| 85 |
SMTP_USE_SSL: bool = True
|
| 86 |
+
SMTP_USERNAME: EmailStr = Field(...)
|
| 87 |
+
SMTP_PASSWORD: str = Field(...) # Gmail App Password (not account pw)
|
| 88 |
EMAIL_FROM_NAME: str = "AuthorBot"
|
| 89 |
+
EMAIL_FROM_ADDRESS: EmailStr = Field(...)
|
| 90 |
|
| 91 |
# βββ Rate Limiting ββββββββββββββββββββββββββββββββββββ
|
| 92 |
RATE_LIMIT_CHAT_PER_MINUTE: int = 60
|
backend/app/core/access/token_crypto.py
CHANGED
|
@@ -44,7 +44,7 @@ def create_access_token(subject: str, extra_claims: dict[str, Any] | None = None
|
|
| 44 |
"type": "access",
|
| 45 |
**(extra_claims or {}),
|
| 46 |
}
|
| 47 |
-
return jwt.encode(payload, cfg.
|
| 48 |
|
| 49 |
|
| 50 |
def create_refresh_token(subject: str) -> str:
|
|
@@ -64,7 +64,7 @@ def create_refresh_token(subject: str) -> str:
|
|
| 64 |
"exp": expire,
|
| 65 |
"type": "refresh",
|
| 66 |
}
|
| 67 |
-
return jwt.encode(payload, cfg.
|
| 68 |
|
| 69 |
|
| 70 |
def decode_jwt(token: str) -> dict[str, Any]:
|
|
@@ -81,7 +81,7 @@ def decode_jwt(token: str) -> dict[str, Any]:
|
|
| 81 |
InvalidTokenError: If the token is malformed or signature is invalid.
|
| 82 |
"""
|
| 83 |
try:
|
| 84 |
-
payload = jwt.decode(token, cfg.
|
| 85 |
return payload
|
| 86 |
except JWTError as e:
|
| 87 |
error_msg = str(e).lower()
|
|
|
|
| 44 |
"type": "access",
|
| 45 |
**(extra_claims or {}),
|
| 46 |
}
|
| 47 |
+
return jwt.encode(payload, cfg.JWT_PRIVATE_KEY, algorithm=cfg.JWT_ALGORITHM)
|
| 48 |
|
| 49 |
|
| 50 |
def create_refresh_token(subject: str) -> str:
|
|
|
|
| 64 |
"exp": expire,
|
| 65 |
"type": "refresh",
|
| 66 |
}
|
| 67 |
+
return jwt.encode(payload, cfg.JWT_PRIVATE_KEY, algorithm=cfg.JWT_ALGORITHM)
|
| 68 |
|
| 69 |
|
| 70 |
def decode_jwt(token: str) -> dict[str, Any]:
|
|
|
|
| 81 |
InvalidTokenError: If the token is malformed or signature is invalid.
|
| 82 |
"""
|
| 83 |
try:
|
| 84 |
+
payload = jwt.decode(token, cfg.JWT_PUBLIC_KEY, algorithms=[cfg.JWT_ALGORITHM])
|
| 85 |
return payload
|
| 86 |
except JWTError as e:
|
| 87 |
error_msg = str(e).lower()
|
backend/app/dependencies.py
CHANGED
|
@@ -20,20 +20,13 @@ logger = structlog.get_logger(__name__)
|
|
| 20 |
|
| 21 |
def _create_engine(cfg: Settings):
|
| 22 |
"""Create SQLAlchemy async engine from settings."""
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
if not is_sqlite:
|
| 32 |
-
# Connection pool settings only apply to server-based databases
|
| 33 |
-
engine_kwargs["pool_size"] = cfg.DB_POOL_SIZE
|
| 34 |
-
engine_kwargs["max_overflow"] = cfg.DB_MAX_OVERFLOW
|
| 35 |
-
|
| 36 |
-
return create_async_engine(db_url, **engine_kwargs)
|
| 37 |
|
| 38 |
|
| 39 |
def _create_session_factory(cfg: Settings) -> async_sessionmaker:
|
|
|
|
| 20 |
|
| 21 |
def _create_engine(cfg: Settings):
|
| 22 |
"""Create SQLAlchemy async engine from settings."""
|
| 23 |
+
return create_async_engine(
|
| 24 |
+
str(cfg.DATABASE_URL),
|
| 25 |
+
pool_size=cfg.DB_POOL_SIZE,
|
| 26 |
+
max_overflow=cfg.DB_MAX_OVERFLOW,
|
| 27 |
+
echo=cfg.DEBUG,
|
| 28 |
+
pool_pre_ping=True,
|
| 29 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
def _create_session_factory(cfg: Settings) -> async_sessionmaker:
|
backend/app/models/client_access.py
CHANGED
|
@@ -7,7 +7,7 @@ Revocation is instant via Redis blacklist + this record.
|
|
| 7 |
|
| 8 |
from datetime import datetime
|
| 9 |
|
| 10 |
-
from sqlalchemy import Boolean, DateTime, Integer, String, Text
|
| 11 |
from sqlalchemy import ForeignKey
|
| 12 |
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 13 |
|
|
@@ -27,7 +27,7 @@ class ClientAccess(Base, TimestampMixin):
|
|
| 27 |
String(36), ForeignKey("users.id"), nullable=False
|
| 28 |
)
|
| 29 |
plan: Mapped[str] = mapped_column(
|
| 30 |
-
|
| 31 |
nullable=False,
|
| 32 |
)
|
| 33 |
granted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
|
|
| 7 |
|
| 8 |
from datetime import datetime
|
| 9 |
|
| 10 |
+
from sqlalchemy import Boolean, DateTime, Enum, Integer, String, Text
|
| 11 |
from sqlalchemy import ForeignKey
|
| 12 |
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 13 |
|
|
|
|
| 27 |
String(36), ForeignKey("users.id"), nullable=False
|
| 28 |
)
|
| 29 |
plan: Mapped[str] = mapped_column(
|
| 30 |
+
Enum("monthly", "quarterly", "semi_annual", "annual", name="subscription_plan"),
|
| 31 |
nullable=False,
|
| 32 |
)
|
| 33 |
granted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
backend/app/models/user.py
CHANGED
|
@@ -4,7 +4,7 @@ Represents both Author (client) and SuperAdmin accounts.
|
|
| 4 |
Role is enforced at the middleware and service layers.
|
| 5 |
"""
|
| 6 |
|
| 7 |
-
from sqlalchemy import Boolean, Integer, String
|
| 8 |
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 9 |
|
| 10 |
from app.models.base import Base, TimestampMixin, generate_uuid
|
|
@@ -19,7 +19,7 @@ class User(Base, TimestampMixin):
|
|
| 19 |
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
| 20 |
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 21 |
role: Mapped[str] = mapped_column(
|
| 22 |
-
|
| 23 |
nullable=False,
|
| 24 |
default="author",
|
| 25 |
)
|
|
@@ -48,7 +48,7 @@ class User(Base, TimestampMixin):
|
|
| 48 |
nullable=False,
|
| 49 |
)
|
| 50 |
response_style: Mapped[str] = mapped_column(
|
| 51 |
-
|
| 52 |
default="balanced",
|
| 53 |
nullable=False,
|
| 54 |
)
|
|
|
|
| 4 |
Role is enforced at the middleware and service layers.
|
| 5 |
"""
|
| 6 |
|
| 7 |
+
from sqlalchemy import Boolean, Enum, Integer, String
|
| 8 |
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 9 |
|
| 10 |
from app.models.base import Base, TimestampMixin, generate_uuid
|
|
|
|
| 19 |
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
| 20 |
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 21 |
role: Mapped[str] = mapped_column(
|
| 22 |
+
Enum("author", "superadmin", name="user_role"),
|
| 23 |
nullable=False,
|
| 24 |
default="author",
|
| 25 |
)
|
|
|
|
| 48 |
nullable=False,
|
| 49 |
)
|
| 50 |
response_style: Mapped[str] = mapped_column(
|
| 51 |
+
Enum("concise", "balanced", "detailed", name="response_style"),
|
| 52 |
default="balanced",
|
| 53 |
nullable=False,
|
| 54 |
)
|
backend/requirements.txt
CHANGED
|
@@ -13,7 +13,6 @@ pydantic-settings==2.2.1
|
|
| 13 |
# ββ Database ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 14 |
sqlalchemy[asyncio]==2.0.30
|
| 15 |
asyncpg==0.29.0
|
| 16 |
-
aiosqlite==0.20.0
|
| 17 |
alembic==1.13.1
|
| 18 |
greenlet==3.0.3
|
| 19 |
|
|
|
|
| 13 |
# ββ Database ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 14 |
sqlalchemy[asyncio]==2.0.30
|
| 15 |
asyncpg==0.29.0
|
|
|
|
| 16 |
alembic==1.13.1
|
| 17 |
greenlet==3.0.3
|
| 18 |
|