Add password validation with i18n support and GitHub button improvements

- Add password field to UserUpdate schema for password change on edit
- Implement password validation in update_user endpoint
- Add i18n translations for password validation errors (ru/en)
- Update SecurityService.validate_password to support language parameter
- Add GitHub icon and repository name 'pvemanager' to header button
- Fix internal server error when updating user password
- Clean up hardcoded translations in frontend
This commit is contained in:
2025-12-15 20:09:50 +05:00
parent 52ec402c03
commit 2b78d93710
15 changed files with 326 additions and 22 deletions

View File

@@ -4,3 +4,9 @@ POSTGRES_PASSWORD=serverpanel_secure_password
# Timezone # Timezone
TZ=Asia/Tashkent TZ=Asia/Tashkent
TIMEZONE=Asia/Tashkent TIMEZONE=Asia/Tashkent
# Update Check Configuration (for private repositories)
# Set to true to disable update checks
DISABLE_UPDATE_CHECK=true
# GitHub token for private repositories (optional, get at: https://github.com/settings/tokens)
GITHUB_TOKEN=

61
PRIVATE_REPO_FIX.md Normal file
View File

@@ -0,0 +1,61 @@
# Решение проблемы с проверкой обновлений для приватных репозиториев
## Проблема
При работе с приватным GitHub репозиторием система выдает ошибку:
```
Failed to fetch VERSION file: HTTP 404
```
## Решение
Есть два способа решения этой проблемы:
### Способ 1: Отключить проверку обновлений (Рекомендуется)
Добавьте в файл `.env` в корне проекта:
```bash
DISABLE_UPDATE_CHECK=true
```
Затем перезапустите контейнер:
```bash
docker compose restart app
```
### Способ 2: Использовать GitHub Token
1. Создайте Personal Access Token на GitHub:
- Перейдите на https://github.com/settings/tokens
- Нажмите "Generate new token (classic)"
- Выберите scope: `repo` (Full control of private repositories)
- Скопируйте созданный токен
2. Добавьте токен в файл `.env`:
```bash
DISABLE_UPDATE_CHECK=false
GITHUB_TOKEN=your_github_personal_access_token_here
```
3. Перезапустите контейнер:
```bash
docker compose restart app
```
## Проверка
После применения изменений:
1. Откройте панель: http://localhost:8000
2. Перейдите в **Settings** → **System Updates**
3. Проверьте, что ошибка исчезла
## Дополнительная информация
- Настройки находятся в файле: `backend/app/services/update_service.py`
- Переменные окружения: `DISABLE_UPDATE_CHECK`, `GITHUB_TOKEN`
- Документация: [README.md](README.md)

View File

@@ -88,6 +88,20 @@ POSTGRES_PASSWORD=your_secure_password
TZ=Your/Timezone TZ=Your/Timezone
``` ```
### Private Repository Configuration
For private repositories, update checks need to be configured:
```bash
# Disable update checks completely
DISABLE_UPDATE_CHECK=true
# OR use GitHub token for access
GITHUB_TOKEN=your_github_personal_access_token
```
To create GitHub token: https://github.com/settings/tokens (needs 'repo' scope)
### Notifications (SMTP and Telegram) ### Notifications (SMTP and Telegram)
Email and Telegram notification settings are now managed via web interface: Email and Telegram notification settings are now managed via web interface:

View File

@@ -28,3 +28,10 @@ DEFAULT_SSH_PORT=22
# Security # Security
CORS_ORIGINS=* CORS_ORIGINS=*
ALLOWED_HOSTS=localhost,127.0.0.1 ALLOWED_HOSTS=localhost,127.0.0.1
# Update Check Configuration
# Set to true to disable update checks for private repositories
DISABLE_UPDATE_CHECK=false
# GitHub token for private repositories (optional)
# Get token at: https://github.com/settings/tokens (needs 'repo' scope)
GITHUB_TOKEN=

View File

@@ -317,7 +317,8 @@ def register_user(
) )
# Validate password # Validate password
is_valid, errors = SecurityService.validate_password(db, user_data.password) lang = request.cookies.get("language", "en")
is_valid, errors = SecurityService.validate_password(db, user_data.password, lang)
if not is_valid: if not is_valid:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -446,7 +447,8 @@ def change_password(
) )
# Validate new password # Validate new password
is_valid, errors = SecurityService.validate_password(db, new_password) lang = request.cookies.get("language", "en")
is_valid, errors = SecurityService.validate_password(db, new_password, lang)
if not is_valid: if not is_valid:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,

View File

@@ -93,6 +93,7 @@ class UserCreate(BaseModel):
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
email: Optional[EmailStr] = None email: Optional[EmailStr] = None
full_name: Optional[str] = Field(None, max_length=100) full_name: Optional[str] = Field(None, max_length=100)
password: Optional[str] = Field(None, min_length=1, max_length=100)
role_id: Optional[int] = None role_id: Optional[int] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
is_admin: Optional[bool] = None is_admin: Optional[bool] = None
@@ -383,7 +384,8 @@ async def create_user(
raise HTTPException(status_code=400, detail="Email already exists") raise HTTPException(status_code=400, detail="Email already exists")
# Validate password # Validate password
is_valid, errors = SecurityService.validate_password(db, user_data.password) lang = request.cookies.get("language", "en")
is_valid, errors = SecurityService.validate_password(db, user_data.password, lang)
if not is_valid: if not is_valid:
raise HTTPException(status_code=400, detail="; ".join(errors)) raise HTTPException(status_code=400, detail="; ".join(errors))
@@ -467,6 +469,15 @@ async def update_user(
if user_data.full_name is not None: if user_data.full_name is not None:
user.full_name = user_data.full_name user.full_name = user_data.full_name
if user_data.password is not None:
# Validate password
lang = request.cookies.get("language", "en")
is_valid, errors = SecurityService.validate_password(db, user_data.password, lang)
if not is_valid:
raise HTTPException(status_code=400, detail="; ".join(errors))
user.hashed_password = get_password_hash(user_data.password)
user.last_password_change = utcnow()
if user_data.role_id is not None: if user_data.role_id is not None:
role = db.query(Role).filter(Role.id == user_data.role_id).first() role = db.query(Role).filter(Role.id == user_data.role_id).first()
if not role: if not role:
@@ -588,7 +599,8 @@ async def reset_user_password(
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
# Validate password # Validate password
is_valid, errors = SecurityService.validate_password(db, password_data.new_password) lang = request.cookies.get("language", "en")
is_valid, errors = SecurityService.validate_password(db, password_data.new_password, lang)
if not is_valid: if not is_valid:
raise HTTPException(status_code=400, detail="; ".join(errors)) raise HTTPException(status_code=400, detail="; ".join(errors))

View File

@@ -3940,6 +3940,28 @@ class I18nService:
"en": "Require Special Characters" "en": "Require Special Characters"
}, },
# Password validation errors
"password_error_min_length": {
"ru": "Пароль должен быть не менее {min_length} символов",
"en": "Password must be at least {min_length} characters"
},
"password_error_uppercase": {
"ru": "Пароль должен содержать хотя бы одну заглавную букву",
"en": "Password must contain at least one uppercase letter"
},
"password_error_lowercase": {
"ru": "Пароль должен содержать хотя бы одну строчную букву",
"en": "Password must contain at least one lowercase letter"
},
"password_error_number": {
"ru": "Пароль должен содержать хотя бы одну цифру",
"en": "Password must contain at least one number"
},
"password_error_special": {
"ru": "Пароль должен содержать хотя бы один спецсимвол",
"en": "Password must contain at least one special character"
},
# IP Blacklist # IP Blacklist
"ip_blacklist": { "ip_blacklist": {
"ru": "Чёрный список IP", "ru": "Чёрный список IP",

View File

@@ -22,6 +22,9 @@ class UserUpdate(BaseModel):
full_name: Optional[str] = Field(None, max_length=100) full_name: Optional[str] = Field(None, max_length=100)
password: Optional[str] = Field(None, min_length=6, max_length=100) password: Optional[str] = Field(None, min_length=6, max_length=100)
is_active: Optional[bool] = None is_active: Optional[bool] = None
is_admin: Optional[bool] = None
role_id: Optional[int] = None
require_password_change: Optional[bool] = None
class UserResponse(UserBase): class UserResponse(UserBase):

View File

@@ -568,11 +568,13 @@ class SecurityService:
# ==================== Password Validation ==================== # ==================== Password Validation ====================
@classmethod @classmethod
def validate_password(cls, db: Session, password: str) -> Tuple[bool, list]: def validate_password(cls, db: Session, password: str, lang: str = "en") -> Tuple[bool, list]:
""" """
Validate password against security requirements. Validate password against security requirements.
Returns (is_valid, list_of_errors) Returns (is_valid, list_of_errors)
""" """
from ..i18n import t
errors = [] errors = []
min_length = cls.get_setting_int(db, "password_min_length", 8) min_length = cls.get_setting_int(db, "password_min_length", 8)
@@ -582,18 +584,18 @@ class SecurityService:
require_special = cls.get_setting_bool(db, "password_require_special", False) require_special = cls.get_setting_bool(db, "password_require_special", False)
if len(password) < min_length: if len(password) < min_length:
errors.append(f"Password must be at least {min_length} characters") errors.append(t("password_error_min_length", lang, min_length=min_length))
if require_upper and not any(c.isupper() for c in password): if require_upper and not any(c.isupper() for c in password):
errors.append("Password must contain at least one uppercase letter") errors.append(t("password_error_uppercase", lang))
if require_lower and not any(c.islower() for c in password): if require_lower and not any(c.islower() for c in password):
errors.append("Password must contain at least one lowercase letter") errors.append(t("password_error_lowercase", lang))
if require_numbers and not any(c.isdigit() for c in password): if require_numbers and not any(c.isdigit() for c in password):
errors.append("Password must contain at least one number") errors.append(t("password_error_number", lang))
if require_special and not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password): if require_special and not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password):
errors.append("Password must contain at least one special character") errors.append(t("password_error_special", lang))
return len(errors) == 0, errors return len(errors) == 0, errors

View File

@@ -19,6 +19,12 @@ VERSION_FILE = os.path.join(PROJECT_ROOT, "VERSION")
# Для fallback - путь внутри контейнера где может быть VERSION # Для fallback - путь внутри контейнера где может быть VERSION
CONTAINER_VERSION_FILE = "/app/VERSION" CONTAINER_VERSION_FILE = "/app/VERSION"
# GitHub token для доступа к приватным репозиториям (опционально)
GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN', None)
# Отключить проверку обновлений (для приватных репозиториев без токена)
DISABLE_UPDATE_CHECK = os.environ.get('DISABLE_UPDATE_CHECK', 'false').lower() == 'true'
# Статус обновления (in-memory) # Статус обновления (in-memory)
update_status = { update_status = {
"is_updating": False, "is_updating": False,
@@ -139,9 +145,16 @@ async def check_for_updates() -> Dict[str, Any]:
"changelog": None, "changelog": None,
"error": None, "error": None,
"git_available": is_git_available(), "git_available": is_git_available(),
"project_mounted": is_project_mounted() "project_mounted": is_project_mounted(),
"disabled": DISABLE_UPDATE_CHECK
} }
# Если проверка обновлений отключена
if DISABLE_UPDATE_CHECK:
result["error"] = "Update check is disabled"
result["latest_version"] = current_version
return result
# Получаем URL репозитория # Получаем URL репозитория
repo_url = None repo_url = None
if result["project_mounted"]: if result["project_mounted"]:
@@ -175,25 +188,45 @@ async def check_for_updates() -> Dict[str, Any]:
owner, repo = "markmorado", "pvemanager" owner, repo = "markmorado", "pvemanager"
try: try:
# Подготавливаем заголовки для запроса
headers = {}
if GITHUB_TOKEN:
headers["Authorization"] = f"token {GITHUB_TOKEN}"
# Используем GitHub API для получения файлов # Используем GitHub API для получения файлов
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
# Получить VERSION из main ветки # Пробуем оба варианта: main и master
version_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/VERSION" version_url = None
version_response = await client.get(version_url) version_response = None
if version_response.status_code == 200: for branch in ["main", "master"]:
test_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/VERSION"
test_response = await client.get(test_url, headers=headers)
if test_response.status_code == 200:
version_url = test_url
version_response = test_response
break
if version_response and version_response.status_code == 200:
result["latest_version"] = version_response.text.strip() result["latest_version"] = version_response.text.strip()
elif version_response and version_response.status_code == 404:
result["error"] = "Repository is private or not accessible. Set DISABLE_UPDATE_CHECK=true to hide this error."
result["latest_version"] = current_version
return result
else: else:
result["error"] = f"Failed to fetch VERSION file: HTTP {version_response.status_code}" result["error"] = f"Failed to fetch VERSION file: HTTP {version_response.status_code if version_response else 'unknown'}"
result["latest_version"] = current_version
return result return result
# Сравнить версии # Сравнить версии
if result["latest_version"] != current_version: if result["latest_version"] != current_version:
result["update_available"] = True result["update_available"] = True
# Получить CHANGELOG.md # Получить CHANGELOG.md (используем ту же ветку, что и для VERSION)
changelog_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/CHANGELOG.md" branch = version_url.split("/")[-2] if version_url else "main"
changelog_response = await client.get(changelog_url) changelog_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/CHANGELOG.md"
changelog_response = await client.get(changelog_url, headers=headers)
if changelog_response.status_code == 200: if changelog_response.status_code == 200:
# Извлекаем только последнюю версию из changelog # Извлекаем только последнюю версию из changelog

View File

@@ -549,6 +549,55 @@ header h2 {
display: block; display: block;
} }
/* GitHub Button */
.github-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 16px;
border-radius: var(--radius-md);
border: 1px solid var(--border-default);
background: var(--bg-surface);
color: var(--text-primary);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: all var(--transition-fast);
white-space: nowrap;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.github-btn:hover {
background: var(--bg-surface-elevated);
color: var(--text-primary);
border-color: var(--text-muted);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.github-star-icon {
flex-shrink: 0;
opacity: 0.8;
}
.github-btn:hover .github-star-icon {
opacity: 1;
color: #fbbf24;
}
.github-text {
font-weight: 500;
}
.github-stars {
font-variant-numeric: tabular-nums;
min-width: 30px;
text-align: center;
font-weight: 600;
padding-left: 8px;
border-left: 1px solid var(--border-default);
}
/* ============================================ /* ============================================
Main Content Main Content
============================================ */ ============================================ */
@@ -1625,6 +1674,21 @@ table tr:hover {
width: 36px; width: 36px;
height: 36px; height: 36px;
} }
/* Compact GitHub button on very small screens */
.github-btn {
padding: 7px 10px;
gap: 6px;
}
.github-text {
display: none;
}
.github-stars {
font-size: 0.75rem;
padding-left: 6px;
}
} }
/* ============================================ /* ============================================

View File

@@ -32,6 +32,15 @@ const translations = {
language_changed: "Язык изменён. Страница будет перезагружена...", language_changed: "Язык изменён. Страница будет перезагружена...",
error_occurred: "Произошла ошибка", error_occurred: "Произошла ошибка",
confirm_delete: "Вы уверены, что хотите удалить?", confirm_delete: "Вы уверены, что хотите удалить?",
// Updates
update_check_disabled: "Проверка обновлений отключена",
checking_updates: "Проверка обновлений...",
check_updates: "Проверить обновления",
update_available: "Доступно обновление",
no_updates: "Установлена последняя версия",
update_warning: "Панель будет недоступна во время обновления",
updating_system: "Обновление системы...",
}, },
en: { en: {
@@ -62,6 +71,15 @@ const translations = {
language_changed: "Language changed. Page will reload...", language_changed: "Language changed. Page will reload...",
error_occurred: "An error occurred", error_occurred: "An error occurred",
confirm_delete: "Are you sure you want to delete?", confirm_delete: "Are you sure you want to delete?",
// Updates
update_check_disabled: "Update check is disabled",
checking_updates: "Checking for updates...",
check_updates: "Check for Updates",
update_available: "Update available",
no_updates: "You have the latest version",
update_warning: "Panel will be unavailable during update",
updating_system: "Updating system...",
} }
}; };

View File

@@ -610,6 +610,16 @@ body.update-in-progress .layout {
</button> </button>
<h2>{{ page_title or title or t('nav_dashboard') }}</h2> <h2>{{ page_title or title or t('nav_dashboard') }}</h2>
<div class="header-actions"> <div class="header-actions">
<!-- GitHub Stars Button -->
<a href="https://github.com/markmorado/pvemanager" target="_blank" rel="noopener noreferrer" class="github-btn" title="Star us on GitHub">
<i class="fa-brands fa-github"></i>
<span class="github-text">pvemanager</span>
<svg class="github-star-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279-7.416-3.967-7.417 3.967 1.481-8.279-6.064-5.828 8.332-1.151z"/>
</svg>
<span class="github-stars" id="githubStars">...</span>
</a>
<!-- Theme Toggle Button --> <!-- Theme Toggle Button -->
<button id="themeToggle" class="theme-toggle" title="{{ t('theme') }}"> <button id="themeToggle" class="theme-toggle" title="{{ t('theme') }}">
<svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -851,6 +861,36 @@ body.update-in-progress .layout {
} }
}); });
// Load GitHub stars count
async function loadGitHubStars() {
try {
const response = await fetch('https://api.github.com/repos/markmorado/pvemanager', {
headers: {
'Accept': 'application/vnd.github.v3+json'
}
});
if (response.ok) {
const data = await response.json();
const starsElement = document.getElementById('githubStars');
if (starsElement) {
// Format number with K for thousands
const stars = data.stargazers_count;
const formatted = stars >= 1000
? (stars / 1000).toFixed(1) + 'K'
: stars.toString();
starsElement.textContent = formatted;
}
}
} catch (error) {
console.error('Failed to load GitHub stars:', error);
const starsElement = document.getElementById('githubStars');
if (starsElement) {
starsElement.textContent = '★';
}
}
}
// Load unread notification count // Load unread notification count
async function loadNotificationCount() { async function loadNotificationCount() {
try { try {
@@ -1268,6 +1308,9 @@ body.update-in-progress .layout {
// Refresh count every 30 seconds // Refresh count every 30 seconds
setInterval(loadNotificationCount, 30000); setInterval(loadNotificationCount, 30000);
// Load GitHub stars count
loadGitHubStars();
} }
// Add click handlers for sidebar menu items to update page title // Add click handlers for sidebar menu items to update page title

View File

@@ -715,6 +715,12 @@
color: var(--danger); color: var(--danger);
} }
.update-status-box.warning {
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
color: #f59e0b;
}
.update-changelog-box { .update-changelog-box {
margin-bottom: var(--spacing-lg); margin-bottom: var(--spacing-lg);
} }
@@ -1797,9 +1803,16 @@ async function checkForUpdates() {
if (data.error) { if (data.error) {
statusBox.style.display = 'block'; statusBox.style.display = 'block';
statusBox.className = 'update-status-box error'; // Если проверка отключена, показываем warning вместо ошибки
statusBox.querySelector('#update-status-message').innerHTML = if (data.disabled || data.error.includes('disabled')) {
'<i class="fas fa-exclamation-circle"></i> ' + data.error; statusBox.className = 'update-status-box warning';
statusBox.querySelector('#update-status-message').innerHTML =
'<i class="fas fa-info-circle"></i> ' + (t('update_check_disabled') || 'Проверка обновлений отключена');
} else {
statusBox.className = 'update-status-box error';
statusBox.querySelector('#update-status-message').innerHTML =
'<i class="fas fa-exclamation-circle"></i> ' + data.error;
}
updateBtn.style.display = 'none'; updateBtn.style.display = 'none';
changelogBox.style.display = 'none'; changelogBox.style.display = 'none';
} else if (data.update_available) { } else if (data.update_available) {

View File

@@ -63,6 +63,10 @@ services:
- PYTHONPATH=/app - PYTHONPATH=/app
- TZ=${TZ:-Asia/Tashkent} - TZ=${TZ:-Asia/Tashkent}
- PROJECT_ROOT=/project - PROJECT_ROOT=/project
# Отключить проверку обновлений для приватных репозиториев
- DISABLE_UPDATE_CHECK=${DISABLE_UPDATE_CHECK:-false}
# GitHub token для доступа к приватным репозиториям (опционально)
- GITHUB_TOKEN=${GITHUB_TOKEN:-}
# Оптимизация для production # Оптимизация для production
- UVICORN_WORKERS=1 - UVICORN_WORKERS=1
- UVICORN_TIMEOUT_KEEP_ALIVE=30 - UVICORN_TIMEOUT_KEEP_ALIVE=30