diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9cfff5e --- /dev/null +++ b/.env.example @@ -0,0 +1,61 @@ +# === GENERAL === +COMPANY_NAME="Aether Messenger" +MODE=PROD +LOG_LEVEL=INFO + +# === BACKEND === +BACKEND_HOST=0.0.0.0 +BACKEND_PORT=8080 +WORKERS=4 +URL=https://yourdomain.com + +# === FIRST SUPERUSER === +FIRST_SUPER_USER_EMAIL=admin@example.com +FIRST_SUPER_USER_PASS=changeme123 +FIRST_SUPER_USER_USERNAME=admin + +# === CORS === +# Добавьте свой домен +CORS_ORIGINS=["https://yourdomain.com", "http://localhost:3000"] + +# === AUTH === +SECRET_KEY=your-super-secret-key-change-me +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=15 +EMAIL_TOKEN_EXPIRE_MINUTES=60 +REFRESH_TOKEN_EXPIRE_DAYS=30 + +# === SMTP (Email) === +SMTP_SERVER=smtp.gmail.com +SMTP_PORT=587 +SMTP_EMAIL=your-email@gmail.com +SMTP_PASS=your-app-password + +# === S3 Storage === +S3_URL=https://s3.amazonaws.com +S3_ACCESS_KEY_ID=your-access-key +S3_SECRET_ACCESS_KEY=your-secret-key +S3_BUCKET_NAME=aether-bucket + +# === DATABASE (PostgreSQL) === +DB_HOST=database +DB_PORT=5432 +DB_USER=aether_user +DB_PASS=strong_password_here +DB_NAME=aether_db + +# === REDIS === +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASS= +REDIS_DB=0 + +# === RABBITMQ === +RMQ_HOST=rabbitmq +RMQ_USER=aether_rmq +RMQ_PASS=rabbitmq_password +RMQ_PORT=5672 + +# === FRONTEND === +VITE_API_URL=/api/v1 +FRONTEND_PORT=3056 diff --git a/.env.old b/.env.old new file mode 100755 index 0000000..bbf8d35 --- /dev/null +++ b/.env.old @@ -0,0 +1,53 @@ +COMPANY_NAME=AETHER + +MODE=DEV +LOG_LEVEL=DEBUG + +BACKEND_HOST=localhost +BACKEND_PORT=8080 +WORKERS=4 +FRONTEND_URL=http://localhost:5173 + +VITE_API_URL=/api/v1 +FRONTEND_PORT=3056 + +FIRST_SUPER_USER_EMAIL=admin@example.com +FIRST_SUPER_USER_PASS=admin +FIRST_SUPER_USER_USERNAME=admin + +DB_HOST=localhost +DB_PORT=5432 +DB_PASS=postgres +DB_USER=postgres +DB_NAME=Aether + +REDIS_HOST=localhost +REDIS_PORT=6379 +# REDIS_PASS= +# REDIS_DB= + +#CORS_HEADERS=["Content-Type", "Set-Cookie", "Access-Control-Allow-Headers", "Access-Control-Allow-Origin", "Authorization"] +#CORS_ORIGINS=["http://localhost:3000"] +#CORS_METHODS=["GET", "POST", "OPTIONS", "DELETE", "PATCH", "PUT"] + +CORS_HEADERS=["Content-Type", "Set-Cookie", "Access-Control-Allow-Headers", "Access-Control-Allow-Origin", "Authorization"] +CORS_ORIGINS=["http://localhost:5500", "http://localhost:5173", "http://localhost:8080", "http://127.0.0.1:8080", "null"] +CORS_METHODS=["GET", "POST", "OPTIONS", "DELETE", "PATCH", "PUT"] + +SECRET_KEY=sercretKey +ALGORITHM=HS256 + +SMTP_SERVER=localhost +SMTP_PORT=1025 +SMTP_EMAIL=noreply@cityvibe.ru +SMTP_PASS=test + +RMQ_HOST=localhost +RMQ_USER=guest +RMQ_PASS=guest +RMQ_PORT=5672 + +S3_URL=http://192.168.31.190:9002 +S3_ACCESS_KEY_ID=lorsan +S3_SECRET_ACCESS_KEY=Lorser2009! +S3_BUCKET_NAME=aether \ No newline at end of file diff --git a/.gitignore b/.gitignore index 243e7f0..a6b3109 100755 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__ .env -test.py \ No newline at end of file +test.py +.env.old \ No newline at end of file diff --git a/assets/mini-logo.png b/assets/mini-logo.png new file mode 100644 index 0000000..33fbaed Binary files /dev/null and b/assets/mini-logo.png differ diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..a096942 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,23 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +*.log +.git +.gitignore +.env +.venv +venv/ +ENV/ +env/ +.pytest_cache +.coverage +htmlcov/ +.mypy_cache +.ruff_cache \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0990c64 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.13.7-bookworm + +WORKDIR /app + +RUN pip install --no-cache-dir poetry + +COPY pyproject.toml poetry.lock ./ + +RUN poetry config virtualenvs.create false && \ + poetry install --no-interaction --no-ansi --no-root + +COPY app/ ./app +COPY alembic.ini . +COPY scripts ./scripts + +RUN chmod +x /app/scripts/prestart.sh + +CMD ["python", "-m", "app.main"] \ No newline at end of file diff --git a/backend/app/auth/router.py b/backend/app/auth/router.py index 92d5eeb..857bbdf 100644 --- a/backend/app/auth/router.py +++ b/backend/app/auth/router.py @@ -98,9 +98,9 @@ async def change_password( @router.post("/password/reset") async def send_reset_password_email( - username: str + username_email: str ) -> Dict: - await UserService.send_reset_password_email(username) + await UserService.send_reset_password_email(username_email) return {"status": True, "message": "Successfully send email reset password"} @router.post("/password/reset/{token}") diff --git a/backend/app/chats/models.py b/backend/app/chats/models.py new file mode 100644 index 0000000..9cc3c19 --- /dev/null +++ b/backend/app/chats/models.py @@ -0,0 +1,13 @@ +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import ForeignKey + +from app.core.database import Base + + +class MessageModel(Base): + __tablename__ = "message" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + sender_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), index=True) + recipient_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), index=True) + content: Mapped[str] = mapped_column() \ No newline at end of file diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2868983..f64325b 100755 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -9,10 +9,14 @@ class Settings(BaseSettings): MODE: Literal["DEV", "TEST", "PROD"] LOG_LEVEL: Literal["ERROR", "WARNING", "INFO", "DEBUG"] - HOST: str - PORT: int + BACKEND_HOST: str + BACKEND_PORT: int WORKERS: int - URL: str + FRONTEND_URL: str + + FIRST_SUPER_USER_EMAIL: str + FIRST_SUPER_USER_PASS: str + FIRST_SUPER_USER_USERNAME: str CORS_ORIGINS: List[str] = ["http://localhost:5500", "http://127.0.0.1:5500", "http://localhost:8080", "http://127.0.0.1:8080", "null"] CORS_HEADERS: List[str] = ["*"] @@ -62,7 +66,7 @@ class Settings(BaseSettings): def RABBITMQ_URL(self) -> str: return f"amqp://{self.RMQ_USER}:{self.RMQ_PASS}@{self.RMQ_HOST}:{self.RMQ_PORT}//" - model_config = SettingsConfigDict(env_file=".env", extra="allow") + model_config = SettingsConfigDict(env_file="../.env", extra="allow") settings: Settings = Settings() \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 5d2db18..f3236d9 100755 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -29,6 +29,11 @@ async def lifespan(app: FastAPI): api_router = APIRouter(prefix="/api/v1") api_router.include_router(user_router) api_router.include_router(auth_router) + +@api_router.get("/health") +async def test_health(): + return {"status": True} + app = FastAPI( title=settings.COMPANY_NAME, description="## Backend messenger aether", @@ -82,24 +87,19 @@ async def log_requests(request: Request, call_next): raise -@app.get("/health") -async def test_health(): - return {"status": True} - - if __name__ == "__main__": if settings.MODE == "PROD": UVICORN_PARAMS = dict( - host=settings.HOST, - port=settings.PORT, + host=settings.BACKEND_HOST, + port=settings.BACKEND_PORT, reload=False, workers=settings.WORKERS, access_log=False ) else: UVICORN_PARAMS = dict( - host=settings.HOST, - port=settings.PORT, + host=settings.BACKEND_HOST, + port=settings.BACKEND_PORT, reload=True, access_log=False ) diff --git a/backend/app/pre_restart.py b/backend/app/pre_restart.py new file mode 100644 index 0000000..11b4e4f --- /dev/null +++ b/backend/app/pre_restart.py @@ -0,0 +1,31 @@ +import asyncio + +from app.core.database import async_session_maker +from app.core.config import settings +from app.users.schemas import UserCreateDB +from app.users.dao import UserDAO +from app.utils.hash_password import hash_password + + +async def init() -> None: + async with async_session_maker() as session: + super_user = await UserDAO.find_one_or_none(session, email=settings.FIRST_SUPER_USER_EMAIL) + + if super_user is not None: + return + + await UserDAO.add( + session, + obj_in=UserCreateDB( + email=settings.FIRST_SUPER_USER_EMAIL, + username=settings.FIRST_SUPER_USER_USERNAME, + display_name="Admin", + hashed_password=hash_password(settings.FIRST_SUPER_USER_PASS), + is_active=True, + is_verified=True, + is_superuser=True + ) + ) + await session.commit() + +asyncio.run(init()) \ No newline at end of file diff --git a/backend/app/users/router.py b/backend/app/users/router.py index 66d6c23..02a747d 100755 --- a/backend/app/users/router.py +++ b/backend/app/users/router.py @@ -7,7 +7,7 @@ from app.users.schemas import User, UserUpdate from app.users.service import UserService from app.auth.service import AuthService from app.users.models import UserModel -from app.auth.dependencies import get_current_verified_user, get_current_superuser +from app.auth.dependencies import get_current_verified_user, get_current_superuser, get_current_user router = APIRouter(prefix="/users", tags=["User"]) @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) @router.get("/me") -async def get_current_user(user: UserModel = Depends(get_current_verified_user)) -> User: +async def get_current_user(user: UserModel = Depends(get_current_user)) -> User: log.debug("Getting current user profile", extra={"user_id": str(user.id)}) return user diff --git a/backend/app/users/service.py b/backend/app/users/service.py index 4e27e77..13848f6 100644 --- a/backend/app/users/service.py +++ b/backend/app/users/service.py @@ -5,11 +5,10 @@ from typing import List from fastapi import HTTPException, status, UploadFile from sqlalchemy import or_ -from sqlalchemy.orm.sync import update from app.utils.hash_password import hash_password, verify_password from app.services.redis_service import EmailTokenStorage, ChangePasswordTokenStorage -from app.core.S3_client import s3_client +from app.utils.S3_client import s3_client from app.core.exceptions import InvalidTokenException, TokenExpiredException, UserNotFoundException from app.users.models import UserModel from app.users.dao import UserDAO @@ -66,7 +65,7 @@ class UserService: @classmethod async def send_verify_email(cls, user: UserModel): token = cls._create_uuid_token() - url = f"{settings.URL}/verify-email/{token}" + url = f"{settings.FRONTEND_URL}/verify-email/{token}" email_token_expires = timedelta(minutes=settings.EMAIL_TOKEN_EXPIRE_MINUTES) await EmailTokenStorage.save_token( @@ -180,15 +179,21 @@ class UserService: @classmethod - async def send_reset_password_email(cls, username: str): + async def send_reset_password_email(cls, username_email: str): async with async_session_maker() as session: - user = await UserDAO.find_one_or_none(session, username=username) + user = await UserDAO.find_one_or_none( + session, + or_( + UserModel.email==username_email, + UserModel.username==username_email + ) + ) if user is None: raise UserNotFoundException token = cls._create_uuid_token() - url = f"{settings.URL}/reset-password/{token}" + url = f"{settings.FRONTEND_URL}/reset-password/{token}" token_expires = timedelta(minutes=settings.EMAIL_TOKEN_EXPIRE_MINUTES) await ChangePasswordTokenStorage.save_token( diff --git a/backend/app/core/S3_client.py b/backend/app/utils/S3_client.py similarity index 100% rename from backend/app/core/S3_client.py rename to backend/app/utils/S3_client.py diff --git a/backend/scripts/prestart.sh b/backend/scripts/prestart.sh new file mode 100755 index 0000000..3a949c7 --- /dev/null +++ b/backend/scripts/prestart.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e +set -x + +alembic upgrade head + +python -m app.pre_restart \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c16539c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,121 @@ +services: + database: + image: postgres:latest + environment: + - POSTGRES_PASSWORD=${DB_PASS} + - POSTGRES_USER=${DB_USER} + - POSTGRES_DB=${DB_NAME} + ports: + - "5432:5432" + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ] + interval: 5s + timeout: 10s + retries: 5 + networks: + - aether + volumes: + - db-data:/var/lib/postgresql + restart: unless-stopped + + rabbitmq: + image: rabbitmq:3.8-management + environment: + RABBITMQ_DEFAULT_USER: "${RMQ_USER}" + RABBITMQ_DEFAULT_PASS: "${RMQ_PASS}" + ports: + - "5672:5672" + - "15672:15672" + networks: + - aether + volumes: + - rabbitmq-data:/var/lib/rabbitmq + restart: unless-stopped + + redis: + image: redis:latest + ports: + - "6379:6379" + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 5s + retries: 5 + networks: + - aether + volumes: + - redis-data:/data + restart: unless-stopped + + prestart: + build: + context: ./backend + command: bash scripts/prestart.sh + depends_on: + database: + condition: service_healthy + networks: + - aether + env_file: + - .env + + celery: + build: + context: ./backend + command: celery -A app.core.celery_app.celery_app worker -l INFO + depends_on: + database: + condition: service_healthy + prestart: + condition: service_completed_successfully + networks: + - aether + env_file: + - .env + restart: unless-stopped + + backend: + build: + context: ./backend + depends_on: + database: + condition: service_healthy + prestart: + condition: service_completed_successfully + celery: + condition: service_started + redis: + condition: service_healthy + ports: + - "3541:${BACKEND_PORT}" + healthcheck: + test: ["CMD-SHELL", "curl", "http://${BACKEND_HOST}:${BACKEND_PORT}/api/v1/health"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - aether + env_file: + - .env + restart: unless-stopped + + frontend: + build: + context: ./frontend + args: + VITE_API_URL: ${VITE_API_URL} + depends_on: + backend: + condition: service_healthy + ports: + - "${FRONTEND_PORT}:3000" + networks: + - aether + restart: unless-stopped + +volumes: + db-data: + rabbitmq-data: + redis-data: + +networks: + aether: \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..d37fee0 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,15 @@ +node_modules +dist +build +.git +.gitignore +.env.local +.env.development +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.DS_Store +coverage +.vscode +.idea \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..4337dbc --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,28 @@ +FROM node:20-alpine + +WORKDIR /app + +# Копируем файлы зависимостей +COPY package.json package-lock.json* ./ + +# Устанавливаем зависимости +RUN npm ci + +# Копируем исходники +COPY . . + +# ARG для передачи переменных окружения на этапе сборки +ARG VITE_API_URL +ENV VITE_API_URL=${VITE_API_URL} + +# Собираем production build (Vite встроит переменные в код) +RUN npm run build + +# Устанавливаем простой HTTP сервер для отдачи статики +RUN npm install -g serve + +# Порт (только документация) +EXPOSE 3000 + +# Запускаем сервер для отдачи статики +CMD ["serve", "-s", "dist", "-l", "3000"] \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 8c9853f..c18315e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + Aether — Messenger diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000..33fbaed Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dec003a..9dfc66d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,15 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { useEffect } from 'react'; import { useAuthStore } from './store/authStore'; +import { useThemeStore } from './store/themeStore'; import { authService } from './services/authService'; import AuthPage from './pages/AuthPage'; import VerifyEmailPage from './pages/VerifyEmailPage'; import ResetPasswordPage from './pages/ResetPasswordPage'; +import ForgotPasswordPage from './pages/ForgotPasswordPage'; import ChatPage from './pages/ChatPage'; import ProfilePage from './pages/ProfilePage'; +import SettingsPage from './pages/SettingsPage'; function PrivateRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((state) => state.isAuthenticated); @@ -26,6 +29,12 @@ function PrivateRoute({ children }: { children: React.ReactNode }) { function App() { const setUser = useAuthStore((state) => state.setUser); const setLoading = useAuthStore((state) => state.setLoading); + const theme = useThemeStore((state) => state.theme); + + useEffect(() => { + // Apply theme on mount + document.documentElement.setAttribute('data-theme', theme); + }, [theme]); useEffect(() => { const checkAuth = async () => { @@ -48,6 +57,7 @@ function App() { } /> } /> } /> + } /> } /> + + + + } + /> } /> diff --git a/frontend/src/assets/mini-logo.png b/frontend/src/assets/mini-logo.png new file mode 100644 index 0000000..33fbaed Binary files /dev/null and b/frontend/src/assets/mini-logo.png differ diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx index f2d4bab..38d94f5 100644 --- a/frontend/src/components/auth/LoginForm.tsx +++ b/frontend/src/components/auth/LoginForm.tsx @@ -21,7 +21,7 @@ export default function LoginForm() { setIsLoading(true); try { - const data = await authService.login({ username, password }); + await authService.login({ username, password }); const user = await authService.getCurrentUser(); setUser(user); navigate('/chat'); @@ -55,9 +55,14 @@ export default function LoginForm() { - +
navigate('/auth'), 2000); } catch (err: any) { @@ -63,6 +64,21 @@ export default function RegisterForm() { />
+
+ + setDisplayName(e.target.value)} + placeholder="Как вас называть" + className="w-full px-0 py-3 bg-transparent border-0 border-b-2 border-gray-200 font-inter text-text-main placeholder:text-text-muted/50 focus:outline-none focus:border-accent-terracotta transition-all duration-300" + required + /> +
+