Princeaka commited on
Commit
220d87f
·
verified ·
1 Parent(s): 0090113

Upload 14 files

Browse files
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