Initial Commit. Split cloud service from the combined project
This commit is contained in:
14
cloud_backend/.env.example
Normal file
14
cloud_backend/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Cloud Backend — copy this to .env and fill in values
|
||||
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=change-me-generate-a-long-random-string
|
||||
|
||||
# SQLite for local dev. Switch to postgresql://user:pass@host/dbname for VPS.
|
||||
DATABASE_URL=sqlite:////app/data/cloud.db
|
||||
|
||||
# JWT expiry in minutes
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
|
||||
# Default sysadmin credentials (created on first startup if admin table is empty)
|
||||
ADMIN_USERNAME=sysadmin
|
||||
ADMIN_PASSWORD=changeme
|
||||
10
cloud_backend/Dockerfile
Normal file
10
cloud_backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
44
cloud_backend/auth_utils.py
Normal file
44
cloud_backend/auth_utils.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from jose import jwt, JWTError
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from config import settings
|
||||
from database import get_db
|
||||
from models.admin import Admin
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
payload = data.copy()
|
||||
payload["exp"] = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def get_current_admin(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> Admin:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
admin = db.query(Admin).filter(Admin.username == username).first()
|
||||
if not admin:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin not found")
|
||||
return admin
|
||||
17
cloud_backend/config.py
Normal file
17
cloud_backend/config.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
_HERE = Path(__file__).parent
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
SECRET_KEY: str = "change-me-generate-a-long-random-string"
|
||||
DATABASE_URL: str = "sqlite:////app/data/cloud.db"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||
ADMIN_USERNAME: str = "sysadmin"
|
||||
ADMIN_PASSWORD: str = "changeme"
|
||||
|
||||
model_config = {"env_file": str(_HERE / ".env")}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
17
cloud_backend/database.py
Normal file
17
cloud_backend/database.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
from config import settings
|
||||
|
||||
connect_args = {"check_same_thread": False} if settings.DATABASE_URL.startswith("sqlite") else {}
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL, connect_args=connect_args)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
51
cloud_backend/main.py
Normal file
51
cloud_backend/main.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from config import settings
|
||||
from database import engine, Base
|
||||
from auth_utils import hash_password
|
||||
import models.admin # noqa: F401
|
||||
import models.site # noqa: F401
|
||||
|
||||
from routers import auth, sites, heartbeat
|
||||
|
||||
|
||||
def _seed_default_admin():
|
||||
from sqlalchemy.orm import Session
|
||||
from models.admin import Admin
|
||||
|
||||
with Session(engine) as db:
|
||||
if not db.query(Admin).filter(Admin.username == settings.ADMIN_USERNAME).first():
|
||||
db.add(Admin(
|
||||
username=settings.ADMIN_USERNAME,
|
||||
password_hash=hash_password(settings.ADMIN_PASSWORD),
|
||||
role="sysadmin",
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_seed_default_admin()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="POS Cloud Backend", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(sites.router, prefix="/api/sites", tags=["sites"])
|
||||
app.include_router(heartbeat.router, prefix="/api/heartbeat", tags=["heartbeat"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
0
cloud_backend/models/__init__.py
Normal file
0
cloud_backend/models/__init__.py
Normal file
11
cloud_backend/models/admin.py
Normal file
11
cloud_backend/models/admin.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from database import Base
|
||||
|
||||
|
||||
class Admin(Base):
|
||||
__tablename__ = "admins"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, nullable=False)
|
||||
password_hash = Column(String, nullable=False)
|
||||
role = Column(String, default="sysadmin")
|
||||
21
cloud_backend/models/site.py
Normal file
21
cloud_backend/models/site.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
|
||||
|
||||
class Site(Base):
|
||||
__tablename__ = "sites"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
site_id = Column(String, unique=True, nullable=False, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
owner_name = Column(String, nullable=False)
|
||||
contact_email = Column(String, nullable=False)
|
||||
secret_key_hash = Column(String, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_locked = Column(Boolean, default=False)
|
||||
lock_reason = Column(String, nullable=True)
|
||||
license_expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
last_seen_at = Column(DateTime(timezone=True), nullable=True)
|
||||
last_seen_ip = Column(String, nullable=True)
|
||||
10
cloud_backend/requirements.txt
Normal file
10
cloud_backend/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
sqlalchemy==2.0.35
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.5.2
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.9
|
||||
httpx==0.27.2
|
||||
0
cloud_backend/routers/__init__.py
Normal file
0
cloud_backend/routers/__init__.py
Normal file
18
cloud_backend/routers/auth.py
Normal file
18
cloud_backend/routers/auth.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth_utils import verify_password, create_access_token
|
||||
from database import get_db
|
||||
from models.admin import Admin
|
||||
from schemas.admin import LoginRequest, TokenOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenOut)
|
||||
def login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||
admin = db.query(Admin).filter(Admin.username == body.username).first()
|
||||
if not admin or not verify_password(body.password, admin.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
token = create_access_token({"sub": admin.username, "role": admin.role})
|
||||
return TokenOut(access_token=token)
|
||||
37
cloud_backend/routers/heartbeat.py
Normal file
37
cloud_backend/routers/heartbeat.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Request, status
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models.site import Site
|
||||
from schemas.site import HeartbeatRequest, HeartbeatResponse
|
||||
|
||||
router = APIRouter()
|
||||
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
@router.post("/", response_model=HeartbeatResponse)
|
||||
def heartbeat(
|
||||
body: HeartbeatRequest,
|
||||
request: Request,
|
||||
x_site_id: str = Header(..., alias="X-Site-ID"),
|
||||
x_site_key: str = Header(..., alias="X-Site-Key"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
site = db.query(Site).filter(Site.site_id == x_site_id).first()
|
||||
if not site or not _pwd.verify(x_site_key, site.secret_key_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid site credentials")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
site.last_seen_at = now
|
||||
site.last_seen_ip = request.client.host if request.client else None
|
||||
db.commit()
|
||||
|
||||
licensed = site.is_active and (site.license_expires_at.replace(tzinfo=timezone.utc) > now)
|
||||
return HeartbeatResponse(
|
||||
licensed=licensed,
|
||||
locked=site.is_locked,
|
||||
lock_reason=site.lock_reason,
|
||||
expires_at=site.license_expires_at,
|
||||
)
|
||||
90
cloud_backend/routers/sites.py
Normal file
90
cloud_backend/routers/sites.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import secrets
|
||||
import uuid
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth_utils import get_current_admin
|
||||
from database import get_db
|
||||
from models.site import Site
|
||||
from schemas.site import SiteCreate, SiteUpdate, SiteOut, SiteCreatedOut, LockRequest
|
||||
|
||||
router = APIRouter()
|
||||
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SiteOut])
|
||||
def list_sites(db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||
return db.query(Site).all()
|
||||
|
||||
|
||||
@router.post("/", response_model=SiteCreatedOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_site(body: SiteCreate, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||
raw_key = secrets.token_urlsafe(32)
|
||||
site = Site(
|
||||
site_id=str(uuid.uuid4()),
|
||||
name=body.name,
|
||||
owner_name=body.owner_name,
|
||||
contact_email=body.contact_email,
|
||||
secret_key_hash=_pwd.hash(raw_key),
|
||||
license_expires_at=body.license_expires_at,
|
||||
)
|
||||
db.add(site)
|
||||
db.commit()
|
||||
db.refresh(site)
|
||||
data = SiteOut.model_validate(site).model_dump()
|
||||
data["secret_key"] = raw_key
|
||||
return SiteCreatedOut(**data)
|
||||
|
||||
|
||||
@router.get("/{site_id}", response_model=SiteOut)
|
||||
def get_site(site_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||
site = db.query(Site).filter(Site.site_id == site_id).first()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
return site
|
||||
|
||||
|
||||
@router.put("/{site_id}", response_model=SiteOut)
|
||||
def update_site(site_id: str, body: SiteUpdate, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||
site = db.query(Site).filter(Site.site_id == site_id).first()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(site, field, value)
|
||||
db.commit()
|
||||
db.refresh(site)
|
||||
return site
|
||||
|
||||
|
||||
@router.post("/{site_id}/lock", response_model=SiteOut)
|
||||
def lock_site(site_id: str, body: LockRequest, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||
site = db.query(Site).filter(Site.site_id == site_id).first()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
site.is_locked = True
|
||||
site.lock_reason = body.reason
|
||||
db.commit()
|
||||
db.refresh(site)
|
||||
return site
|
||||
|
||||
|
||||
@router.post("/{site_id}/unlock", response_model=SiteOut)
|
||||
def unlock_site(site_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||
site = db.query(Site).filter(Site.site_id == site_id).first()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
site.is_locked = False
|
||||
site.lock_reason = None
|
||||
db.commit()
|
||||
db.refresh(site)
|
||||
return site
|
||||
|
||||
|
||||
@router.delete("/{site_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_site(site_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||
site = db.query(Site).filter(Site.site_id == site_id).first()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
db.delete(site)
|
||||
db.commit()
|
||||
0
cloud_backend/schemas/__init__.py
Normal file
0
cloud_backend/schemas/__init__.py
Normal file
11
cloud_backend/schemas/admin.py
Normal file
11
cloud_backend/schemas/admin.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenOut(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
53
cloud_backend/schemas/site.py
Normal file
53
cloud_backend/schemas/site.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SiteCreate(BaseModel):
|
||||
name: str
|
||||
owner_name: str
|
||||
contact_email: str
|
||||
license_expires_at: datetime
|
||||
|
||||
|
||||
class SiteUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
owner_name: str | None = None
|
||||
contact_email: str | None = None
|
||||
license_expires_at: datetime | None = None
|
||||
|
||||
|
||||
class SiteOut(BaseModel):
|
||||
id: int
|
||||
site_id: str
|
||||
name: str
|
||||
owner_name: str
|
||||
contact_email: str
|
||||
is_active: bool
|
||||
is_locked: bool
|
||||
lock_reason: str | None
|
||||
license_expires_at: datetime
|
||||
created_at: datetime
|
||||
last_seen_at: datetime | None
|
||||
last_seen_ip: str | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SiteCreatedOut(SiteOut):
|
||||
secret_key: str
|
||||
|
||||
|
||||
class LockRequest(BaseModel):
|
||||
reason: str
|
||||
|
||||
|
||||
class HeartbeatRequest(BaseModel):
|
||||
version: str = "1.0.0"
|
||||
uptime_seconds: int = 0
|
||||
|
||||
|
||||
class HeartbeatResponse(BaseModel):
|
||||
licensed: bool
|
||||
locked: bool
|
||||
lock_reason: str | None
|
||||
expires_at: datetime
|
||||
Reference in New Issue
Block a user