diff --git a/.gitignore b/.gitignore deleted file mode 100644 index e4d7f54..0000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.env -fleetledger/.env diff --git a/fleetledger/.env-example b/fleetledger/.env-example deleted file mode 100644 index ced0806..0000000 --- a/fleetledger/.env-example +++ /dev/null @@ -1,7 +0,0 @@ -# Copy this file to .env and fill in strong values for production. -SESSION_SECRET=changeme_super_secret_value -# Set to 1 for HTTPS deployments; set to 0 only for local HTTP testing. -SESSION_COOKIE_SECURE=1 -DATABASE_PATH=/data/fleetledger.db -# Optional: Fernet key for encrypting management passwords (leave empty to disable) -ENCRYPTION_KEY= diff --git a/fleetledger/Dockerfile b/fleetledger/Dockerfile deleted file mode 100644 index 34bc2d4..0000000 --- a/fleetledger/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -FROM python:3.12-slim - -WORKDIR /app - -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PYTHONUNBUFFERED=1 - -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - && rm -rf /var/lib/apt/lists/* - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY app ./app - -ENV DATABASE_PATH=/data/fleetledger.db - -EXPOSE 8000 - -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/fleetledger/README.md b/fleetledger/README.md deleted file mode 100644 index bcf64fc..0000000 --- a/fleetledger/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# FleetLedger - -FleetLedger is a small self-hosted web app to keep track of your rented servers: - -- VPS, dedicated servers, storage boxes, managed services -- Provider, location, IPs, hardware -- Monthly / yearly pricing and contract dates -- Simple access info (management URLs, SSH user + key hint) -- Multi-user support with per-user data separation -- Admin user management (activate / deactivate users) -- Dark-mode-first UI with PWA support (installable as an app) -- Per-user **map view** for server locations -- Admin **global dashboard** for fleet-wide stats - -> **Security note:** FleetLedger is *not* a full password manager. -> It is intentionally designed to store only **management password(s) optionally** and -> only **SSH key *names*** (no private keys). - ---- - -## Features - -- **Authentication & Users** - - User registration + login (session cookie based) - - First registered user becomes **admin** - - Admin can view all users and activate/deactivate them - - Deactivated users cannot log in and will be logged out automatically - -- **Server Management** - - Each user has their own list of servers (no cross-visibility) - - Create / edit / archive (soft-delete) servers - - Fields include: - - General: name, hostname, type (VPS, dedicated, storage, managed, other), provider, location, tags - - Network: IPv4, IPv6 - - Billing: price, currency, billing period (monthly/yearly/other), contract start/end - - Hardware: CPU model, core count, RAM, storage size & type - - Access: management URL, management user, management password (optional), SSH user, SSH key hint - - Free-form notes - - Contract badges: - - **"abgelaufen"** (expired): contract end in the past - - **"läuft bald aus"** (expiring soon): contract end within the next 30 days - - Detail view also shows how many days until / since contract end - -- **Per-user Dashboard & Map** - - On `/`: small dashboard row showing: - - number of active servers - - estimated total monthly cost - - how many contracts are expiring soon / already expired - - On `/map`: Leaflet-based map showing all non-archived servers of the logged-in user - - Marker position is derived from the `location` string (city/datacenter name) - - Multiple servers per city are slightly offset so all markers remain clickable - - Click on a marker → opens the server details page - -- **Admin Global Dashboard** - - On `/admin/dashboard` (admin only): - - Global counts: users, servers, monthly cost, expiring soon, expired - - Breakdown by provider (server count, monthly total, expiring soon, expired) - - List of contracts expiring soon and already expired - -- **Security** - - Passwords hashed with **bcrypt** (`passlib[bcrypt]`) - - Optional encryption for management passwords using **Fernet** (`cryptography`) - - No private SSH keys are stored, only name/hint strings - - Jinja2 auto-escaping enabled; no untrusted HTML is rendered with `|safe` - - Management URLs are restricted to `http://` or `https://` (no `javascript:` links, etc.) - -- **UI / UX** - - TailwindCSS via CDN for quick styling - - Dark mode is **enabled by default** - - Theme preference stored in `localStorage` and toggleable via a small button - - Responsive layout, works well on mobile - - PWA manifest and service worker for a simple offline-friendly experience - ---- - -## Quick Start (Docker) - -### 0. Environment - -Kopiere `.env-example` nach `.env` und setze mindestens ein starkes `SESSION_SECRET`. Für lokale HTTP-Tests kannst du `SESSION_COOKIE_SECURE=0` setzen, in Produktion sollte es `1` bleiben. Optional kannst du einen `ENCRYPTION_KEY` (Fernet) hinterlegen, um Management-Passwörter zu speichern. - -### 1. Clone / copy the repository - -```bash -git clone https://example.com/your/fleetledger.git -cd fleetledger diff --git a/fleetledger/app/__init__.py b/fleetledger/app/__init__.py deleted file mode 100644 index 2487607..0000000 --- a/fleetledger/app/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Empty file to mark "app" as a package. diff --git a/fleetledger/app/auth.py b/fleetledger/app/auth.py deleted file mode 100644 index d72ee59..0000000 --- a/fleetledger/app/auth.py +++ /dev/null @@ -1,75 +0,0 @@ -from typing import Optional - -from fastapi import Depends, HTTPException, Request, status -from passlib.context import CryptContext -from sqlmodel import Session, select - -from .db import get_session -from .models import User - -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - - -def hash_password(password: str) -> str: - """Hash a password using bcrypt.""" - return pwd_context.hash(password) - - -def verify_password(plain_password: str, hashed_password: str) -> bool: - """Verify a password against a stored bcrypt hash.""" - return pwd_context.verify(plain_password, hashed_password) - - -def get_current_user( - request: Request, - session: Session = Depends(get_session), -) -> Optional[User]: - """ - Retrieve the currently logged-in user based on the session cookie. - - Returns None if no user is logged in or the user is inactive. - """ - user_id = request.session.get("user_id") - if not user_id: - return None - user = session.get(User, user_id) - if not user or not user.is_active: - # Clear session if the user was deactivated. - request.session.clear() - return None - return user - - -def require_current_user( - current_user: Optional[User] = Depends(get_current_user), -) -> User: - """ - Require an authenticated user. - - If not authenticated, redirect to /login with a 303 status. - """ - if not current_user: - raise HTTPException( - status_code=status.HTTP_303_SEE_OTHER, - headers={"Location": "/login"}, - ) - return current_user - - -def require_admin( - current_user: Optional[User] = Depends(get_current_user), -) -> User: - """ - Require an authenticated admin user. - - Non-authenticated users are redirected to /login. - Non-admins receive HTTP 403. - """ - if not current_user: - raise HTTPException( - status_code=status.HTTP_303_SEE_OTHER, - headers={"Location": "/login"}, - ) - if not current_user.is_admin: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - return current_user diff --git a/fleetledger/app/db.py b/fleetledger/app/db.py deleted file mode 100644 index efb7132..0000000 --- a/fleetledger/app/db.py +++ /dev/null @@ -1,21 +0,0 @@ -from sqlmodel import SQLModel, create_engine, Session -import os - -# SQLite database path, mapped to a Docker volume for persistence -DB_PATH = os.getenv("DATABASE_PATH", "/data/fleetledger.db") - -engine = create_engine( - f"sqlite:///{DB_PATH}", - connect_args={"check_same_thread": False}, -) - - -def init_db() -> None: - """Create all tables if they do not exist yet.""" - SQLModel.metadata.create_all(engine) - - -def get_session(): - """FastAPI dependency that yields a SQLModel session.""" - with Session(engine) as session: - yield session diff --git a/fleetledger/app/main.py b/fleetledger/app/main.py deleted file mode 100644 index 6ff6774..0000000 --- a/fleetledger/app/main.py +++ /dev/null @@ -1,783 +0,0 @@ -import os -from datetime import datetime -from typing import Optional - -from fastapi import Depends, FastAPI, Form, HTTPException, Request, status -from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates -from sqlmodel import Session, select -from starlette.middleware.sessions import SessionMiddleware - -from .db import init_db, get_session -from .models import Server, User -from .utils import ( - encrypt_secret, - decrypt_secret, - can_encrypt, - ensure_csrf_token, - validate_csrf, -) -from .auth import ( - hash_password, - verify_password, - get_current_user, - require_current_user, - require_admin, -) - -app = FastAPI(title="FleetLedger") - -app.mount("/static", StaticFiles(directory="app/static"), name="static") -templates = Jinja2Templates(directory="app/templates") - -# Session middleware (server-side session based on signed cookie) -SESSION_SECRET = os.getenv("SESSION_SECRET") -if not SESSION_SECRET or SESSION_SECRET.startswith("CHANGE_ME"): - raise RuntimeError( - "SESSION_SECRET environment variable must be set to a strong random value." - ) - -SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "1") != "0" - -app.add_middleware( - SessionMiddleware, - secret_key=SESSION_SECRET, - same_site="lax", - https_only=SESSION_COOKIE_SECURE, - cookie_name="fleetledger_session", - max_age=60 * 60 * 24 * 30, # 30 days -) - - -@app.on_event("startup") -def on_startup() -> None: - """Initialize database on startup.""" - init_db() - - -@app.get("/manifest.webmanifest") -def manifest() -> FileResponse: - """Serve the PWA manifest.""" - return FileResponse( - "app/static/manifest.webmanifest", media_type="application/manifest+json" - ) - - -@app.get("/service-worker.js", include_in_schema=False) -def service_worker() -> FileResponse: - """Serve the service worker from root scope.""" - return FileResponse( - "app/static/service-worker.js", - media_type="application/javascript", - headers={"Service-Worker-Allowed": "/"}, - ) - - -# ------------- Auth: Register / Login / Logout ------------- - - -@app.get("/register", response_class=HTMLResponse) -def register_form( - request: Request, - session: Session = Depends(get_session), - current_user: Optional[User] = Depends(get_current_user), -): - """ - Render the registration form. - - If at least one user already exists, only admins may register new users. - """ - user_count = len(session.exec(select(User)).all()) - if user_count > 0 and (not current_user or not current_user.is_admin): - return RedirectResponse("/", status_code=303) - - csrf_token = ensure_csrf_token(request) - - return templates.TemplateResponse( - "register.html", - { - "request": request, - "error": None, - "current_user": current_user, - "csrf_token": csrf_token, - }, - ) - - -@app.post("/register") -def register( - request: Request, - username: str = Form(...), - email: str = Form(""), - password: str = Form(...), - password_confirm: str = Form(...), - csrf_token: str = Form(...), - session: Session = Depends(get_session), - current_user: Optional[User] = Depends(get_current_user), -): - """ - Handle registration submissions. - - - First user becomes admin automatically. - - Later users can only be created by admins. - """ - if not validate_csrf(request, csrf_token): - token = ensure_csrf_token(request) - return templates.TemplateResponse( - "register.html", - { - "request": request, - "error": "Ungültiger Sicherheits-Token. Bitte erneut versuchen.", - "current_user": current_user, - "csrf_token": token, - }, - status_code=400, - ) - - user_count = len(session.exec(select(User)).all()) - if user_count > 0 and (not current_user or not current_user.is_admin): - return RedirectResponse("/", status_code=303) - - error = None - if password != password_confirm: - error = "Passwords do not match." - else: - existing = session.exec( - select(User).where(User.username == username) - ).first() - if existing: - error = "Username is already taken." - - csrf_token = ensure_csrf_token(request) - - if error: - return templates.TemplateResponse( - "register.html", - { - "request": request, - "error": error, - "current_user": current_user, - "csrf_token": csrf_token, - }, - status_code=400, - ) - - user = User( - username=username, - email=email or None, - password_hash=hash_password(password), - is_active=True, - is_admin=(user_count == 0), # first user is admin - ) - session.add(user) - session.commit() - session.refresh(user) - - # Auto-login after registration - request.session["user_id"] = user.id - return RedirectResponse("/", status_code=303) - - -@app.get("/login", response_class=HTMLResponse) -def login_form( - request: Request, - current_user: Optional[User] = Depends(get_current_user), -): - """Render the login form.""" - if current_user: - return RedirectResponse("/", status_code=303) - csrf_token = ensure_csrf_token(request) - return templates.TemplateResponse( - "login.html", - { - "request": request, - "error": None, - "current_user": current_user, - "csrf_token": csrf_token, - }, - ) - - -@app.post("/login") -def login( - request: Request, - username: str = Form(...), - password: str = Form(...), - csrf_token: str = Form(...), - session: Session = Depends(get_session), -): - """Handle login submissions.""" - if not validate_csrf(request, csrf_token): - token = ensure_csrf_token(request) - return templates.TemplateResponse( - "login.html", - { - "request": request, - "error": "Ungültiger Sicherheits-Token. Bitte erneut versuchen.", - "current_user": None, - "csrf_token": token, - }, - status_code=400, - ) - - user = session.exec(select(User).where(User.username == username)).first() - error = None - if not user or not verify_password(password, user.password_hash): - error = "Invalid username or password." - elif not user.is_active: - error = "User is deactivated." - - csrf_token = ensure_csrf_token(request) - - if error: - return templates.TemplateResponse( - "login.html", - { - "request": request, - "error": error, - "current_user": None, - "csrf_token": csrf_token, - }, - status_code=400, - ) - - request.session["user_id"] = user.id - return RedirectResponse("/", status_code=303) - - -@app.get("/logout") -def logout(request: Request): - """Log out the current user and clear the session.""" - request.session.clear() - return RedirectResponse("/login", status_code=303) - - -# ------------- Admin: User management ------------- - - -@app.get("/users", response_class=HTMLResponse) -def list_users( - request: Request, - session: Session = Depends(get_session), - current_user: User = Depends(require_admin), -): - """List all users (admin only).""" - users = session.exec(select(User).order_by(User.username)).all() - csrf_token = ensure_csrf_token(request) - return templates.TemplateResponse( - "users_list.html", - { - "request": request, - "users": users, - "current_user": current_user, - "csrf_token": csrf_token, - }, - ) - - -@app.post("/users/{user_id}/toggle-active") -def toggle_user_active( - user_id: int, - request: Request, - csrf_token: str = Form(...), - session: Session = Depends(get_session), - current_user: User = Depends(require_admin), -): - """ - Toggle a user's active state (admin only). - - An admin cannot deactivate themselves to avoid lockout. - """ - if not validate_csrf(request, csrf_token): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid CSRF token." - ) - - user = session.get(User, user_id) - if not user: - return RedirectResponse("/users", status_code=303) - - if user.id == current_user.id: - return RedirectResponse("/users", status_code=303) - - user.is_active = not user.is_active - session.add(user) - session.commit() - return RedirectResponse("/users", status_code=303) - - -# ------------- Admin: Global dashboard ------------- - - -@app.get("/admin/dashboard", response_class=HTMLResponse) -def admin_dashboard( - request: Request, - session: Session = Depends(get_session), - current_user: User = Depends(require_admin), -): - """ - Global admin dashboard aggregating all non-archived servers across all users. - """ - servers = session.exec( - select(Server).where(Server.archived == False) - ).all() - users = session.exec(select(User)).all() - - total_servers = len(servers) - total_users = len(users) - expiring_soon = sum(1 for s in servers if s.is_expiring_soon) - expired = sum(1 for s in servers if s.is_expired) - - monthly_total = 0.0 - currencies = set() - for s in servers: - if not s.price: - continue - if s.currency: - currencies.add(s.currency) - if s.billing_period == "yearly": - monthly_total += s.price / 12.0 - else: - monthly_total += s.price - - monthly_currency = None - if len(currencies) == 1: - monthly_currency = next(iter(currencies)) - - # Provider-level stats - provider_stats = {} - for s in servers: - provider = s.provider or "Unknown" - if provider not in provider_stats: - provider_stats[provider] = { - "count": 0, - "monthly_total": 0.0, - "expiring_soon": 0, - "expired": 0, - "currency_set": set(), - } - ps = provider_stats[provider] - ps["count"] += 1 - if s.price: - if s.currency: - ps["currency_set"].add(s.currency) - if s.billing_period == "yearly": - ps["monthly_total"] += s.price / 12.0 - else: - ps["monthly_total"] += s.price - if s.is_expiring_soon: - ps["expiring_soon"] += 1 - if s.is_expired: - ps["expired"] += 1 - - # Contracts expiring soon and expired, for small lists - expiring_soon_list = sorted( - [s for s in servers if s.is_expiring_soon], - key=lambda s: s.contract_end or datetime.max.date(), - ) - expired_list = sorted( - [s for s in servers if s.is_expired], - key=lambda s: s.contract_end or datetime.min.date(), - reverse=True, - ) - - stats = { - "total_servers": total_servers, - "total_users": total_users, - "expiring_soon": expiring_soon, - "expired": expired, - "monthly_total": monthly_total, - "monthly_currency": monthly_currency, - "mixed_currencies": len(currencies) > 1, - } - - return templates.TemplateResponse( - "admin_dashboard.html", - { - "request": request, - "current_user": current_user, - "stats": stats, - "provider_stats": provider_stats, - "expiring_soon_list": expiring_soon_list, - "expired_list": expired_list, - }, - ) - - -# ------------- Server views (CRUD, per user) ------------- - - -@app.get("/", response_class=HTMLResponse) -def list_servers( - request: Request, - session: Session = Depends(get_session), - current_user: User = Depends(require_current_user), -): - """ - List all non-archived servers owned by the current user. - Additionally compute some summary stats for the dashboard. - """ - servers = session.exec( - select(Server) - .where(Server.archived == False) - .where(Server.owner_id == current_user.id) - .order_by(Server.provider, Server.name) - ).all() - - # --- Dashboard stats --- - total_servers = len(servers) - expiring_soon = sum(1 for s in servers if s.is_expiring_soon) - expired = sum(1 for s in servers if s.is_expired) - - # Approximate total monthly cost - monthly_total = 0.0 - currencies = set() - for s in servers: - if not s.price: - continue - if s.currency: - currencies.add(s.currency) - if s.billing_period == "yearly": - monthly_total += s.price / 12.0 - else: - # treat "monthly" and "other" as monthly for the purpose of the overview - monthly_total += s.price - - monthly_currency = None - if len(currencies) == 1: - monthly_currency = next(iter(currencies)) - - stats = { - "total_servers": total_servers, - "expiring_soon": expiring_soon, - "expired": expired, - "monthly_total": monthly_total, - "monthly_currency": monthly_currency, - "mixed_currencies": len(currencies) > 1, - } - - return templates.TemplateResponse( - "servers_list.html", - { - "request": request, - "servers": servers, - "can_encrypt": can_encrypt(), - "current_user": current_user, - "stats": stats, - }, - ) - - -@app.get("/servers/new", response_class=HTMLResponse) -def new_server_form( - request: Request, - current_user: User = Depends(require_current_user), -): - """Render a blank form for creating a new server.""" - csrf_token = ensure_csrf_token(request) - return templates.TemplateResponse( - "server_form.html", - { - "request": request, - "server": None, - "can_encrypt": can_encrypt(), - "current_user": current_user, - "csrf_token": csrf_token, - }, - ) - - -@app.post("/servers/new") -def create_server( - request: Request, - name: str = Form(...), - provider: str = Form(...), - hostname: str = Form(""), - type: str = Form("vps"), - location: str = Form(""), - ipv4: str = Form(""), - ipv6: str = Form(""), - billing_period: str = Form("monthly"), - price: float = Form(0.0), - currency: str = Form("EUR"), - contract_start: Optional[str] = Form(None), - contract_end: Optional[str] = Form(None), - cpu_model: str = Form(""), - cpu_cores: int = Form(0), - ram_mb: int = Form(0), - storage_gb: int = Form(0), - storage_type: str = Form(""), - tags: str = Form(""), - mgmt_url: str = Form(""), - mgmt_user: str = Form(""), - mgmt_password: str = Form(""), - ssh_user: str = Form(""), - ssh_key_hint: str = Form(""), - notes: str = Form(""), - csrf_token: str = Form(...), - session: Session = Depends(get_session), - current_user: User = Depends(require_current_user), -): - """Create a new server entry for the current user.""" - if not validate_csrf(request, csrf_token): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid CSRF token." - ) - - c_start = ( - datetime.fromisoformat(contract_start).date() if contract_start else None - ) - c_end = datetime.fromisoformat(contract_end).date() if contract_end else None - - enc_pwd = encrypt_secret(mgmt_password) if mgmt_password else None - - # Only allow http:// or https:// URLs to avoid javascript: schemes etc. - mgmt_url_clean = mgmt_url.strip() - if mgmt_url_clean and not ( - mgmt_url_clean.lower().startswith("http://") - or mgmt_url_clean.lower().startswith("https://") - ): - mgmt_url_clean = "" - - server = Server( - owner_id=current_user.id, - name=name, - provider=provider, - hostname=hostname or None, - type=type, - location=location or None, - ipv4=ipv4 or None, - ipv6=ipv6 or None, - billing_period=billing_period, - price=price, - currency=currency, - contract_start=c_start, - contract_end=c_end, - cpu_model=cpu_model or None, - cpu_cores=cpu_cores or None, - ram_mb=ram_mb or None, - storage_gb=storage_gb or None, - storage_type=storage_type or None, - tags=tags or None, - mgmt_url=mgmt_url_clean or None, - mgmt_user=mgmt_user or None, - mgmt_password_encrypted=enc_pwd, - ssh_user=ssh_user or None, - ssh_key_hint=ssh_key_hint or None, - notes=notes or None, - updated_at=datetime.utcnow(), - ) - session.add(server) - session.commit() - session.refresh(server) - return RedirectResponse(url="/", status_code=303) - - -@app.get("/servers/{server_id}", response_class=HTMLResponse) -def server_detail( - server_id: int, - request: Request, - session: Session = Depends(get_session), - current_user: User = Depends(require_current_user), -): - """Show details for a single server.""" - server = session.get(Server, server_id) - if not server or server.owner_id != current_user.id: - return RedirectResponse("/", status_code=303) - - decrypted_pwd = ( - decrypt_secret(server.mgmt_password_encrypted) - if server.mgmt_password_encrypted - else None - ) - csrf_token = ensure_csrf_token(request) - - return templates.TemplateResponse( - "server_detail.html", - { - "request": request, - "server": server, - "mgmt_password": decrypted_pwd, - "can_encrypt": can_encrypt(), - "current_user": current_user, - "csrf_token": csrf_token, - }, - ) - - -@app.get("/servers/{server_id}/edit", response_class=HTMLResponse) -def edit_server_form( - server_id: int, - request: Request, - session: Session = Depends(get_session), - current_user: User = Depends(require_current_user), -): - """Render a pre-filled form for editing an existing server.""" - server = session.get(Server, server_id) - if not server or server.owner_id != current_user.id: - return RedirectResponse("/", status_code=303) - - csrf_token = ensure_csrf_token(request) - - return templates.TemplateResponse( - "server_form.html", - { - "request": request, - "server": server, - "can_encrypt": can_encrypt(), - "current_user": current_user, - "csrf_token": csrf_token, - }, - ) - - -@app.post("/servers/{server_id}/edit") -def update_server( - server_id: int, - request: Request, - name: str = Form(...), - provider: str = Form(...), - hostname: str = Form(""), - type: str = Form("vps"), - location: str = Form(""), - ipv4: str = Form(""), - ipv6: str = Form(""), - billing_period: str = Form("monthly"), - price: float = Form(0.0), - currency: str = Form("EUR"), - contract_start: Optional[str] = Form(None), - contract_end: Optional[str] = Form(None), - cpu_model: str = Form(""), - cpu_cores: int = Form(0), - ram_mb: int = Form(0), - storage_gb: int = Form(0), - storage_type: str = Form(""), - tags: str = Form(""), - mgmt_url: str = Form(""), - mgmt_user: str = Form(""), - mgmt_password: str = Form(""), - ssh_user: str = Form(""), - ssh_key_hint: str = Form(""), - notes: str = Form(""), - csrf_token: str = Form(...), - session: Session = Depends(get_session), - current_user: User = Depends(require_current_user), -): - """Update an existing server entry (only owner).""" - server = session.get(Server, server_id) - if not server or server.owner_id != current_user.id: - return RedirectResponse("/", status_code=303) - - if not validate_csrf(request, csrf_token): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid CSRF token." - ) - - c_start = ( - datetime.fromisoformat(contract_start).date() if contract_start else None - ) - c_end = datetime.fromisoformat(contract_end).date() if contract_end else None - - # Only update mgmt password if a non-empty value was submitted. - if mgmt_password: - enc_pwd = encrypt_secret(mgmt_password) - server.mgmt_password_encrypted = enc_pwd - - mgmt_url_clean = mgmt_url.strip() - if mgmt_url_clean and not ( - mgmt_url_clean.lower().startswith("http://") - or mgmt_url_clean.lower().startswith("https://") - ): - mgmt_url_clean = "" - - server.name = name - server.provider = provider - server.hostname = hostname or None - server.type = type - server.location = location or None - server.ipv4 = ipv4 or None - server.ipv6 = ipv6 or None - server.billing_period = billing_period - server.price = price - server.currency = currency - server.contract_start = c_start - server.contract_end = c_end - server.cpu_model = cpu_model or None - server.cpu_cores = cpu_cores or None - server.ram_mb = ram_mb or None - server.storage_gb = storage_gb or None - server.storage_type = storage_type or None - server.tags = tags or None - server.mgmt_url = mgmt_url_clean or None - server.mgmt_user = mgmt_user or None - server.ssh_user = ssh_user or None - server.ssh_key_hint = ssh_key_hint or None - server.notes = notes or None - server.updated_at = datetime.utcnow() - - session.add(server) - session.commit() - return RedirectResponse(f"/servers/{server_id}", status_code=303) - - -@app.post("/servers/{server_id}/archive") -def archive_server( - server_id: int, - request: Request, - csrf_token: str = Form(...), - session: Session = Depends(get_session), - current_user: User = Depends(require_current_user), -): - """ - Soft-delete (archive) a server. - - The record is kept but not shown in the main list. - """ - if not validate_csrf(request, csrf_token): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid CSRF token." - ) - - server = session.get(Server, server_id) - if not server or server.owner_id != current_user.id: - return RedirectResponse("/", status_code=303) - - server.archived = True - server.updated_at = datetime.utcnow() - session.add(server) - session.commit() - return RedirectResponse("/", status_code=303) - - -# ------------- Per-user map view ------------- - - -@app.get("/map", response_class=HTMLResponse) -def server_map( - request: Request, - session: Session = Depends(get_session), - current_user: User = Depends(require_current_user), -): - """ - Show a per-user map view with all non-archived servers. - - Coordinates are not stored in the database. Instead, the frontend derives - approximate positions from the location string (city/datacenter name) - using a built-in city map and a deterministic fallback. - """ - servers = session.exec( - select(Server) - .where(Server.archived == False) - .where(Server.owner_id == current_user.id) - .order_by(Server.provider, Server.name) - ).all() - - return templates.TemplateResponse( - "servers_map.html", - { - "request": request, - "servers": servers, - "current_user": current_user, - }, - ) diff --git a/fleetledger/app/models.py b/fleetledger/app/models.py deleted file mode 100644 index b9b8bca..0000000 --- a/fleetledger/app/models.py +++ /dev/null @@ -1,99 +0,0 @@ -from datetime import date, datetime -from typing import Optional, List - -from sqlmodel import SQLModel, Field, Relationship - - -class User(SQLModel, table=True): - """Application user model.""" - - id: Optional[int] = Field(default=None, primary_key=True) - - username: str = Field(index=True, unique=True) - email: Optional[str] = Field(default=None, index=True) - password_hash: str - - is_active: bool = Field(default=True) - is_admin: bool = Field(default=False) - - servers: List["Server"] = Relationship(back_populates="owner") - - -class Server(SQLModel, table=True): - """Server/VPS entry owned by a user.""" - - id: Optional[int] = Field(default=None, primary_key=True) - - # Owner - owner_id: int = Field(foreign_key="user.id") - owner: Optional[User] = Relationship(back_populates="servers") - - # General info - name: str - hostname: Optional[str] = None - type: str = "vps" # vps, dedicated, storage, managed, other - provider: str - location: Optional[str] = None - - # Network - ipv4: Optional[str] = None - ipv6: Optional[str] = None - - # Cost / billing - billing_period: str = "monthly" # monthly, yearly, other - price: float = 0.0 - currency: str = "EUR" - contract_start: Optional[date] = None - contract_end: Optional[date] = None - tags: Optional[str] = None # e.g. "prod,critical,backup" - - # Hardware - cpu_model: Optional[str] = None - cpu_cores: Optional[int] = None - ram_mb: Optional[int] = None - storage_gb: Optional[int] = None - storage_type: Optional[str] = None # nvme, ssd, hdd, ceph, ... - - # Access (no private SSH keys, only hints) - mgmt_url: Optional[str] = None - mgmt_user: Optional[str] = None - mgmt_password_encrypted: Optional[str] = None - ssh_user: Optional[str] = None - ssh_key_hint: Optional[str] = None # e.g. "id_ed25519_ovh" - - notes: Optional[str] = None - - created_at: datetime = Field(default_factory=datetime.utcnow) - updated_at: datetime = Field(default_factory=datetime.utcnow) - archived: bool = Field(default=False) - - # ----- Convenience properties for badges / UI ----- - - @property - def days_until_contract_end(self) -> Optional[int]: - """ - Number of days until the contract_end date. - - Returns: - int: positive or zero if in the future, - negative if already past, - None if no contract_end is set. - """ - if not self.contract_end: - return None - return (self.contract_end - date.today()).days - - @property - def is_expired(self) -> bool: - """Return True if the contract_end date lies in the past.""" - return self.contract_end is not None and self.contract_end < date.today() - - @property - def is_expiring_soon(self) -> bool: - """ - Return True if the contract will end within the next 30 days. - - This is used for "expiring soon" badges in the UI. - """ - days = self.days_until_contract_end - return days is not None and 0 <= days <= 30 diff --git a/fleetledger/app/static/icon-192.png b/fleetledger/app/static/icon-192.png deleted file mode 100644 index bfeade6..0000000 Binary files a/fleetledger/app/static/icon-192.png and /dev/null differ diff --git a/fleetledger/app/static/icon-512.png b/fleetledger/app/static/icon-512.png deleted file mode 100644 index aa731fa..0000000 Binary files a/fleetledger/app/static/icon-512.png and /dev/null differ diff --git a/fleetledger/app/static/manifest.webmanifest b/fleetledger/app/static/manifest.webmanifest deleted file mode 100644 index 158b24e..0000000 --- a/fleetledger/app/static/manifest.webmanifest +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "FleetLedger", - "short_name": "FleetLedger", - "start_url": "/", - "display": "standalone", - "background_color": "#020617", - "theme_color": "#020617", - "icons": [ - { - "src": "/static/icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/static/icon-512.png", - "sizes": "512x512", - "type": "image/png" - } - ] -} diff --git a/fleetledger/app/static/service-worker.js b/fleetledger/app/static/service-worker.js deleted file mode 100644 index bbd314b..0000000 --- a/fleetledger/app/static/service-worker.js +++ /dev/null @@ -1,74 +0,0 @@ -const CACHE_VERSION = "v2"; -const CACHE_NAME = `fleetledger-${CACHE_VERSION}`; -const ASSETS = [ - "/", - "/static/style.css", - "/static/icon-192.png", - "/static/icon-512.png", - "/static/manifest.webmanifest", -]; - -self.addEventListener("install", (event) => { - event.waitUntil( - caches.open(CACHE_NAME).then((cache) => { - return cache.addAll(ASSETS); - }) - ); -}); - -self.addEventListener("activate", (event) => { - event.waitUntil( - caches.keys().then((keys) => - Promise.all( - keys - .filter((key) => key.startsWith("fleetledger-") && key !== CACHE_NAME) - .map((key) => caches.delete(key)) - ) - ) - ); -}); - -async function networkFirst(request) { - try { - const response = await fetch(request); - const cache = await caches.open(CACHE_NAME); - cache.put(request, response.clone()); - return response; - } catch (err) { - const cached = await caches.match(request); - if (cached) { - return cached; - } - return caches.match("/"); - } -} - -async function cacheFirst(request) { - const cached = await caches.match(request); - if (cached) { - return cached; - } - const response = await fetch(request); - const cache = await caches.open(CACHE_NAME); - cache.put(request, response.clone()); - return response; -} - -self.addEventListener("fetch", (event) => { - if (event.request.method !== "GET") { - return; - } - - const url = new URL(event.request.url); - - // Navigation requests: network first, fallback to cache - if (event.request.mode === "navigate") { - event.respondWith(networkFirst(event.request)); - return; - } - - // Same-origin static assets: cache first - if (url.origin === self.location.origin) { - event.respondWith(cacheFirst(event.request)); - } -}); diff --git a/fleetledger/app/static/style.css b/fleetledger/app/static/style.css deleted file mode 100644 index 5bf82eb..0000000 --- a/fleetledger/app/static/style.css +++ /dev/null @@ -1,5 +0,0 @@ -/* Simple extra styles on top of Tailwind. */ -body { - font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", - "Segoe UI", sans-serif; -} diff --git a/fleetledger/app/templates/admin_dashboard.html b/fleetledger/app/templates/admin_dashboard.html deleted file mode 100644 index 18394db..0000000 --- a/fleetledger/app/templates/admin_dashboard.html +++ /dev/null @@ -1,154 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
- Globale Übersicht über alle nicht archivierten Server und Benutzer. -
-| Provider | -Server | -Monatskosten | -Laufen bald aus | -Abgelaufen | -
|---|---|---|---|---|
| {{ provider }} | -{{ ps.count }} | -- {{ "%.2f"|format(ps.monthly_total) }} - {% if ps.currency_set|length == 1 %} - {{ (ps.currency_set|list)[0] }} - {% elif ps.currency_set|length > 1 %} - (mixed) - {% endif %} - | -{{ ps.expiring_soon }} | -{{ ps.expired }} | -
| - Keine Server erfasst. - | -||||
Melde dich an, um deine Server zu verwalten.
- - {% if error %} -- Erstelle einen neuen Account. Der erste Benutzer wird automatisch Admin. -
- - {% if error %} -{{ server.hostname }}
- {% endif %} - {% if server.contract_end %} -- Vertragsende: {{ server.contract_end }} - {% if server.days_until_contract_end is not none %} - {% if server.days_until_contract_end < 0 %} - (vor {{ (server.days_until_contract_end * -1) }} Tagen) - {% elif server.days_until_contract_end == 0 %} - (heute) - {% else %} - (in {{ server.days_until_contract_end }} Tagen) - {% endif %} - {% endif %} -
- {% endif %} -- Hinweis: Es werden nur Key-Namen gespeichert, keine privaten SSH-Schlüssel. -
-- Trage alle relevanten Infos zu deinem VPS / Server ein. -
- - {% if not can_encrypt %} -- Übersicht aller gemieteten VPS, dedizierten Server und Storage-Systeme. -
-| Name | -Provider | -Type | -Location | -IPv4 | -Kosten | -
|---|---|---|---|---|---|
|
-
-
-
-
- {{ s.name }}
-
- {% if s.hostname %}
-
- {% if s.is_expired %}
-
- abgelaufen
-
- {% elif s.is_expiring_soon %}
-
- läuft bald aus
-
- {% endif %}
- {{ s.hostname }}
- {% endif %}
- |
- {{ s.provider }} | -- - {{ s.type }} - - | -- {% if s.location %}{{ s.location }}{% else %}–{% endif %} - | -- {% if s.ipv4 %}{{ s.ipv4 }}{% else %}–{% endif %} - | -- {% if s.price %} - {{ "%.2f"|format(s.price) }} {{ s.currency }} - / {{ "Monat" if s.billing_period == "monthly" else "Jahr" }} - {% else %} - – - {% endif %} - | -
- Zeigt alle nicht archivierten Server mit gesetzter Location auf einer Karte. - Für grobe Übersicht reicht die Stadt – keine exakten GPS-Daten notwendig. -
-- Hinweis: Marker werden anhand der Location-Namen grob auf der Weltkarte platziert. Mehrere Server - in derselben Stadt werden leicht versetzt dargestellt, damit sie klickbar bleiben. -
-- Admins können Benutzer aktivieren/deaktivieren. Der eigene Account kann nicht deaktiviert werden. -
-| Benutzername | -Rolle | -Status | -Aktion | -|
|---|---|---|---|---|
| - {{ u.username }} - | -- {% if u.email %}{{ u.email }}{% else %}–{% endif %} - | -- {% if u.is_admin %}Admin{% else %}User{% endif %} - | -- {% if u.is_active %} - - aktiv - - {% else %} - - deaktiviert - - {% endif %} - | -- {% if u.id != current_user.id %} - - {% else %} - Eigener Account - {% endif %} - | -