diff --git a/.env.example b/.env.example index 07c4979..e6ac163 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,8 @@ DATABASE_URL=postgresql://your_db_user:your_secure_password@localhost:5435/famil APP_PORT=8080 RUST_LOG=info + +DOMAIN=yourdomain.com +EMAIL=your@email.com + +ALLOWED_ORIGINS=https://yourdomain.com diff --git a/.gitignore b/.gitignore index 43c1f52..03573f6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ dist/ *.log Cargo.lock + +certbot/ +nginx/conf.d/app-ssl.conf +.env diff --git a/backend/src/lib.rs b/backend/src/lib.rs index acbb429..5770be0 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -152,14 +152,16 @@ pub async fn create_app(db: DatabaseConnection) -> Result { let swagger_ui = SwaggerUi::new("/swagger-ui") .url("/api-docs/openapi.json", ApiDoc::openapi()); + let allowed_origins = std::env::var("ALLOWED_ORIGINS") + .unwrap_or_else(|_| "http://localhost:3000,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:8080".to_string()); + + let origins: Vec = allowed_origins + .split(',') + .filter_map(|origin| origin.trim().parse::().ok()) + .collect(); + let cors = CorsLayer::new() - .allow_origin([ - "http://localhost:3000".parse::().unwrap(), - "http://localhost:5173".parse::().unwrap(), - "http://localhost:5174".parse::().unwrap(), - "http://localhost:5175".parse::().unwrap(), - "http://localhost:8080".parse::().unwrap(), - ]) + .allow_origin(origins) .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS]) .allow_headers([ axum::http::header::CONTENT_TYPE, @@ -168,9 +170,10 @@ pub async fn create_app(db: DatabaseConnection) -> Result { ]) .allow_credentials(true); - let app = api_routes - .layer(cors) - .merge(swagger_ui); + let app = Router::new() + .nest("/api", api_routes) + .merge(swagger_ui) + .layer(cors); Ok(app) } diff --git a/deploy-update.tar.gz b/deploy-update.tar.gz new file mode 100644 index 0000000..266e03e Binary files /dev/null and b/deploy-update.tar.gz differ diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9f1ada1..a3fedf6 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -21,8 +21,7 @@ services: environment: DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} RUST_LOG: ${RUST_LOG:-info} - ports: - - "8080:8080" + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:5173} depends_on: - postgres networks: @@ -31,16 +30,44 @@ services: frontend: image: ghcr.io/${OWNER:-${COMPOSE_PROJECT_NAME}}/family_budget-frontend:latest container_name: family_budget_frontend - ports: - - "80:80" depends_on: - backend networks: - app_network + nginx: + image: nginx:alpine + container_name: family_budget_nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - certbot_www:/var/www/certbot:ro + - certbot_conf:/etc/letsencrypt:ro + depends_on: + - backend + - frontend + networks: + - app_network + restart: unless-stopped + + certbot: + image: certbot/certbot + container_name: family_budget_certbot + volumes: + - certbot_www:/var/www/certbot:rw + - certbot_conf:/etc/letsencrypt:rw + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + volumes: postgres_data: driver: local + certbot_www: + driver: local + certbot_conf: + driver: local networks: app_network: diff --git a/docker-compose.yml b/docker-compose.yml index 3857c61..e8701e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ services: environment: DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} RUST_LOG: ${RUST_LOG:-info} + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:5173} ports: - "8080:8080" depends_on: diff --git a/frontend/.env b/frontend/.env index b8ca0df..14ea4ad 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1 +1 @@ -VITE_API_BASE_URL= +VITE_API_BASE_URL=/api diff --git a/frontend/.env.example b/frontend/.env.example index 99cbcd5..14ea4ad 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1 +1 @@ -VITE_API_BASE_URL=http://localhost:3000 +VITE_API_BASE_URL=/api diff --git a/frontend/Dockerfile b/frontend/Dockerfile index ed29a58..98d305d 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -7,7 +7,7 @@ RUN npm ci COPY . . -ARG VITE_API_BASE_URL=/ +ARG VITE_API_BASE_URL=/api ENV VITE_API_BASE_URL=$VITE_API_BASE_URL RUN npm run build diff --git a/frontend/index.html b/frontend/index.html index 072a57e..8862032 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - frontend + Family budget
diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg index e7b8dfb..dba30ed 100644 --- a/frontend/public/vite.svg +++ b/frontend/public/vite.svg @@ -1 +1,3 @@ - \ No newline at end of file + + 💰 + diff --git a/frontend/src/pages/FamilyView.tsx b/frontend/src/pages/FamilyView.tsx index c687f1d..5eac7ae 100644 --- a/frontend/src/pages/FamilyView.tsx +++ b/frontend/src/pages/FamilyView.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { categoryApi, expenseApi } from '../api/client'; import { useStore } from '../store/useStore'; -import type { Category } from '../types'; +import type { Category, Expense } from '../types'; import { ArrowLeft, Wallet, @@ -14,6 +14,9 @@ import { X, DollarSign, Tag, + History, + Calendar, + MessageSquare, } from 'lucide-react'; export default function FamilyView() { @@ -34,6 +37,9 @@ export default function FamilyView() { const [expenseAmount, setExpenseAmount] = useState(''); const [expenseDescription, setExpenseDescription] = useState(''); + const [showHistory, setShowHistory] = useState(null); + const [categoryExpenses, setCategoryExpenses] = useState([]); + useEffect(() => { if (!familyId) { navigate('/'); @@ -150,6 +156,27 @@ export default function FamilyView() { } }; + const handleShowHistory = async (categoryId: number) => { + if (!familyId) return; + + if (showHistory === categoryId) { + setShowHistory(null); + return; + } + + try { + const response = await expenseApi.getAllByCategory( + parseInt(familyId), + categoryId + ); + setCategoryExpenses(response.data); + setShowHistory(categoryId); + } catch (err) { + alert('Ошибка загрузки истории трат'); + console.error(err); + } + }; + if (loading) { return (
@@ -172,6 +199,21 @@ export default function FamilyView() { return Math.max(0, Math.min(100, (remaining / limit) * 100)); }; + const formatDate = (dateString: string) => { + let dateStr = dateString; + if (!dateStr.endsWith('Z') && !dateStr.includes('+')) { + dateStr = dateStr + 'Z'; + } + const date = new Date(dateStr); + return date.toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + return (
@@ -214,14 +256,14 @@ export default function FamilyView() { return (
-
-
-
- +
+
+
+
-

+

{category.name}

@@ -229,38 +271,113 @@ export default function FamilyView() { {showAddExpense !== category.id && ( )}
-
+
- Остаток: - + Остаток: + {remaining.toFixed(2)} ₽
-
+
Лимит: - {limit.toFixed(2)} ₽ + {limit.toFixed(2)} ₽
-
+
-

+

{percentage.toFixed(0)}% осталось

+
+ + + +
+ + {showHistory === category.id && ( +
+
+

+ + История трат +

+ +
+ + {categoryExpenses.length === 0 ? ( +

Нет трат

+ ) : ( +
+ {categoryExpenses.map((expense) => ( +
+
+
+
+ +
+ + {parseFloat(expense.amount.toString()).toFixed(2)} ₽ + +
+
+ + {formatDate(expense.created_at)} +
+
+ {expense.description && ( +
+ + {expense.description} +
+ )} +
+ ))} +
+ )} +
+ )} + {showAddExpense === category.id && (

@@ -364,48 +481,12 @@ export default function FamilyView() { ) : ( )} - -
- {categories.map((category) => ( -
-
-
- -
- - {category.name} - -
-
- - -
-
- ))} -

diff --git a/init-letsencrypt.sh b/init-letsencrypt.sh new file mode 100755 index 0000000..fcd886d --- /dev/null +++ b/init-letsencrypt.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +if [ -z "$DOMAIN" ]; then + echo "Error: DOMAIN environment variable is not set" + echo "Usage: DOMAIN=yourdomain.com EMAIL=your@email.com ./init-letsencrypt.sh" + exit 1 +fi + +if [ -z "$EMAIL" ]; then + echo "Error: EMAIL environment variable is not set" + echo "Usage: DOMAIN=yourdomain.com EMAIL=your@email.com ./init-letsencrypt.sh" + exit 1 +fi + +echo "### Initializing Let's Encrypt for domain: $DOMAIN" +echo "### Email: $EMAIL" + +data_path="./certbot" +rsa_key_size=4096 +staging=0 + +if [ -d "$data_path/conf/live/$DOMAIN" ]; then + read -p "Existing certificate found for $DOMAIN. Continue and replace? (y/N) " decision + if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then + exit + fi +fi + +if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then + echo "### Downloading recommended TLS parameters ..." + mkdir -p "$data_path/conf" + curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf" + curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem" + echo +fi + +echo "### Creating dummy certificate for $DOMAIN ..." +path="/etc/letsencrypt/live/$DOMAIN" +mkdir -p "$data_path/conf/live/$DOMAIN" +docker compose -f docker-compose.prod.yml run --rm --entrypoint "\ + openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\ + -keyout '$path/privkey.pem' \ + -out '$path/fullchain.pem' \ + -subj '/CN=localhost'" certbot +echo + +echo "### Starting nginx ..." +docker compose -f docker-compose.prod.yml up --force-recreate -d nginx +echo + +echo "### Deleting dummy certificate for $DOMAIN ..." +docker compose -f docker-compose.prod.yml run --rm --entrypoint "\ + rm -Rf /etc/letsencrypt/live/$DOMAIN && \ + rm -Rf /etc/letsencrypt/archive/$DOMAIN && \ + rm -Rf /etc/letsencrypt/renewal/$DOMAIN.conf" certbot +echo + +echo "### Requesting Let's Encrypt certificate for $DOMAIN ..." +domain_args="-d $DOMAIN" + +case "$staging" in + 1) staging_arg="--staging" ;; + *) staging_arg="" ;; +esac + +docker compose -f docker-compose.prod.yml run --rm --entrypoint "\ + certbot certonly --webroot -w /var/www/certbot \ + $staging_arg \ + $domain_args \ + --email $EMAIL \ + --rsa-key-size $rsa_key_size \ + --agree-tos \ + --force-renewal" certbot +echo + +echo "### Enabling SSL configuration..." +envsubst '${DOMAIN}' < nginx/conf.d/app-ssl.conf.template > nginx/conf.d/app-ssl.conf +rm nginx/conf.d/app.conf + +echo "### Reloading nginx ..." +docker compose -f docker-compose.prod.yml exec nginx nginx -s reload + +echo "### Done! Your site is now secured with HTTPS" diff --git a/nginx/conf.d/app-ssl.conf.template b/nginx/conf.d/app-ssl.conf.template new file mode 100644 index 0000000..330e16b --- /dev/null +++ b/nginx/conf.d/app-ssl.conf.template @@ -0,0 +1,81 @@ +upstream backend { + server backend:8080; +} + +upstream frontend { + server frontend:80; +} + +server { + listen 80; + server_name ${DOMAIN}; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + server_name ${DOMAIN}; + + ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options SAMEORIGIN; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + + location /api { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + location /swagger-ui { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + location /api-docs { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + proxy_pass http://frontend; + 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; + } +} diff --git a/nginx/conf.d/app.conf b/nginx/conf.d/app.conf new file mode 100644 index 0000000..49194f6 --- /dev/null +++ b/nginx/conf.d/app.conf @@ -0,0 +1,58 @@ +upstream backend { + server backend:8080; +} + +upstream frontend { + server frontend:80; +} + +server { + listen 80; + server_name _; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location /api { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + location /swagger-ui { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + location /api-docs { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + proxy_pass http://frontend; + 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; + } +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..ad5a57d --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,37 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 20M; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml text/javascript + application/json application/javascript application/xml+rss + application/rss+xml font/truetype font/opentype + application/vnd.ms-fontobject image/svg+xml; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/renew-cert.sh b/renew-cert.sh new file mode 100755 index 0000000..dd89848 --- /dev/null +++ b/renew-cert.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +echo "### Renewing SSL certificate..." +docker compose -f docker-compose.prod.yml run --rm certbot renew + +echo "### Reloading nginx..." +docker compose -f docker-compose.prod.yml exec nginx nginx -s reload + +echo "### Done!"