Spaces:
Running
Running
Upload 14 files
Browse files- backend/__init__.py +0 -0
- backend/app.py +36 -0
- backend/core/config.py +14 -0
- backend/core/security.py +40 -0
- backend/database.py +16 -0
- backend/migrations/alembic.ini +3 -0
- backend/migrations/env.py +23 -0
- backend/migrations/versions/0001_init.py +37 -0
- backend/models.py +34 -0
- backend/routes/about.py +17 -0
- backend/routes/apikey.py +32 -0
- backend/routes/auth.py +27 -0
- backend/routes/chat.py +28 -0
- backend/schemas.py +45 -0
backend/__init__.py
ADDED
File without changes
|
backend/app.py
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import FastAPI
|
2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
3 |
+
from .core.config import settings
|
4 |
+
from .database import Base, engine
|
5 |
+
from .routes import auth, apikey, chat, about
|
6 |
+
|
7 |
+
def run_migrations():
|
8 |
+
try:
|
9 |
+
from alembic import command
|
10 |
+
from alembic.config import Config
|
11 |
+
import os
|
12 |
+
cfg = Config(os.path.join(os.path.dirname(__file__), "migrations", "alembic.ini"))
|
13 |
+
cfg.set_main_option("script_location", os.path.join(os.path.dirname(__file__), "migrations"))
|
14 |
+
cfg.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
15 |
+
command.upgrade(cfg, "head")
|
16 |
+
except Exception as e:
|
17 |
+
print("Alembic migration error:", e)
|
18 |
+
|
19 |
+
def create_app() -> FastAPI:
|
20 |
+
app = FastAPI(title=settings.APP_NAME)
|
21 |
+
|
22 |
+
app.add_middleware(
|
23 |
+
CORSMiddleware,
|
24 |
+
allow_origins=[settings.CORS_ORIGINS] if settings.CORS_ORIGINS != "*" else ["*"],
|
25 |
+
allow_credentials=True,
|
26 |
+
allow_methods=["*"],
|
27 |
+
allow_headers=["*"],
|
28 |
+
)
|
29 |
+
|
30 |
+
Base.metadata.bind = engine
|
31 |
+
|
32 |
+
app.include_router(auth.router)
|
33 |
+
app.include_router(apikey.router)
|
34 |
+
app.include_router(chat.router)
|
35 |
+
app.include_router(about.router)
|
36 |
+
return app
|
backend/core/config.py
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
from pydantic import BaseModel
|
3 |
+
from dotenv import load_dotenv
|
4 |
+
|
5 |
+
load_dotenv()
|
6 |
+
|
7 |
+
class Settings(BaseModel):
|
8 |
+
APP_NAME: str = os.getenv("APP_NAME", "CHB App")
|
9 |
+
DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///./app.db")
|
10 |
+
JWT_SECRET: str = os.getenv("JWT_SECRET", "dev-secret")
|
11 |
+
JWT_EXPIRES_MINUTES: int = int(os.getenv("JWT_EXPIRES_MINUTES", "60"))
|
12 |
+
CORS_ORIGINS: str = os.getenv("CORS_ORIGINS", "*")
|
13 |
+
|
14 |
+
settings = Settings()
|
backend/core/security.py
ADDED
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from datetime import datetime, timedelta
|
2 |
+
from typing import Optional
|
3 |
+
from jose import jwt, JWTError
|
4 |
+
from passlib.context import CryptContext
|
5 |
+
from fastapi import Depends, HTTPException, status
|
6 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
7 |
+
from sqlalchemy.orm import Session
|
8 |
+
from .config import settings
|
9 |
+
from ..database import get_db
|
10 |
+
from ..models import User
|
11 |
+
|
12 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
13 |
+
auth_scheme = HTTPBearer()
|
14 |
+
|
15 |
+
def hash_password(p: str) -> str:
|
16 |
+
return pwd_context.hash(p)
|
17 |
+
|
18 |
+
def verify_password(p: str, hashed: str) -> bool:
|
19 |
+
return pwd_context.verify(p, hashed)
|
20 |
+
|
21 |
+
def create_access_token(sub: str, expires_minutes: int = None) -> str:
|
22 |
+
expire = datetime.utcnow() + timedelta(minutes=expires_minutes or settings.JWT_EXPIRES_MINUTES)
|
23 |
+
to_encode = {"sub": sub, "exp": expire}
|
24 |
+
return jwt.encode(to_encode, settings.JWT_SECRET, algorithm="HS256")
|
25 |
+
|
26 |
+
def decode_token(token: str) -> Optional[str]:
|
27 |
+
try:
|
28 |
+
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
|
29 |
+
return payload.get("sub")
|
30 |
+
except JWTError:
|
31 |
+
return None
|
32 |
+
|
33 |
+
def get_current_user(creds: HTTPAuthorizationCredentials = Depends(auth_scheme), db: Session = Depends(get_db)) -> User:
|
34 |
+
email = decode_token(creds.credentials)
|
35 |
+
if not email:
|
36 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
37 |
+
user = db.query(User).filter(User.email == email).first()
|
38 |
+
if not user:
|
39 |
+
raise HTTPException(status_code=401, detail="User not found")
|
40 |
+
return user
|
backend/database.py
ADDED
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from sqlalchemy import create_engine
|
2 |
+
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
3 |
+
from .core.config import settings
|
4 |
+
|
5 |
+
class Base(DeclarativeBase):
|
6 |
+
pass
|
7 |
+
|
8 |
+
engine = create_engine(settings.DATABASE_URL, connect_args={"check_same_thread": False} if settings.DATABASE_URL.startswith("sqlite") else {})
|
9 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
10 |
+
|
11 |
+
def get_db():
|
12 |
+
db = SessionLocal()
|
13 |
+
try:
|
14 |
+
yield db
|
15 |
+
finally:
|
16 |
+
db.close()
|
backend/migrations/alembic.ini
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
[alembic]
|
2 |
+
script_location = migrations
|
3 |
+
sqlalchemy.url = sqlite:///./app.db
|
backend/migrations/env.py
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from logging.config import fileConfig
|
2 |
+
from sqlalchemy import engine_from_config, pool
|
3 |
+
from alembic import context
|
4 |
+
import os, sys
|
5 |
+
config = context.config
|
6 |
+
if config.config_file_name is not None:
|
7 |
+
fileConfig(config.config_file_name)
|
8 |
+
target_metadata = None
|
9 |
+
def run_migrations_offline():
|
10 |
+
url = config.get_main_option("sqlalchemy.url")
|
11 |
+
context.configure(url=url, literal_binds=True)
|
12 |
+
with context.begin_transaction():
|
13 |
+
context.run_migrations()
|
14 |
+
def run_migrations_online():
|
15 |
+
connectable = engine_from_config(config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool)
|
16 |
+
with connectable.connect() as connection:
|
17 |
+
context.configure(connection=connection)
|
18 |
+
with context.begin_transaction():
|
19 |
+
context.run_migrations()
|
20 |
+
if context.is_offline_mode():
|
21 |
+
run_migrations_offline()
|
22 |
+
else:
|
23 |
+
run_migrations_online()
|
backend/migrations/versions/0001_init.py
ADDED
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from alembic import op
|
2 |
+
import sqlalchemy as sa
|
3 |
+
|
4 |
+
revision = '0001_init'
|
5 |
+
down_revision = None
|
6 |
+
branch_labels = None
|
7 |
+
depends_on = None
|
8 |
+
|
9 |
+
def upgrade():
|
10 |
+
op.create_table(
|
11 |
+
'users',
|
12 |
+
sa.Column('id', sa.Integer(), primary_key=True),
|
13 |
+
sa.Column('email', sa.String(255), nullable=False, unique=True),
|
14 |
+
sa.Column('password_hash', sa.String(255), nullable=False),
|
15 |
+
sa.Column('created_at', sa.DateTime(), nullable=True),
|
16 |
+
)
|
17 |
+
op.create_table(
|
18 |
+
'api_keys',
|
19 |
+
sa.Column('id', sa.Integer(), primary_key=True),
|
20 |
+
sa.Column('key', sa.String(255), nullable=False, unique=True),
|
21 |
+
sa.Column('revoked', sa.Boolean(), default=False),
|
22 |
+
sa.Column('created_at', sa.DateTime(), nullable=True),
|
23 |
+
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'))
|
24 |
+
)
|
25 |
+
op.create_table(
|
26 |
+
'chat_history',
|
27 |
+
sa.Column('id', sa.Integer(), primary_key=True),
|
28 |
+
sa.Column('role', sa.String(20)),
|
29 |
+
sa.Column('content', sa.Text(), nullable=False),
|
30 |
+
sa.Column('created_at', sa.DateTime(), nullable=True),
|
31 |
+
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'))
|
32 |
+
)
|
33 |
+
|
34 |
+
def downgrade():
|
35 |
+
op.drop_table('chat_history')
|
36 |
+
op.drop_table('api_keys')
|
37 |
+
op.drop_table('users')
|
backend/models.py
ADDED
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text
|
2 |
+
from sqlalchemy.orm import relationship, Mapped, mapped_column
|
3 |
+
from datetime import datetime
|
4 |
+
from .database import Base
|
5 |
+
|
6 |
+
class User(Base):
|
7 |
+
__tablename__ = "users"
|
8 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
9 |
+
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
10 |
+
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
11 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
12 |
+
|
13 |
+
api_keys = relationship("APIKey", back_populates="owner", cascade="all, delete-orphan")
|
14 |
+
messages = relationship("ChatMessage", back_populates="user", cascade="all, delete-orphan")
|
15 |
+
|
16 |
+
class APIKey(Base):
|
17 |
+
__tablename__ = "api_keys"
|
18 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
19 |
+
key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
20 |
+
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
|
21 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
22 |
+
|
23 |
+
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
|
24 |
+
owner = relationship("User", back_populates="api_keys")
|
25 |
+
|
26 |
+
class ChatMessage(Base):
|
27 |
+
__tablename__ = "chat_history"
|
28 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
29 |
+
role: Mapped[str] = mapped_column(String(20), default="user") # 'user' or 'assistant'
|
30 |
+
content: Mapped[str] = mapped_column(Text, nullable=False)
|
31 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
32 |
+
|
33 |
+
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
|
34 |
+
user = relationship("User", back_populates="messages")
|
backend/routes/about.py
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter
|
2 |
+
|
3 |
+
router = APIRouter(prefix="/api/info", tags=["Info"])
|
4 |
+
|
5 |
+
@router.get("/about")
|
6 |
+
def about():
|
7 |
+
return {"name": "CHB", "tagline": "Multimodal Assistant", "version": "1.0.0"}
|
8 |
+
|
9 |
+
@router.get("/howto")
|
10 |
+
def howto():
|
11 |
+
return {
|
12 |
+
"steps": [
|
13 |
+
"Sign up or log in",
|
14 |
+
"Use Chat to send messages, long-press the paperclip to switch modes",
|
15 |
+
"Go to API Keys to generate or revoke keys"
|
16 |
+
]
|
17 |
+
}
|
backend/routes/apikey.py
ADDED
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import secrets
|
2 |
+
from fastapi import APIRouter, Depends, HTTPException
|
3 |
+
from sqlalchemy.orm import Session
|
4 |
+
from ..database import get_db
|
5 |
+
from ..models import APIKey
|
6 |
+
from ..schemas import APIKeyOut, KeyRevokeIn
|
7 |
+
from ..core.security import get_current_user
|
8 |
+
|
9 |
+
router = APIRouter(prefix="/api/keys", tags=["API Keys"])
|
10 |
+
|
11 |
+
@router.get("", response_model=list[APIKeyOut])
|
12 |
+
def list_keys(db: Session = Depends(get_db), user = Depends(get_current_user)):
|
13 |
+
rows = db.query(APIKey).filter(APIKey.user_id == user.id).order_by(APIKey.created_at.desc()).all()
|
14 |
+
return rows
|
15 |
+
|
16 |
+
@router.post("/generate", response_model=APIKeyOut)
|
17 |
+
def generate_key(db: Session = Depends(get_db), user = Depends(get_current_user)):
|
18 |
+
key = "chb_" + secrets.token_urlsafe(32)
|
19 |
+
row = APIKey(key=key, user_id=user.id)
|
20 |
+
db.add(row)
|
21 |
+
db.commit()
|
22 |
+
db.refresh(row)
|
23 |
+
return row
|
24 |
+
|
25 |
+
@router.post("/revoke")
|
26 |
+
def revoke_key(payload: KeyRevokeIn, db: Session = Depends(get_db), user = Depends(get_current_user)):
|
27 |
+
row = db.query(APIKey).filter(APIKey.user_id == user.id, APIKey.key == payload.key).first()
|
28 |
+
if not row:
|
29 |
+
raise HTTPException(status_code=404, detail="Key not found")
|
30 |
+
row.revoked = True
|
31 |
+
db.commit()
|
32 |
+
return {"revoked": payload.key}
|
backend/routes/auth.py
ADDED
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, HTTPException, Depends
|
2 |
+
from sqlalchemy.orm import Session
|
3 |
+
from ..schemas import UserCreate, UserOut, LoginIn, Token
|
4 |
+
from ..models import User
|
5 |
+
from ..database import get_db
|
6 |
+
from ..core.security import hash_password, verify_password, create_access_token
|
7 |
+
|
8 |
+
router = APIRouter(prefix="/api/auth", tags=["Auth"])
|
9 |
+
|
10 |
+
@router.post("/signup", response_model=Token)
|
11 |
+
def signup(payload: UserCreate, db: Session = Depends(get_db)):
|
12 |
+
if db.query(User).filter(User.email == payload.email).first():
|
13 |
+
raise HTTPException(status_code=400, detail="User already exists")
|
14 |
+
user = User(email=payload.email, password_hash=hash_password(payload.password))
|
15 |
+
db.add(user)
|
16 |
+
db.commit()
|
17 |
+
db.refresh(user)
|
18 |
+
token = create_access_token(sub=user.email)
|
19 |
+
return {"access_token": token}
|
20 |
+
|
21 |
+
@router.post("/login", response_model=Token)
|
22 |
+
def login(payload: LoginIn, db: Session = Depends(get_db)):
|
23 |
+
user = db.query(User).filter(User.email == payload.email).first()
|
24 |
+
if not user or not verify_password(payload.password, user.password_hash):
|
25 |
+
raise HTTPException(status_code=401, detail="Invalid credentials")
|
26 |
+
token = create_access_token(sub=user.email)
|
27 |
+
return {"access_token": token}
|
backend/routes/chat.py
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Depends, UploadFile, File
|
2 |
+
from sqlalchemy.orm import Session
|
3 |
+
from ..database import get_db
|
4 |
+
from ..schemas import ChatIn, ChatOut, ChatMessageOut
|
5 |
+
from ..models import ChatMessage
|
6 |
+
from ..core.security import get_current_user
|
7 |
+
|
8 |
+
router = APIRouter(prefix="/api/chat", tags=["Chat"])
|
9 |
+
|
10 |
+
@router.get("/history", response_model=list[ChatMessageOut])
|
11 |
+
def history(db: Session = Depends(get_db), user = Depends(get_current_user)):
|
12 |
+
rows = db.query(ChatMessage).filter(ChatMessage.user_id == user.id).order_by(ChatMessage.created_at.asc()).all()
|
13 |
+
return rows
|
14 |
+
|
15 |
+
@router.post("", response_model=ChatOut)
|
16 |
+
def send(payload: ChatIn, db: Session = Depends(get_db), user = Depends(get_current_user)):
|
17 |
+
um = ChatMessage(user_id=user.id, role="user", content=payload.message)
|
18 |
+
db.add(um)
|
19 |
+
reply_text = f"CHB: You said '{payload.message}'"
|
20 |
+
bm = ChatMessage(user_id=user.id, role="assistant", content=reply_text)
|
21 |
+
db.add(bm)
|
22 |
+
db.commit()
|
23 |
+
return {"reply": reply_text}
|
24 |
+
|
25 |
+
@router.post("/upload")
|
26 |
+
async def upload(file: UploadFile = File(...), user = Depends(get_current_user)):
|
27 |
+
# stub: accept file and return name; real storage to be implemented
|
28 |
+
return {"filename": file.filename, "detail":"file received"}
|
backend/schemas.py
ADDED
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel, EmailStr, Field
|
2 |
+
from typing import List, Optional
|
3 |
+
from datetime import datetime
|
4 |
+
|
5 |
+
class UserCreate(BaseModel):
|
6 |
+
email: EmailStr
|
7 |
+
password: str = Field(min_length=6)
|
8 |
+
|
9 |
+
class UserOut(BaseModel):
|
10 |
+
id: int
|
11 |
+
email: EmailStr
|
12 |
+
created_at: datetime
|
13 |
+
class Config:
|
14 |
+
from_attributes = True
|
15 |
+
|
16 |
+
class Token(BaseModel):
|
17 |
+
access_token: str
|
18 |
+
token_type: str = "bearer"
|
19 |
+
|
20 |
+
class LoginIn(BaseModel):
|
21 |
+
email: EmailStr
|
22 |
+
password: str
|
23 |
+
|
24 |
+
class APIKeyOut(BaseModel):
|
25 |
+
key: str
|
26 |
+
revoked: bool
|
27 |
+
created_at: datetime
|
28 |
+
class Config:
|
29 |
+
from_attributes = True
|
30 |
+
|
31 |
+
class KeyRevokeIn(BaseModel):
|
32 |
+
key: str
|
33 |
+
|
34 |
+
class ChatIn(BaseModel):
|
35 |
+
message: str
|
36 |
+
|
37 |
+
class ChatOut(BaseModel):
|
38 |
+
reply: str
|
39 |
+
|
40 |
+
class ChatMessageOut(BaseModel):
|
41 |
+
role: str
|
42 |
+
content: str
|
43 |
+
created_at: datetime
|
44 |
+
class Config:
|
45 |
+
from_attributes = True
|