This commit is contained in:
Халимов Рустам
2026-03-10 22:00:45 +03:00
parent b4b4b8e0d3
commit be0c3812fc
12 changed files with 232 additions and 46 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
dist
.git
*.log
apps/web/dist
apps/server/dist
apps/server/uploads
.vscode
.env

28
Dockerfile.server Normal file
View File

@@ -0,0 +1,28 @@
FROM node:20-alpine
WORKDIR /app
# Install dependencies at the root
COPY package*.json ./
COPY apps/server/package*.json ./apps/server/
COPY apps/web/package*.json ./apps/web/
RUN npm install
# Copy source and prisma
COPY apps/server ./apps/server
# Generate Prisma client
WORKDIR /app/apps/server
RUN npx prisma generate
# Build server
RUN npm run build
# Make start script executable
RUN chmod +x ./start.sh
EXPOSE 3001
# Run the server using the start script
CMD ["./start.sh"]

31
Dockerfile.web Normal file
View File

@@ -0,0 +1,31 @@
# Stage 1: Build React app
FROM node:20-alpine AS build
WORKDIR /app
# ROOT package.json
COPY package*.json ./
COPY apps/server/package*.json ./apps/server/
COPY apps/web/package*.json ./apps/web/
RUN npm install
# Copy source
COPY . .
# Build web frontend
WORKDIR /app/apps/web
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:alpine
# Copy built assets
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
# Custom Nginx config to proxy /api and /socket.io to server:3001
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -46,22 +46,26 @@ router.post('/register', registerLimiter, async (req, res) => {
}
if (!username || !password) {
console.log('Registration failed: missing username or password');
res.status(400).json({ error: 'Username и пароль обязательны' });
return;
}
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
console.log('Registration failed: invalid username format', username);
res.status(400).json({ error: 'Username: 3-20 символов, только латиница, цифры, _' });
return;
}
if (password.length < config.minPasswordLength) {
console.log('Registration failed: password too short');
res.status(400).json({ error: `Пароль должен быть не менее ${config.minPasswordLength} символов` });
return;
}
// Password must contain at least one letter and one digit
if (!/[a-zA-Zа-яА-Я]/.test(password) || !/\d/.test(password)) {
console.log('Registration failed: password missing letters or digits');
res.status(400).json({ error: 'Пароль должен содержать буквы и цифры' });
return;
}

15
apps/server/start.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
# Wait for DB
echo "Waiting for database..."
# Simple sleep for now, or use docker-compose healthcheck
sleep 5
# Create DB schema (push for dev, or migrate deploy for prod)
echo "Running Prisma DB push/seed..."
npx prisma db push --accept-data-loss
npx prisma db seed
# Start server
echo "Starting server..."
npm start

View File

@@ -198,6 +198,11 @@ const translations = {
createAccount: 'Создать аккаунт',
testAccounts: 'Тестовые аккаунты: evgeniy, anastasia, artem, polina',
passwordForAll: 'Пароль для всех:',
dontHaveAccount: 'Нет аккаунта?',
alreadyHaveAccount: 'Уже есть аккаунт?',
passwordRequirements: 'Минимум 8 символов, буквы и цифры',
registerNow: 'Зарегистрироваться',
loginNow: 'Войти',
// File/upload
fileTooLarge: 'Файл слишком большой (макс. 50МБ)',
dropFileHere: 'Отпустите файл здесь',
@@ -439,6 +444,11 @@ const translations = {
createAccount: 'Create account',
testAccounts: 'Test accounts: evgeniy, anastasia, artem, polina',
passwordForAll: 'Password for all:',
dontHaveAccount: "Don't have an account?",
alreadyHaveAccount: 'Already have an account?',
passwordRequirements: 'At least 8 characters, letters and numbers',
registerNow: 'Register',
loginNow: 'Login',
fileTooLarge: 'File too large (max 50MB)',
dropFileHere: 'Drop file here',
uploading: 'Uploading...',

View File

@@ -58,7 +58,8 @@ export default function AuthPage() {
className="relative z-10 w-full max-w-md mx-4"
>
<div className="glass-strong rounded-3xl p-8 shadow-2xl shadow-vortex-500/5">
{/* Логотип */}
{/* Заголовок */}
<div className="flex flex-col items-center mb-8">
<motion.div
initial={{ rotate: -180, scale: 0 }}
@@ -72,35 +73,9 @@ export default function AuthPage() {
/>
</motion.div>
<h1 className="text-2xl font-bold gradient-text mt-4">Vortex</h1>
<p className="text-zinc-500 text-sm mt-1">{t('modernMessengerShort')}</p>
</div>
{/* Переключатель Вход/Регистрация */}
<div className="flex rounded-xl bg-white/5 p-1 mb-6">
<button
onClick={() => { setIsLogin(true); setError(''); setPassword(''); }}
className={`flex-1 py-2.5 px-4 rounded-lg text-sm font-medium transition-all duration-200 flex items-center justify-center gap-2 ${
isLogin
? 'bg-gradient-to-r from-vortex-500 to-purple-600 text-white shadow-lg shadow-vortex-500/25'
: 'text-zinc-400 hover:text-zinc-200'
}`}
aria-pressed={isLogin}
>
<LogIn size={16} />
{t('login')}
</button>
<button
onClick={() => { setIsLogin(false); setError(''); setPassword(''); }}
className={`flex-1 py-2.5 px-4 rounded-lg text-sm font-medium transition-all duration-200 flex items-center justify-center gap-2 ${
!isLogin
? 'bg-gradient-to-r from-vortex-500 to-purple-600 text-white shadow-lg shadow-vortex-500/25'
: 'text-zinc-400 hover:text-zinc-200'
}`}
aria-pressed={!isLogin}
>
<UserPlus size={16} />
{t('register')}
</button>
<p className="text-zinc-500 text-sm mt-1">
{isLogin ? t('login') : t('register')}
</p>
</div>
{/* Ошибка */}
@@ -122,14 +97,14 @@ export default function AuthPage() {
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1.5">
Username {!isLogin && <span className="text-zinc-600">{t('latinOnly')}</span>}
Username {!isLogin && <span className="text-zinc-600 font-normal">{t('latinOnly')}</span>}
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
placeholder="username"
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-vortex-500/50 focus:ring-1 focus:ring-vortex-500/25 transition-all"
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-vortex-500/50 focus:ring-1 focus:ring-vortex-500/25 transition-all outline-hidden"
required
autoFocus
autoComplete="off"
@@ -143,17 +118,20 @@ export default function AuthPage() {
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="space-y-4"
>
<label className="block text-sm font-medium text-zinc-400 mb-1.5">
{t('displayNameLabel')}
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={t('displayNamePlaceholder')}
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-vortex-500/50 focus:ring-1 focus:ring-vortex-500/25 transition-all"
/>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1.5">
{t('displayNameLabel')}
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={t('displayNamePlaceholder')}
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-vortex-500/50 focus:ring-1 focus:ring-vortex-500/25 transition-all outline-hidden"
/>
</div>
</motion.div>
)}
</AnimatePresence>
@@ -166,10 +144,10 @@ export default function AuthPage() {
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('passwordPlaceholder')}
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-vortex-500/50 focus:ring-1 focus:ring-vortex-500/25 transition-all pr-12"
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-vortex-500/50 focus:ring-1 focus:ring-vortex-500/25 transition-all pr-12 outline-hidden"
required
autoComplete={isLogin ? 'current-password' : 'new-password'}
minLength={6}
minLength={8}
/>
<button
type="button"
@@ -179,6 +157,12 @@ export default function AuthPage() {
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{!isLogin && (
<p className="mt-1.5 text-xs text-zinc-500 flex items-center gap-1.5">
<div className="w-1 h-1 rounded-full bg-vortex-500" />
{t('passwordRequirements')}
</p>
)}
</div>
<AnimatePresence>
@@ -195,7 +179,7 @@ export default function AuthPage() {
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder={t('bioPlaceholder')}
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-vortex-500/50 focus:ring-1 focus:ring-vortex-500/25 transition-all"
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-vortex-500/50 focus:ring-1 focus:ring-vortex-500/25 transition-all outline-hidden"
/>
</motion.div>
)}
@@ -206,7 +190,7 @@ export default function AuthPage() {
whileTap={{ scale: 0.99 }}
disabled={isSubmitting}
type="submit"
className="w-full py-3 px-4 rounded-xl bg-gradient-to-r from-vortex-500 to-purple-600 text-white font-medium shadow-lg shadow-vortex-500/25 hover:shadow-vortex-500/40 transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full py-3.5 px-4 rounded-xl bg-gradient-to-r from-vortex-500 to-purple-600 text-white font-medium shadow-lg shadow-vortex-500/25 hover:shadow-vortex-500/40 transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-2"
>
{isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
@@ -219,6 +203,25 @@ export default function AuthPage() {
</motion.button>
</form>
{/* Футер с переключателем */}
<div className="mt-8 pt-6 border-t border-white/5 text-center">
<p className="text-zinc-500 text-sm">
{isLogin ? t('dontHaveAccount') : t('alreadyHaveAccount')}{' '}
<button
onClick={() => {
setIsLogin(!isLogin);
setError('');
setPassword('');
setDisplayName('');
setBio('');
}}
className="text-vortex-400 hover:text-vortex-300 font-medium transition-colors ml-1"
>
{isLogin ? t('registerNow') : t('loginNow')}
</button>
</p>
</div>
</div>
</motion.div>
</motion.div>

46
docker-compose.yml Normal file
View File

@@ -0,0 +1,46 @@
services:
db:
image: postgres:15-alpine
container_name: vortex-db
restart: always
environment:
POSTGRES_USER: ${POSTGRES_USER:-vortex}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-vortex_pass}
POSTGRES_DB: ${POSTGRES_DB:-vortex_db}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
server:
build:
context: .
dockerfile: Dockerfile.server
container_name: vortex-server
restart: always
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-vortex}:${POSTGRES_PASSWORD:-vortex_pass}@db:5432/${POSTGRES_DB:-vortex_db}
PORT: 3001
JWT_SECRET: ${JWT_SECRET:-your-secure-random-secret-key-here}
CORS_ORIGINS: http://localhost,http://localhost:80,http://localhost:5173
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}
depends_on:
- db
ports:
- "3001:3001"
volumes:
- ./apps/server/uploads:/app/apps/server/uploads
web:
build:
context: .
dockerfile: Dockerfile.web
container_name: vortex-web
restart: always
ports:
- "80:80"
depends_on:
- server
volumes:
postgres_data:

40
docker/nginx/default.conf Normal file
View File

@@ -0,0 +1,40 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests
location /api/ {
proxy_pass http://server:3001;
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;
}
# Proxy Socket.io
location /socket.io/ {
proxy_pass http://server:3001;
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;
}
# Static uploads
location /uploads/ {
proxy_pass http://server:3001;
proxy_set_header Host $host;
}
}