From 728d342a891b32085b516118ea214d5edfe8a91a Mon Sep 17 00:00:00 2001 From: "Wayne H. Shephard" Date: Thu, 8 Oct 2026 00:26:42 +0300 Subject: [PATCH] docker composed --- .dockerignore | 9 +++ .env.example | 1 + backend/Dockerfile | 18 +++++ backend/database.py | 9 +-- backend/main.py | 5 +- backend/requirements.txt | 2 + docker-compose.yml | 32 +++++++++ frontend/Dockerfile | 9 +++ frontend/index.html | 2 +- frontend/js/api.js | 10 ++- frontend/js/auth.js | 148 +++++++++++++++++++++++++++++++++++++++ frontend/js/config.js | 5 ++ frontend/nginx.conf | 24 +++++++ readme.md | 113 +++++++++++++++++++++++++----- 14 files changed, 360 insertions(+), 27 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 backend/Dockerfile create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/js/auth.js create mode 100644 frontend/js/config.js create mode 100644 frontend/nginx.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3d237b2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +__pycache__ +*.pyc +node_modules +.env +data/ +*.md +.vscode +.idea diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3b7dbc0 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +JWT_SECRET=my-very-long-random-string-for-production diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..06b840b --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Устанавливаем зависимости +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копируем код +COPY . . + +# Создаём директорию для БД внутри контейнера (будет смонтирована volume) +RUN mkdir -p /data + +ENV PYTHONUNBUFFERED=1 +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/database.py b/backend/database.py index 5ba59b9..2d679a2 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1,18 +1,19 @@ +import os from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, declarative_base -SQLALCHEMY_DATABASE_URL = "sqlite:///./gym.db" +# По умолчанию используем локальный файл, но в Docker мы зададим env var +DB_PATH = os.getenv("GYM_DB_PATH", "./gym.db") +SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_PATH}" engine = create_engine( SQLALCHEMY_DATABASE_URL, - connect_args={"check_same_thread": False} # только для SQLite + connect_args={"check_same_thread": False} ) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - Base = declarative_base() -# Dependency def get_db(): db = SessionLocal() try: diff --git a/backend/main.py b/backend/main.py index 9270c35..b97e9ee 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +import os from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from database import engine, Base @@ -11,7 +12,9 @@ import routers.exercises import routers.metrics import routers.sync -# Создаём таблицы +# Гарантируем, что директория существует +os.makedirs(os.path.dirname(os.getenv("GYM_DB_PATH", "./gym.db")), exist_ok=True) + Base.metadata.create_all(bind=engine) app = FastAPI( diff --git a/backend/requirements.txt b/backend/requirements.txt index 754b52e..5ddf5e3 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,3 +1,5 @@ +-i https://mirror.yandex.ru/pypi/simple/ +--trusted-host mirror.yandex.ru fastapi==0.104.1 uvicorn[standard]==0.24.0 sqlalchemy==2.0.23 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c443d22 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3.8' + +services: + backend: + build: ./backend + container_name: gym-bro-backend + dns: + - 8.8.8.8 + - 1.1.1.1 + environment: + - GYM_JWT_SECRET=${JWT_SECRET:-super-secret-key-change-in-prod} + - GYM_DB_PATH=/data/gym.db + volumes: + - ./data:/data # Монтируем локальную папку ./data в /data контейнера + restart: unless-stopped + networks: + - gym-net + + frontend: + build: ./frontend + container_name: gym-bro-frontend + ports: + - "80:80" # Или другой порт, если 80 занят + depends_on: + - backend + restart: unless-stopped + networks: + - gym-net + +networks: + gym-net: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..39c3b65 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,9 @@ +FROM nginx:alpine + +# Копируем конфиг +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Копируем статику +COPY . /usr/share/nginx/html + +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html index a89fd07..c6239c8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - Gym Tracker + Gym Bro 😈 diff --git a/frontend/js/api.js b/frontend/js/api.js index 3dc9e69..2376aaa 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -1,8 +1,12 @@ -// Базовый URL API. Если фронт и бэк на одном домене, можно использовать '' или '/api' -const API_BASE = 'http://localhost:8000'; +// frontend/js/api.js +import { apiFetch } from "./auth.js"; +import { API_BASE } from "./config.js"; // <-- Добавили импорт + +// УДАЛИТЬ эти строки из начала api.js: +// export const API_BASE = ... export async function fetchWorkoutTypes() { - const res = await fetch(`${API_BASE}/workout-types/`); + const res = await apiFetch("/workout-types/"); if (!res.ok) throw new Error('Failed to fetch workout types'); return await res.json(); } diff --git a/frontend/js/auth.js b/frontend/js/auth.js new file mode 100644 index 0000000..58b6e7d --- /dev/null +++ b/frontend/js/auth.js @@ -0,0 +1,148 @@ +// frontend/js/auth.js + +import { API_BASE } from "./config.js"; + +const TOKEN_KEY = "gym_auth_token"; +const USER_KEY = "gym_auth_user"; + +/** + * Получить сохранённый токен + */ +export function getToken() { + return localStorage.getItem(TOKEN_KEY); +} + +/** + * Получить сохранённого пользователя + */ +export function getStoredUser() { + try { + const raw = localStorage.getItem(USER_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +/** + * Сохранить сессию (токен + данные пользователя) + */ +export function setSession(token, user) { + localStorage.setItem(TOKEN_KEY, token); + localStorage.setItem(USER_KEY, JSON.stringify(user)); +} + +/** + * Очистить сессию (Logout) + */ +export function clearSession() { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); +} + +/** + * Защищённый Fetch wrapper. + * Автоматически добавляет Authorization header. + * При получении 401 очищает сессию и вызывает событие 'auth:unauthorized'. + */ +export async function apiFetch(path, options = {}) { + const token = getToken(); + + const headers = new Headers(options.headers || {}); + + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + + // Если есть тело запроса и Content-Type не задан явно — ставим JSON + if (options.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + let res; + try { + res = await fetch(`${API_BASE}${path}`, { + ...options, + headers, + }); + } catch (e) { + // Сеть недоступна или другая ошибка транспорта + // Не логаутим автоматически, просто пробрасываем ошибку + throw e; + } + + // Если сервер ответил 401 Unauthorized — токен протух или неверный + if (res.status === 401) { + clearSession(); + window.dispatchEvent(new Event("auth:unauthorized")); + } + + return res; +} + +/** + * Логин пользователя + */ +export async function login(username, password) { + const res = await fetch(`${API_BASE}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + + if (!res.ok) { + const err = await parseError(res, "Login failed"); + throw new Error(err); + } + + const data = await res.json(); + setSession(data.access_token, data.user); + return data.user; +} + +/** + * Регистрация нового пользователя + */ +export async function register(username, password, role = "personal") { + const res = await fetch(`${API_BASE}/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password, role }), + }); + + if (!res.ok) { + const err = await parseError(res, "Register failed"); + throw new Error(err); + } + + const data = await res.json(); + setSession(data.access_token, data.user); + return data.user; +} + +/** + * Проверка токена / получение текущего пользователя + */ +export async function me() { + const res = await apiFetch("/auth/me"); + if (!res.ok) return null; + return await res.json(); +} + +/** + * Хелпер для парсинга ошибок FastAPI/Pydantic + */ +async function parseError(res, fallbackMessage) { + try { + const err = await res.json(); + if (typeof err.detail === "string") return err.detail; + if (Array.isArray(err.detail)) { + return err.detail + .map((d) => `${d.loc?.join(".") || ""}: ${d.msg || ""}`.trim()) + .join("; "); + } + return JSON.stringify(err.detail || err); + } catch { + return fallbackMessage || res.statusText; + } +} diff --git a/frontend/js/config.js b/frontend/js/config.js new file mode 100644 index 0000000..627cf95 --- /dev/null +++ b/frontend/js/config.js @@ -0,0 +1,5 @@ +// frontend/js/config.js +export const API_BASE = + window.GYM_API_BASE || + localStorage.getItem("gym_api_base") || + "/api"; diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..003fb0d --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,24 @@ +server { + listen 80; + server_name localhost; + + # Статика фронтенда + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; # Для SPA роутинга (если понадобится) + } + + # Проксирование API на бэкенд + location /api/ { + proxy_pass http://backend:8000/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + + # Важно: убираем префикс /api, так как бэк ожидает пути вроде /auth/login + rewrite ^/api/(.*)$ /$1 break; + } +} diff --git a/readme.md b/readme.md index 3a96e26..3201a8a 100644 --- a/readme.md +++ b/readme.md @@ -1,21 +1,98 @@ -# gym bro +# Gym Bro 😈 + +![License](https://img.shields.io/badge/license-MIT-blue.svg) +![Python](https://img.shields.io/badge/python-3.11+-blue.svg) +![FastAPI](https://img.shields.io/badge/FastAPI-0.104-green.svg) +![Vue/JS](https://img.shields.io/badge/frontend-Vanilla_JS-yellow.svg) +![Docker](https://img.shields.io/badge/docker-ready-2496ED.svg) + +**Gym Bro** — это легковесный, self-hosted трекер тренировок с поддержкой офлайн-режима, синхронизации и режима тренера. Без лишнего веса, только мышцы. 💪 + +## Особенности + +* **Offline First:** Работает без интернета. Данные сохраняются локально (`localStorage`) и синхронизируются при появлении связи. +* **Multi-user Support:** Разделение данных между пользователями (JWT Auth). Каждый видит только свои тренировки. +* **Trainer Mode:** Режим для тренеров. Ведите несколько клиентов одновременно, присваивайте им уникальные цвета для визуального разделения. +* **Dropsets & History:** Поддержка дроп-сетов и мгновенная подсказка истории упражнений прямо во время подхода. +* **Body Metrics:** Гибкая система замеров тела (вес, объёмы, % жира) с историей изменений. +* **No Build Step for Frontend:** Чистый HTML/CSS/JS. Не нужен Webpack/Vite. Просто открой файл или залей на любой static host. +* **Single Binary Deployment:** Весь стек упакован в Docker Compose. Один командой поднимается БД, API и Веб-сервер. + +## 🛠 Технологии + +* **Backend:** Python 3.11, FastAPI, SQLAlchemy, SQLite, PyJWT, Bcrypt. +* **Frontend:** Vanilla JavaScript (ES Modules), CSS Variables, No Frameworks. +* **Infrastructure:** Docker, Nginx (as reverse proxy & static server). + +## 🚀 Быстрый старт (Docker) + +### Требования +* Docker Engine +* Docker Compose Plugin + +### Установка + +1. Клонируйте репозиторий: + ```bash + git clone https://github.com/your-username/gym-bro.git + cd gym-bro + ``` + +2. Настройте секреты (опционально, для продакшена обязательно): + ``` bash + cp .env.example .env + # Отредактируйте .env, замените JWT_SECRET на длинную случайную строку + ``` + +3. Соберите и запустите: + ```bash + docker compose up -d --build + ``` + +4. Откройте браузер: + ```text + http://localhost + ``` + +5. Зарегистрируйтесь и начинайте качаться! + +### 📂 Структура проекта ``` text -clients (ФИО, ДР, цель) - │ - │ 1:N - ├──► workouts ──────► workout_types (справочник) - │ │ - │ │ 1:N - │ ├──► workout_exercises ──► exercises ──► workout_types - │ │ │ - │ │ │ 1:N - │ │ └──► sets ──► dropset_items - │ │ - │ └──► cardio - │ - └──► body_metrics - │ - │ 1:N - └──► body_metric_values ──► metric_types (справочник) +gym-bro/ +├── backend/ # FastAPI приложение +│ ├── routers/ # API эндпоинты +│ ├── models.py # SQLAlchemy модели +│ ├── schemas.py # Pydantic схемы +│ ├── auth.py # Логика JWT/Bcrypt +│ └── Dockerfile +├── frontend/ # Статические файлы +│ ├── js/ # ES модули логики +│ ├── styles/ # CSS +│ ├── index.html # Точка входа +│ ├── nginx.conf # Конфиг прокси +│ └── Dockerfile +├── data/ # Volume для SQLite (не коммитится!) +├── docker-compose.yml +└── README.md ``` + +### 🔐 Безопасность +- Пароли хэшируются с помощью bcrypt. +- Сессии управляются через JWT токены (HttpOnly cookie опция планируется). +- Все данные изолированы по owner_id на уровне БД. +- Внимание: Для продакшена обязательно измените JWT_SECRET в .env файле. + +### 🤝 Вклад +Pull requests приветствуются! +1. Форкните проект. +2. Создайте ветку feature/amazing-feature. +3. Коммитьте изменения. +4. Пушьте в GitHub. +5. Открывайте Pull Request. + +### 📄 Лицензия +MIT License. + +---- +Made with ❤️ and heavy weights by ivan-igorevich