From 9812a251981c05ac0dd1645527b5e6c36c269938 Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 20 Apr 2026 19:05:14 +0300 Subject: [PATCH] =?UTF-8?q?Phase=204:=20cloud=20backend=20=E2=80=94=20lice?= =?UTF-8?q?nsing,=20heartbeat,=20site=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New cloud_backend/ FastAPI service on port 8001 (SQLite for dev, swappable to PostgreSQL) - Endpoints: sysadmin auth (JWT), site registration, lock/unlock, heartbeat (X-Site-ID + X-Site-Key headers) - Default sysadmin seeded on first startup from ADMIN_USERNAME/ADMIN_PASSWORD env vars - cloud_backend added to docker-compose.yml with persistent data volume at ./data/cloud/ - local_backend cloud_sync.py updated to use correct /api/heartbeat/ endpoint with header auth - local_backend config.py: added SITE_KEY setting - Smoke tested: login, register site, heartbeat, lock, unlock, list all pass --- cloud_backend/.env.example | 14 +++++ cloud_backend/Dockerfile | 10 ++++ cloud_backend/auth_utils.py | 44 ++++++++++++++ cloud_backend/config.py | 17 ++++++ cloud_backend/database.py | 17 ++++++ cloud_backend/main.py | 51 ++++++++++++++++ cloud_backend/models/__init__.py | 0 cloud_backend/models/admin.py | 11 ++++ cloud_backend/models/site.py | 21 +++++++ cloud_backend/requirements.txt | 10 ++++ cloud_backend/routers/__init__.py | 0 cloud_backend/routers/auth.py | 18 ++++++ cloud_backend/routers/heartbeat.py | 37 ++++++++++++ cloud_backend/routers/sites.py | 90 ++++++++++++++++++++++++++++ cloud_backend/schemas/__init__.py | 0 cloud_backend/schemas/admin.py | 11 ++++ cloud_backend/schemas/site.py | 53 ++++++++++++++++ docker-compose.yml | 10 ++++ local_backend/config.py | 1 + local_backend/services/cloud_sync.py | 8 ++- 20 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 cloud_backend/.env.example create mode 100644 cloud_backend/Dockerfile create mode 100644 cloud_backend/auth_utils.py create mode 100644 cloud_backend/config.py create mode 100644 cloud_backend/database.py create mode 100644 cloud_backend/main.py create mode 100644 cloud_backend/models/__init__.py create mode 100644 cloud_backend/models/admin.py create mode 100644 cloud_backend/models/site.py create mode 100644 cloud_backend/requirements.txt create mode 100644 cloud_backend/routers/__init__.py create mode 100644 cloud_backend/routers/auth.py create mode 100644 cloud_backend/routers/heartbeat.py create mode 100644 cloud_backend/routers/sites.py create mode 100644 cloud_backend/schemas/__init__.py create mode 100644 cloud_backend/schemas/admin.py create mode 100644 cloud_backend/schemas/site.py diff --git a/cloud_backend/.env.example b/cloud_backend/.env.example new file mode 100644 index 0000000..27e1b1a --- /dev/null +++ b/cloud_backend/.env.example @@ -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 diff --git a/cloud_backend/Dockerfile b/cloud_backend/Dockerfile new file mode 100644 index 0000000..ba52c92 --- /dev/null +++ b/cloud_backend/Dockerfile @@ -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"] diff --git a/cloud_backend/auth_utils.py b/cloud_backend/auth_utils.py new file mode 100644 index 0000000..de1e202 --- /dev/null +++ b/cloud_backend/auth_utils.py @@ -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 diff --git a/cloud_backend/config.py b/cloud_backend/config.py new file mode 100644 index 0000000..22789d6 --- /dev/null +++ b/cloud_backend/config.py @@ -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() diff --git a/cloud_backend/database.py b/cloud_backend/database.py new file mode 100644 index 0000000..8daecb2 --- /dev/null +++ b/cloud_backend/database.py @@ -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() diff --git a/cloud_backend/main.py b/cloud_backend/main.py new file mode 100644 index 0000000..dadbf30 --- /dev/null +++ b/cloud_backend/main.py @@ -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"} diff --git a/cloud_backend/models/__init__.py b/cloud_backend/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloud_backend/models/admin.py b/cloud_backend/models/admin.py new file mode 100644 index 0000000..99a13dd --- /dev/null +++ b/cloud_backend/models/admin.py @@ -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") diff --git a/cloud_backend/models/site.py b/cloud_backend/models/site.py new file mode 100644 index 0000000..32de4d9 --- /dev/null +++ b/cloud_backend/models/site.py @@ -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) diff --git a/cloud_backend/requirements.txt b/cloud_backend/requirements.txt new file mode 100644 index 0000000..99fc783 --- /dev/null +++ b/cloud_backend/requirements.txt @@ -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 diff --git a/cloud_backend/routers/__init__.py b/cloud_backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloud_backend/routers/auth.py b/cloud_backend/routers/auth.py new file mode 100644 index 0000000..26451d1 --- /dev/null +++ b/cloud_backend/routers/auth.py @@ -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) diff --git a/cloud_backend/routers/heartbeat.py b/cloud_backend/routers/heartbeat.py new file mode 100644 index 0000000..feefcfa --- /dev/null +++ b/cloud_backend/routers/heartbeat.py @@ -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, + ) diff --git a/cloud_backend/routers/sites.py b/cloud_backend/routers/sites.py new file mode 100644 index 0000000..7c1b0a4 --- /dev/null +++ b/cloud_backend/routers/sites.py @@ -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() diff --git a/cloud_backend/schemas/__init__.py b/cloud_backend/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cloud_backend/schemas/admin.py b/cloud_backend/schemas/admin.py new file mode 100644 index 0000000..731e42f --- /dev/null +++ b/cloud_backend/schemas/admin.py @@ -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" diff --git a/cloud_backend/schemas/site.py b/cloud_backend/schemas/site.py new file mode 100644 index 0000000..b7e8f55 --- /dev/null +++ b/cloud_backend/schemas/site.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index f95c2cc..05ce462 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,14 @@ services: + cloud_backend: + build: ./cloud_backend + ports: + - "8001:8001" + restart: unless-stopped + env_file: + - ./cloud_backend/.env + volumes: + - ./data/cloud:/app/data + backend: build: ./local_backend ports: diff --git a/local_backend/config.py b/local_backend/config.py index 9096fcb..c5e1c7c 100644 --- a/local_backend/config.py +++ b/local_backend/config.py @@ -15,6 +15,7 @@ else: class Settings(BaseSettings): SITE_ID: str = "" + SITE_KEY: str = "" CLOUD_URL: str = "" SECRET_KEY: str = "change-me-generate-a-long-random-string" LICENSE_GRACE_HOURS: int = 24 diff --git a/local_backend/services/cloud_sync.py b/local_backend/services/cloud_sync.py index 396899c..89eb42d 100644 --- a/local_backend/services/cloud_sync.py +++ b/local_backend/services/cloud_sync.py @@ -46,8 +46,12 @@ async def _sync_once(): try: async with httpx.AsyncClient(timeout=10) as client: resp = await client.post( - f"{settings.CLOUD_URL}/api/sites/heartbeat", - json={"site_id": settings.SITE_ID}, + f"{settings.CLOUD_URL}/api/heartbeat/", + headers={ + "X-Site-ID": settings.SITE_ID, + "X-Site-Key": settings.SITE_KEY, + }, + json={"version": "1.0.0", "uptime_seconds": 0}, ) resp.raise_for_status() data = resp.json()