Исправления в ветке auth-feature

This commit is contained in:
2025-10-27 18:48:49 +06:00
parent e1bd79db8c
commit 611bd17ac1
71 changed files with 3936 additions and 258 deletions

269
serve/auth.go Normal file
View File

@@ -0,0 +1,269 @@
package serve
import (
"crypto/rand"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
"github.com/direct-dev-ru/linux-command-gpt/config"
"github.com/golang-jwt/jwt/v5"
)
// JWTClaims представляет claims для JWT токена
type JWTClaims struct {
Username string `json:"username"`
jwt.RegisteredClaims
}
// AuthRequest представляет запрос на аутентификацию
type AuthRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// AuthResponse представляет ответ на аутентификацию
type AuthResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
}
// JWTSecretKey генерирует или загружает секретный ключ для JWT
func getJWTSecretKey() ([]byte, error) {
// Пытаемся загрузить из переменной окружения
if secret := os.Getenv("LCG_JWT_SECRET"); secret != "" {
return []byte(secret), nil
}
// Пытаемся загрузить из файла
secretFile := fmt.Sprintf("%s/server/jwt_secret", config.AppConfig.Server.ConfigFolder)
if data, err := os.ReadFile(secretFile); err == nil {
return data, nil
}
// Генерируем новый секретный ключ
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("failed to generate JWT secret: %v", err)
}
// Создаем директорию если не существует
if err := os.MkdirAll(fmt.Sprintf("%s/server", config.AppConfig.Server.ConfigFolder), 0755); err != nil {
return nil, fmt.Errorf("failed to create config directory: %v", err)
}
// Сохраняем секретный ключ в файл
if err := os.WriteFile(secretFile, secret, 0600); err != nil {
return nil, fmt.Errorf("failed to save JWT secret: %v", err)
}
return secret, nil
}
// generateJWTToken создает JWT токен для пользователя
func generateJWTToken(username string) (string, error) {
secret, err := getJWTSecretKey()
if err != nil {
return "", err
}
// Создаем claims
claims := JWTClaims{
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), // Токен действителен 24 часа
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "lcg-server",
},
}
// Создаем токен
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}
// validateJWTToken проверяет JWT токен
func validateJWTToken(tokenString string) (*JWTClaims, error) {
secret, err := getJWTSecretKey()
if err != nil {
return nil, err
}
// Парсим токен
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
// Проверяем метод подписи
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return secret, nil
})
if err != nil {
return nil, err
}
// Проверяем валидность токена
if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}
// getTokenFromCookie извлекает JWT токен из cookies
func getTokenFromCookie(r *http.Request) (string, error) {
cookie, err := r.Cookie("auth_token")
if err != nil {
return "", err
}
return cookie.Value, nil
}
// setAuthCookie устанавливает HTTP-only cookie с JWT токеном
func setAuthCookie(w http.ResponseWriter, token string) {
cookie := &http.Cookie{
Name: "auth_token",
Domain: config.AppConfig.Server.Domain,
Value: token,
Path: config.AppConfig.Server.CookiePath,
HttpOnly: true,
Secure: config.AppConfig.Server.CookieSecure,
SameSite: http.SameSiteLaxMode, // Более мягкий режим для reverse proxy
MaxAge: config.AppConfig.Server.CookieTTLHours * 60 * 60,
}
// Добавляем домен если указан
if config.AppConfig.Server.Domain != "" {
cookie.Domain = config.AppConfig.Server.Domain
}
http.SetCookie(w, cookie)
}
// clearAuthCookie удаляет cookie с токеном
func clearAuthCookie(w http.ResponseWriter) {
cookie := &http.Cookie{
Name: "auth_token",
Value: "",
Path: config.AppConfig.Server.CookiePath,
HttpOnly: true,
Secure: config.AppConfig.Server.CookieSecure,
SameSite: http.SameSiteLaxMode,
MaxAge: -1, // Удаляем cookie
}
// Добавляем домен если указан
if config.AppConfig.Server.Domain != "" {
cookie.Domain = config.AppConfig.Server.Domain
}
http.SetCookie(w, cookie)
}
// handleLogin обрабатывает запрос на вход
func handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req AuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
apiJsonResponse(w, AuthResponse{
Success: false,
Error: "Invalid request body",
})
return
}
// Проверяем пароль
if req.Password != config.AppConfig.Server.Password {
apiJsonResponse(w, AuthResponse{
Success: false,
Error: "Неверный пароль",
})
return
}
// Генерируем JWT токен
token, err := generateJWTToken(req.Username)
if err != nil {
apiJsonResponse(w, AuthResponse{
Success: false,
Error: "Failed to generate token",
})
return
}
// Устанавливаем cookie
setAuthCookie(w, token)
apiJsonResponse(w, AuthResponse{
Success: true,
Message: "Успешная авторизация",
})
}
// handleLogout обрабатывает запрос на выход
func handleLogout(w http.ResponseWriter, r *http.Request) {
clearAuthCookie(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// handleValidateToken обрабатывает проверку валидности токена
func handleValidateToken(w http.ResponseWriter, r *http.Request) {
token, err := getTokenFromCookie(r)
if err != nil {
apiJsonResponse(w, AuthResponse{
Success: false,
Error: "Token not found",
})
return
}
_, err = validateJWTToken(token)
if err != nil {
apiJsonResponse(w, AuthResponse{
Success: false,
Error: "Invalid token",
})
return
}
apiJsonResponse(w, AuthResponse{
Success: true,
Message: "Token is valid",
})
}
// requireAuth middleware проверяет аутентификацию
func requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Проверяем, требуется ли аутентификация
if !config.AppConfig.Server.RequireAuth {
next(w, r)
return
}
// Получаем токен из cookie
token, err := getTokenFromCookie(r)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Проверяем валидность токена
_, err = validateJWTToken(token)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Токен валиден, продолжаем
next(w, r)
}
}

263
serve/csrf.go Normal file
View File

@@ -0,0 +1,263 @@
package serve
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"os"
"time"
"github.com/direct-dev-ru/linux-command-gpt/config"
)
// CSRFManager управляет CSRF токенами
type CSRFManager struct {
secretKey []byte
}
// CSRFData содержит данные для CSRF токена
type CSRFData struct {
Token string
Timestamp int64
UserID string
}
// NewCSRFManager создает новый менеджер CSRF
func NewCSRFManager() (*CSRFManager, error) {
secret, err := getCSRFSecretKey()
if err != nil {
return nil, err
}
return &CSRFManager{secretKey: secret}, nil
}
// getCSRFSecretKey получает или генерирует секретный ключ для CSRF
func getCSRFSecretKey() ([]byte, error) {
// Пытаемся загрузить из переменной окружения
if secret := os.Getenv("LCG_CSRF_SECRET"); secret != "" {
return []byte(secret), nil
}
// Пытаемся загрузить из файла
secretFile := fmt.Sprintf("%s/server/csrf_secret", config.AppConfig.Server.ConfigFolder)
if data, err := os.ReadFile(secretFile); err == nil {
return data, nil
}
// Генерируем новый секретный ключ
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("failed to generate CSRF secret: %v", err)
}
// Создаем директорию если не существует
if err := os.MkdirAll(fmt.Sprintf("%s/server", config.AppConfig.Server.ConfigFolder), 0755); err != nil {
return nil, fmt.Errorf("failed to create config directory: %v", err)
}
// Сохраняем секретный ключ в файл
if err := os.WriteFile(secretFile, secret, 0600); err != nil {
return nil, fmt.Errorf("failed to save CSRF secret: %v", err)
}
return secret, nil
}
// GenerateToken генерирует CSRF токен для пользователя
func (c *CSRFManager) GenerateToken(userID string) (string, error) {
// Создаем данные токена
data := CSRFData{
Token: generateRandomString(32),
Timestamp: time.Now().Unix(),
UserID: userID,
}
// Создаем подпись
signature := c.createSignature(data)
// Кодируем данные в base64
encodedData := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%d:%s", data.Token, data.Timestamp, data.UserID)))
return fmt.Sprintf("%s.%s", encodedData, signature), nil
}
// ValidateToken проверяет CSRF токен
func (c *CSRFManager) ValidateToken(token, userID string) bool {
// Разделяем токен на данные и подпись
parts := splitToken(token)
if len(parts) != 2 {
return false
}
encodedData, signature := parts[0], parts[1]
// Декодируем данные
dataBytes, err := base64.StdEncoding.DecodeString(encodedData)
if err != nil {
return false
}
// Парсим данные
dataParts := splitString(string(dataBytes), ":")
if len(dataParts) != 3 {
return false
}
tokenValue, timestampStr, tokenUserID := dataParts[0], dataParts[1], dataParts[2]
// Проверяем пользователя
if tokenUserID != userID {
return false
}
// Проверяем время жизни токена (24 часа)
timestamp, err := parseInt64(timestampStr)
if err != nil {
return false
}
if time.Now().Unix()-timestamp > 24*60*60 {
return false
}
// Создаем данные для проверки подписи
data := CSRFData{
Token: tokenValue,
Timestamp: timestamp,
UserID: tokenUserID,
}
// Проверяем подпись
expectedSignature := c.createSignature(data)
return signature == expectedSignature
}
// createSignature создает подпись для данных
func (c *CSRFManager) createSignature(data CSRFData) string {
message := fmt.Sprintf("%s:%d:%s", data.Token, data.Timestamp, data.UserID)
hash := sha256.Sum256(append(c.secretKey, []byte(message)...))
return hex.EncodeToString(hash[:])
}
// getTokenFromCookie извлекает CSRF токен из cookie
func GetCSRFTokenFromCookie(r *http.Request) string {
cookie, err := r.Cookie("csrf_token")
if err != nil {
return ""
}
return cookie.Value
}
// setCSRFCookie устанавливает CSRF токен в cookie
func setCSRFCookie(w http.ResponseWriter, token string) {
cookie := &http.Cookie{
Name: "csrf_token",
Value: token,
Path: config.AppConfig.Server.CookiePath,
HttpOnly: true,
Secure: config.AppConfig.Server.CookieSecure,
SameSite: http.SameSiteLaxMode, // Более мягкий режим для reverse proxy
MaxAge: 1 * 60 * 60,
}
// Добавляем домен если указан
if config.AppConfig.Server.Domain != "" {
cookie.Domain = config.AppConfig.Server.Domain
}
http.SetCookie(w, cookie)
}
// clearCSRFCookie удаляет CSRF cookie
func СlearCSRFCookie(w http.ResponseWriter) {
cookie := &http.Cookie{
Name: "csrf_token",
Value: "",
Path: config.AppConfig.Server.CookiePath,
HttpOnly: true,
Secure: config.AppConfig.Server.CookieSecure,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
}
// Добавляем домен если указан
if config.AppConfig.Server.Domain != "" {
cookie.Domain = config.AppConfig.Server.Domain
}
http.SetCookie(w, cookie)
}
// generateRandomString генерирует случайную строку
func generateRandomString(length int) string {
bytes := make([]byte, length)
rand.Read(bytes)
return base64.URLEncoding.EncodeToString(bytes)[:length]
}
// splitToken разделяет токен на части
func splitToken(token string) []string {
// Ищем последнюю точку
lastDot := -1
for i := len(token) - 1; i >= 0; i-- {
if token[i] == '.' {
lastDot = i
break
}
}
if lastDot == -1 {
return []string{token}
}
return []string{token[:lastDot], token[lastDot+1:]}
}
// splitString разделяет строку по разделителю
func splitString(s, sep string) []string {
if s == "" {
return []string{}
}
var result []string
start := 0
for i := 0; i < len(s); i++ {
if i+len(sep) <= len(s) && s[i:i+len(sep)] == sep {
result = append(result, s[start:i])
start = i + len(sep)
i += len(sep) - 1
}
}
result = append(result, s[start:])
return result
}
// parseInt64 парсит строку в int64
func parseInt64(s string) (int64, error) {
var result int64
for _, char := range s {
if char < '0' || char > '9' {
return 0, fmt.Errorf("invalid number: %s", s)
}
result = result*10 + int64(char-'0')
}
return result, nil
}
// Глобальный экземпляр CSRF менеджера
var csrfManager *CSRFManager
// InitCSRFManager инициализирует глобальный CSRF менеджер
func InitCSRFManager() error {
var err error
csrfManager, err = NewCSRFManager()
return err
}
// GetCSRFManager возвращает глобальный CSRF менеджер
func GetCSRFManager() *CSRFManager {
return csrfManager
}

View File

@@ -23,6 +23,12 @@ type ExecutePageData struct {
ResultSection template.HTML
VerboseButtons template.HTML
ActionButtons template.HTML
CSRFToken string
ProviderType string
Model string
Host string
BasePath string
AppName string
// Поля конфигурации для валидации
MaxUserMessageLength int
}
@@ -50,7 +56,7 @@ func handleExecutePage(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// Показываем форму
showExecuteForm(w)
showExecuteForm(w, r)
case http.MethodPost:
// Обрабатываем выполнение
handleExecuteRequest(w, r)
@@ -60,7 +66,25 @@ func handleExecutePage(w http.ResponseWriter, r *http.Request) {
}
// showExecuteForm показывает форму выполнения
func showExecuteForm(w http.ResponseWriter) {
func showExecuteForm(w http.ResponseWriter, r *http.Request) {
// Генерируем CSRF токен
csrfManager := GetCSRFManager()
if csrfManager == nil {
http.Error(w, "CSRF manager not initialized", http.StatusInternalServerError)
return
}
// Получаем сессионный ID
sessionID := getSessionID(r)
csrfToken, err := csrfManager.GenerateToken(sessionID)
if err != nil {
http.Error(w, "Failed to generate CSRF token", http.StatusInternalServerError)
return
}
// Устанавливаем CSRF токен в cookie
setCSRFCookie(w, csrfToken)
// Получаем системные промпты
pm := gpt.NewPromptManager(config.AppConfig.PromptFolder)
@@ -84,6 +108,12 @@ func showExecuteForm(w http.ResponseWriter) {
ResultSection: template.HTML(""),
VerboseButtons: template.HTML(""),
ActionButtons: template.HTML(""),
CSRFToken: csrfToken,
ProviderType: config.AppConfig.ProviderType,
Model: config.AppConfig.Model,
Host: config.AppConfig.Host,
BasePath: getBasePath(),
AppName: config.AppConfig.AppName,
MaxUserMessageLength: config.AppConfig.Validation.MaxUserMessageLength,
}
@@ -194,6 +224,15 @@ func handleExecuteRequest(w http.ResponseWriter, r *http.Request) {
}
}
// Генерируем CSRF токен для результата
csrfManager := GetCSRFManager()
sessionID := getSessionID(r)
csrfToken, err := csrfManager.GenerateToken(sessionID)
if err != nil {
http.Error(w, "Failed to generate CSRF token", http.StatusInternalServerError)
return
}
data := ExecutePageData{
Title: "Результат выполнения",
Header: "Результат выполнения",
@@ -202,6 +241,12 @@ func handleExecuteRequest(w http.ResponseWriter, r *http.Request) {
ResultSection: template.HTML(formatResultSection(result)),
VerboseButtons: template.HTML(formatVerboseButtons(result)),
ActionButtons: template.HTML(formatActionButtons(result)),
CSRFToken: csrfToken,
ProviderType: config.AppConfig.ProviderType,
Model: config.AppConfig.Model,
Host: config.AppConfig.Host,
BasePath: getBasePath(),
AppName: config.AppConfig.AppName,
MaxUserMessageLength: config.AppConfig.Validation.MaxUserMessageLength,
}

View File

@@ -5,6 +5,7 @@ import (
"html/template"
"net/http"
"os"
"sort"
"strconv"
"strings"
@@ -38,9 +39,13 @@ func handleHistoryPage(w http.ResponseWriter, r *http.Request) {
}
data := struct {
Entries []HistoryEntryInfo
Entries []HistoryEntryInfo
BasePath string
AppName string
}{
Entries: historyEntries,
Entries: historyEntries,
BasePath: getBasePath(),
AppName: config.AppConfig.AppName,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -54,6 +59,11 @@ func readHistoryEntries() ([]HistoryEntryInfo, error) {
return nil, err
}
// Сортируем записи по времени в убывающем порядке (новые сначала)
sort.Slice(entries, func(i, j int) bool {
return entries[i].Timestamp.After(entries[j].Timestamp)
})
var result []HistoryEntryInfo
for _, entry := range entries {
result = append(result, HistoryEntryInfo{
@@ -74,7 +84,15 @@ func handleDeleteHistoryEntry(w http.ResponseWriter, r *http.Request) {
return
}
indexStr := strings.TrimPrefix(r.URL.Path, "/history/delete/")
// Убираем BasePath из URL перед извлечением индекса
basePath := config.AppConfig.Server.BasePath
var indexStr string
if basePath != "" && basePath != "/" {
basePath = strings.TrimSuffix(basePath, "/")
indexStr = strings.TrimPrefix(r.URL.Path, basePath+"/history/delete/")
} else {
indexStr = strings.TrimPrefix(r.URL.Path, "/history/delete/")
}
index, err := strconv.Atoi(indexStr)
if err != nil {
http.Error(w, "Invalid index", http.StatusBadRequest)
@@ -110,8 +128,15 @@ func handleClearHistory(w http.ResponseWriter, r *http.Request) {
// handleHistoryView обрабатывает просмотр записи истории
func handleHistoryView(w http.ResponseWriter, r *http.Request) {
// Получаем индекс из URL
indexStr := strings.TrimPrefix(r.URL.Path, "/history/view/")
// Получаем индекс из URL, учитывая BasePath
basePath := config.AppConfig.Server.BasePath
var indexStr string
if basePath != "" && basePath != "/" {
basePath = strings.TrimSuffix(basePath, "/")
indexStr = strings.TrimPrefix(r.URL.Path, basePath+"/history/view/")
} else {
indexStr = strings.TrimPrefix(r.URL.Path, "/history/view/")
}
index, err := strconv.Atoi(indexStr)
if err != nil {
http.NotFound(w, r)
@@ -158,12 +183,14 @@ func handleHistoryView(w http.ResponseWriter, r *http.Request) {
Command string
Response string
ExplanationHTML template.HTML
BasePath string
}{
Index: index,
Timestamp: targetEntry.Timestamp.Format("02.01.2006 15:04:05"),
Command: targetEntry.Command,
Response: targetEntry.Response,
ExplanationHTML: template.HTML(explanationSection),
BasePath: getBasePath(),
}
// Парсим и выполняем шаблон

View File

@@ -21,9 +21,20 @@ type HistoryEntry struct {
// read читает записи истории из файла
func Read(historyPath string) ([]HistoryEntry, error) {
data, err := os.ReadFile(historyPath)
if err != nil || len(data) == 0 {
if err != nil {
// Если файл не существует, создаем пустой файл истории
if os.IsNotExist(err) {
emptyHistory := []HistoryEntry{}
if writeErr := Write(historyPath, emptyHistory); writeErr != nil {
return nil, fmt.Errorf("не удалось создать файл истории: %v", writeErr)
}
return emptyHistory, nil
}
return nil, err
}
if len(data) == 0 {
return []HistoryEntry{}, nil
}
var items []HistoryEntry
if err := json.Unmarshal(data, &items); err != nil {
return nil, err

103
serve/login.go Normal file
View File

@@ -0,0 +1,103 @@
package serve
import (
"crypto/sha256"
"encoding/hex"
"html/template"
"net/http"
"github.com/direct-dev-ru/linux-command-gpt/config"
"github.com/direct-dev-ru/linux-command-gpt/serve/templates"
)
// handleLoginPage обрабатывает страницу входа
func handleLoginPage(w http.ResponseWriter, r *http.Request) {
// Если пользователь уже авторизован, перенаправляем на главную
if isAuthenticated(r) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// Генерируем CSRF токен
csrfManager := GetCSRFManager()
if csrfManager == nil {
http.Error(w, "CSRF manager not initialized", http.StatusInternalServerError)
return
}
// Для неавторизованных пользователей используем сессионный ID
sessionID := getSessionID(r)
csrfToken, err := csrfManager.GenerateToken(sessionID)
if err != nil {
http.Error(w, "Failed to generate CSRF token", http.StatusInternalServerError)
return
}
// Устанавливаем CSRF токен в cookie
setCSRFCookie(w, csrfToken)
data := LoginPageData{
Title: "Авторизация - LCG",
Message: "",
Error: "",
CSRFToken: csrfToken,
}
if err := RenderLoginPage(w, data); err != nil {
http.Error(w, "Failed to render login page", http.StatusInternalServerError)
return
}
}
// isAuthenticated проверяет, авторизован ли пользователь
func isAuthenticated(r *http.Request) bool {
// Проверяем, требуется ли аутентификация
if !config.AppConfig.Server.RequireAuth {
return true
}
// Получаем токен из cookie
token, err := getTokenFromCookie(r)
if err != nil {
return false
}
// Проверяем валидность токена
_, err = validateJWTToken(token)
return err == nil
}
// LoginPageData представляет данные для страницы входа
type LoginPageData struct {
Title string
Message string
Error string
CSRFToken string
}
// RenderLoginPage рендерит страницу входа
func RenderLoginPage(w http.ResponseWriter, data LoginPageData) error {
tmpl, err := template.New("login").Parse(templates.LoginPageTemplate)
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
return tmpl.Execute(w, data)
}
// getSessionID получает или создает сессионный ID для пользователя
func getSessionID(r *http.Request) string {
// Пытаемся получить из cookie
if cookie, err := r.Cookie("session_id"); err == nil {
return cookie.Value
}
// Если нет cookie, генерируем новый ID на основе IP и User-Agent
ip := r.RemoteAddr
userAgent := r.Header.Get("User-Agent")
// Создаем простой хеш для сессии
hash := sha256.Sum256([]byte(ip + userAgent))
return hex.EncodeToString(hash[:])[:16]
}

112
serve/middleware.go Normal file
View File

@@ -0,0 +1,112 @@
package serve
import (
"net/http"
"github.com/direct-dev-ru/linux-command-gpt/config"
)
// AuthMiddleware проверяет аутентификацию для всех запросов
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Проверяем, требуется ли аутентификация
if !config.AppConfig.Server.RequireAuth {
next(w, r)
return
}
// Исключаем страницу входа и API логина из проверки
if r.URL.Path == "/login" || r.URL.Path == "/api/login" || r.URL.Path == "/api/validate-token" {
next(w, r)
return
}
// Проверяем аутентификацию
if !isAuthenticated(r) {
// Для API запросов возвращаем JSON ошибку
if isAPIRequest(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"success": false, "error": "Authentication required"}`))
return
}
// Для веб-запросов перенаправляем на страницу входа
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Пользователь аутентифицирован, продолжаем
next(w, r)
}
}
// CSRFMiddleware проверяет CSRF токены для POST/PUT/DELETE запросов
func CSRFMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Проверяем только изменяющие запросы
if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
next(w, r)
return
}
// Исключаем некоторые API endpoints
if r.URL.Path == "/api/login" || r.URL.Path == "/api/logout" {
next(w, r)
return
}
// Получаем CSRF токен из заголовка или формы
csrfToken := r.Header.Get("X-CSRF-Token")
if csrfToken == "" {
csrfToken = r.FormValue("csrf_token")
}
if csrfToken == "" {
// Для API запросов возвращаем JSON ошибку
if isAPIRequest(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"success": false, "error": "CSRF token required"}`))
return
}
// Для веб-запросов возвращаем ошибку
http.Error(w, "CSRF token required", http.StatusForbidden)
return
}
// Получаем сессионный ID
sessionID := getSessionID(r)
// Проверяем CSRF токен
csrfManager := GetCSRFManager()
if csrfManager == nil || !csrfManager.ValidateToken(csrfToken, sessionID) {
// Для API запросов возвращаем JSON ошибку
if isAPIRequest(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"success": false, "error": "Invalid CSRF token"}`))
return
}
// Для веб-запросов возвращаем ошибку
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
// CSRF токен валиден, продолжаем
next(w, r)
}
}
// isAPIRequest проверяет, является ли запрос API запросом
func isAPIRequest(r *http.Request) bool {
path := r.URL.Path
return len(path) >= 4 && path[:4] == "/api"
}
// RequireAuth обертка для requireAuth из auth.go
func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return requireAuth(next)
}

View File

@@ -90,6 +90,8 @@ func handlePromptsPage(w http.ResponseWriter, r *http.Request) {
MaxSystemPromptLength int
MaxPromptNameLength int
MaxPromptDescLength int
BasePath string
AppName string
}{
Prompts: promptsWithDefault,
VerbosePrompts: verbosePrompts,
@@ -97,6 +99,8 @@ func handlePromptsPage(w http.ResponseWriter, r *http.Request) {
MaxSystemPromptLength: config.AppConfig.Validation.MaxSystemPromptLength,
MaxPromptNameLength: config.AppConfig.Validation.MaxPromptNameLength,
MaxPromptDescLength: config.AppConfig.Validation.MaxPromptDescLength,
BasePath: getBasePath(),
AppName: config.AppConfig.AppName,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")

View File

@@ -8,18 +8,41 @@ import (
"path/filepath"
"strings"
"time"
"unicode"
"github.com/direct-dev-ru/linux-command-gpt/config"
"github.com/direct-dev-ru/linux-command-gpt/serve/templates"
"github.com/russross/blackfriday/v2"
)
// generateAbbreviation создает аббревиатуру из первых букв слов в названии приложения
func generateAbbreviation(appName string) string {
words := strings.Fields(appName)
var abbreviation strings.Builder
for _, word := range words {
if len(word) > 0 {
// Берем первую букву слова, если это буква
firstRune := []rune(word)[0]
if unicode.IsLetter(firstRune) {
abbreviation.WriteRune(unicode.ToUpper(firstRune))
}
}
}
result := abbreviation.String()
if result == "" {
return "LCG" // Fallback если не удалось сгенерировать аббревиатуру
}
return result
}
// FileInfo содержит информацию о файле
type FileInfo struct {
Name string
Size string
ModTime string
Preview string
Preview template.HTML
Content string // Полное содержимое для поиска
}
@@ -52,13 +75,19 @@ func handleResultsPage(w http.ResponseWriter, r *http.Request) {
}
data := struct {
Files []FileInfo
TotalFiles int
RecentFiles int
Files []FileInfo
TotalFiles int
RecentFiles int
BasePath string
AppName string
AppAbbreviation string
}{
Files: files,
TotalFiles: len(files),
RecentFiles: recentCount,
Files: files,
TotalFiles: len(files),
RecentFiles: recentCount,
BasePath: getBasePath(),
AppName: config.AppConfig.AppName,
AppAbbreviation: generateAbbreviation(config.AppConfig.AppName),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -83,44 +112,15 @@ func getResultFiles() ([]FileInfo, error) {
continue
}
// Читаем превью файла (первые 200 символов) и конвертируем Markdown
// Читаем превью файла (первые 200 символов) как обычный текст
preview := ""
fullContent := ""
if content, err := os.ReadFile(filepath.Join(config.AppConfig.ResultFolder, entry.Name())); err == nil {
// Сохраняем полное содержимое для поиска
fullContent = string(content)
// Конвертируем Markdown в HTML для превью
htmlContent := blackfriday.Run(content)
preview = strings.TrimSpace(string(htmlContent))
// Удаляем HTML теги для превью
preview = strings.ReplaceAll(preview, "<h1>", "")
preview = strings.ReplaceAll(preview, "</h1>", "")
preview = strings.ReplaceAll(preview, "<h2>", "")
preview = strings.ReplaceAll(preview, "</h2>", "")
preview = strings.ReplaceAll(preview, "<h3>", "")
preview = strings.ReplaceAll(preview, "</h3>", "")
preview = strings.ReplaceAll(preview, "<p>", "")
preview = strings.ReplaceAll(preview, "</p>", "")
preview = strings.ReplaceAll(preview, "<code>", "")
preview = strings.ReplaceAll(preview, "</code>", "")
preview = strings.ReplaceAll(preview, "<pre>", "")
preview = strings.ReplaceAll(preview, "</pre>", "")
preview = strings.ReplaceAll(preview, "<strong>", "")
preview = strings.ReplaceAll(preview, "</strong>", "")
preview = strings.ReplaceAll(preview, "<em>", "")
preview = strings.ReplaceAll(preview, "</em>", "")
preview = strings.ReplaceAll(preview, "<ul>", "")
preview = strings.ReplaceAll(preview, "</ul>", "")
preview = strings.ReplaceAll(preview, "<li>", "• ")
preview = strings.ReplaceAll(preview, "</li>", "")
preview = strings.ReplaceAll(preview, "<ol>", "")
preview = strings.ReplaceAll(preview, "</ol>", "")
preview = strings.ReplaceAll(preview, "<blockquote>", "")
preview = strings.ReplaceAll(preview, "</blockquote>", "")
preview = strings.ReplaceAll(preview, "<br>", "")
preview = strings.ReplaceAll(preview, "<br/>", "")
preview = strings.ReplaceAll(preview, "<br />", "")
// Берем первые 200 символов как превью
preview = string(content)
// Очищаем от лишних пробелов и переносов
preview = strings.ReplaceAll(preview, "\n", " ")
preview = strings.ReplaceAll(preview, "\r", "")
@@ -136,7 +136,7 @@ func getResultFiles() ([]FileInfo, error) {
Name: entry.Name(),
Size: formatFileSize(info.Size()),
ModTime: info.ModTime().Format("02.01.2006 15:04"),
Preview: preview,
Preview: template.HTML(preview),
Content: fullContent,
})
}

View File

@@ -5,13 +5,49 @@ import (
"fmt"
"net/http"
"os"
"strings"
"github.com/direct-dev-ru/linux-command-gpt/config"
"github.com/direct-dev-ru/linux-command-gpt/ssl"
)
// makePath создает путь с учетом BasePath
func makePath(path string) string {
basePath := config.AppConfig.Server.BasePath
if basePath == "" || basePath == "/" {
return path
}
// Убираем слэш в конце basePath если есть
basePath = strings.TrimSuffix(basePath, "/")
// Убираем слэш в начале path если есть
path = strings.TrimPrefix(path, "/")
// Если path пустой, возвращаем basePath с слэшем в конце
if path == "" {
return basePath + "/"
}
return basePath + "/" + path
}
// getBasePath возвращает BasePath для использования в шаблонах
func getBasePath() string {
basePath := config.AppConfig.Server.BasePath
if basePath == "" || basePath == "/" {
return ""
}
return strings.TrimSuffix(basePath, "/")
}
// StartResultServer запускает HTTP/HTTPS сервер для просмотра сохраненных результатов
func StartResultServer(host, port string) error {
// Инициализируем CSRF менеджер
if err := InitCSRFManager(); err != nil {
return fmt.Errorf("failed to initialize CSRF manager: %v", err)
}
addr := fmt.Sprintf("%s:%s", host, port)
// Проверяем, нужно ли использовать HTTPS
@@ -103,78 +139,116 @@ func registerHTTPSRoutes() {
registerRoutesExceptHome()
// Регистрируем главную страницу с проверкой HTTPS
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.HandleFunc(makePath("/"), func(w http.ResponseWriter, r *http.Request) {
// Проверяем, пришел ли запрос по HTTP (не HTTPS)
if r.TLS == nil {
handleHTTPSRedirect(w, r)
return
}
// Если уже HTTPS, обрабатываем как обычно
handleResultsPage(w, r)
AuthMiddleware(handleResultsPage)(w, r)
})
// Регистрируем главную страницу без слэша в конце для BasePath
basePath := config.AppConfig.Server.BasePath
if basePath != "" && basePath != "/" {
basePath = strings.TrimSuffix(basePath, "/")
http.HandleFunc(basePath, func(w http.ResponseWriter, r *http.Request) {
// Проверяем, пришел ли запрос по HTTP (не HTTPS)
if r.TLS == nil {
handleHTTPSRedirect(w, r)
return
}
// Если уже HTTPS, обрабатываем как обычно
AuthMiddleware(handleResultsPage)(w, r)
})
}
}
// registerRoutesExceptHome регистрирует все маршруты кроме главной страницы
func registerRoutesExceptHome() {
// Страница входа (без аутентификации)
http.HandleFunc(makePath("/login"), handleLoginPage)
// API для аутентификации (без аутентификации)
http.HandleFunc(makePath("/api/login"), handleLogin)
http.HandleFunc(makePath("/api/logout"), handleLogout)
http.HandleFunc(makePath("/api/validate-token"), handleValidateToken)
// Файлы
http.HandleFunc("/file/", handleFileView)
http.HandleFunc("/delete/", handleDeleteFile)
http.HandleFunc(makePath("/file/"), AuthMiddleware(handleFileView))
http.HandleFunc(makePath("/delete/"), AuthMiddleware(handleDeleteFile))
// История запросов
http.HandleFunc("/history", handleHistoryPage)
http.HandleFunc("/history/view/", handleHistoryView)
http.HandleFunc("/history/delete/", handleDeleteHistoryEntry)
http.HandleFunc("/history/clear", handleClearHistory)
http.HandleFunc(makePath("/history"), AuthMiddleware(handleHistoryPage))
http.HandleFunc(makePath("/history/view/"), AuthMiddleware(handleHistoryView))
http.HandleFunc(makePath("/history/delete/"), AuthMiddleware(handleDeleteHistoryEntry))
http.HandleFunc(makePath("/history/clear"), AuthMiddleware(handleClearHistory))
// Управление промптами
http.HandleFunc("/prompts", handlePromptsPage)
http.HandleFunc("/prompts/add", handleAddPrompt)
http.HandleFunc("/prompts/edit/", handleEditPrompt)
http.HandleFunc("/prompts/edit-verbose/", handleEditVerbosePrompt)
http.HandleFunc("/prompts/delete/", handleDeletePrompt)
http.HandleFunc("/prompts/restore/", handleRestorePrompt)
http.HandleFunc("/prompts/restore-verbose/", handleRestoreVerbosePrompt)
http.HandleFunc("/prompts/save-lang", handleSaveLang)
http.HandleFunc(makePath("/prompts"), AuthMiddleware(handlePromptsPage))
http.HandleFunc(makePath("/prompts/add"), AuthMiddleware(handleAddPrompt))
http.HandleFunc(makePath("/prompts/edit/"), AuthMiddleware(handleEditPrompt))
http.HandleFunc(makePath("/prompts/edit-verbose/"), AuthMiddleware(handleEditVerbosePrompt))
http.HandleFunc(makePath("/prompts/delete/"), AuthMiddleware(handleDeletePrompt))
http.HandleFunc(makePath("/prompts/restore/"), AuthMiddleware(handleRestorePrompt))
http.HandleFunc(makePath("/prompts/restore-verbose/"), AuthMiddleware(handleRestoreVerbosePrompt))
http.HandleFunc(makePath("/prompts/save-lang"), AuthMiddleware(handleSaveLang))
// Веб-страница для выполнения запросов
http.HandleFunc("/run", handleExecutePage)
http.HandleFunc(makePath("/run"), AuthMiddleware(CSRFMiddleware(handleExecutePage)))
// API для выполнения запросов
http.HandleFunc("/api/execute", handleExecute)
http.HandleFunc(makePath("/api/execute"), AuthMiddleware(CSRFMiddleware(handleExecute)))
// API для сохранения результатов и истории
http.HandleFunc("/api/save-result", handleSaveResult)
http.HandleFunc("/api/add-to-history", handleAddToHistory)
http.HandleFunc(makePath("/api/save-result"), AuthMiddleware(CSRFMiddleware(handleSaveResult)))
http.HandleFunc(makePath("/api/add-to-history"), AuthMiddleware(CSRFMiddleware(handleAddToHistory)))
}
// registerRoutes регистрирует все маршруты сервера
func registerRoutes() {
// Страница входа (без аутентификации)
http.HandleFunc(makePath("/login"), handleLoginPage)
// API для аутентификации (без аутентификации)
http.HandleFunc(makePath("/api/login"), handleLogin)
http.HandleFunc(makePath("/api/logout"), handleLogout)
http.HandleFunc(makePath("/api/validate-token"), handleValidateToken)
// Главная страница и файлы
http.HandleFunc("/", handleResultsPage)
http.HandleFunc("/file/", handleFileView)
http.HandleFunc("/delete/", handleDeleteFile)
http.HandleFunc(makePath("/"), AuthMiddleware(handleResultsPage))
http.HandleFunc(makePath("/file/"), AuthMiddleware(handleFileView))
http.HandleFunc(makePath("/delete/"), AuthMiddleware(handleDeleteFile))
// История запросов
http.HandleFunc("/history", handleHistoryPage)
http.HandleFunc("/history/view/", handleHistoryView)
http.HandleFunc("/history/delete/", handleDeleteHistoryEntry)
http.HandleFunc("/history/clear", handleClearHistory)
http.HandleFunc(makePath("/history"), AuthMiddleware(handleHistoryPage))
http.HandleFunc(makePath("/history/view/"), AuthMiddleware(handleHistoryView))
http.HandleFunc(makePath("/history/delete/"), AuthMiddleware(handleDeleteHistoryEntry))
http.HandleFunc(makePath("/history/clear"), AuthMiddleware(handleClearHistory))
// Управление промптами
http.HandleFunc("/prompts", handlePromptsPage)
http.HandleFunc("/prompts/add", handleAddPrompt)
http.HandleFunc("/prompts/edit/", handleEditPrompt)
http.HandleFunc("/prompts/edit-verbose/", handleEditVerbosePrompt)
http.HandleFunc("/prompts/delete/", handleDeletePrompt)
http.HandleFunc("/prompts/restore/", handleRestorePrompt)
http.HandleFunc("/prompts/restore-verbose/", handleRestoreVerbosePrompt)
http.HandleFunc("/prompts/save-lang", handleSaveLang)
http.HandleFunc(makePath("/prompts"), AuthMiddleware(handlePromptsPage))
http.HandleFunc(makePath("/prompts/add"), AuthMiddleware(handleAddPrompt))
http.HandleFunc(makePath("/prompts/edit/"), AuthMiddleware(handleEditPrompt))
http.HandleFunc(makePath("/prompts/edit-verbose/"), AuthMiddleware(handleEditVerbosePrompt))
http.HandleFunc(makePath("/prompts/delete/"), AuthMiddleware(handleDeletePrompt))
http.HandleFunc(makePath("/prompts/restore/"), AuthMiddleware(handleRestorePrompt))
http.HandleFunc(makePath("/prompts/restore-verbose/"), AuthMiddleware(handleRestoreVerbosePrompt))
http.HandleFunc(makePath("/prompts/save-lang"), AuthMiddleware(handleSaveLang))
// Веб-страница для выполнения запросов
http.HandleFunc("/run", handleExecutePage)
http.HandleFunc(makePath("/run"), AuthMiddleware(CSRFMiddleware(handleExecutePage)))
// API для выполнения запросов
http.HandleFunc("/api/execute", handleExecute)
http.HandleFunc(makePath("/api/execute"), AuthMiddleware(CSRFMiddleware(handleExecute)))
// API для сохранения результатов и истории
http.HandleFunc("/api/save-result", handleSaveResult)
http.HandleFunc("/api/add-to-history", handleAddToHistory)
http.HandleFunc(makePath("/api/save-result"), AuthMiddleware(CSRFMiddleware(handleSaveResult)))
http.HandleFunc(makePath("/api/add-to-history"), AuthMiddleware(CSRFMiddleware(handleAddToHistory)))
// Регистрируем главную страницу без слэша в конце для BasePath
basePath := config.AppConfig.Server.BasePath
if basePath != "" && basePath != "/" {
basePath = strings.TrimSuffix(basePath, "/")
http.HandleFunc(basePath, AuthMiddleware(handleResultsPage))
}
}

View File

@@ -79,6 +79,14 @@ var ExecutePageCSSTemplate = template.Must(template.New("execute_css").Parse(`
opacity: 0.9;
font-size: 1.1em;
}
.config-info {
margin: 5px 0 0 0 !important;
opacity: 0.7 !important;
font-size: 0.9em !important;
font-style: italic;
color: rgba(255, 255, 255, 0.8);
}
.content {
padding: 30px;
}

View File

@@ -8,7 +8,7 @@ var ExecutePageTemplate = template.Must(template.New("execute").Parse(`<!DOCTYPE
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - Linux Command GPT</title>
<title>{{.Title}} - {{.AppName}}</title>
<style>
{{template "execute_css" .}}
</style>
@@ -17,16 +17,19 @@ var ExecutePageTemplate = template.Must(template.New("execute").Parse(`<!DOCTYPE
<div class="container">
<div class="header">
<h1>{{.Header}}</h1>
<p>Выполнение запросов к Linux Command GPT через веб-интерфейс</p>
<p>Выполнение запросов к {{.AppName}} через веб-интерфейс</p>
<p class="config-info">({{.ProviderType}}{{.Model}}{{.Host}})</p>
</div>
<div class="content">
<div class="nav-buttons">
<a href="/" class="nav-btn">🏠 Главная</a>
<a href="/history" class="nav-btn">📝 История</a>
<a href="/prompts" class="nav-btn">⚙️ Промпты</a>
<a href="{{.BasePath}}/" class="nav-btn">🏠 Главная</a>
<a href="{{.BasePath}}/history" class="nav-btn">📝 История</a>
<a href="{{.BasePath}}/prompts" class="nav-btn">⚙️ Промпты</a>
</div>
<form method="POST" id="executeForm">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="form-section">
<div class="form-group">
<label for="system_id">🤖 Системный промпт:</label>

View File

@@ -89,6 +89,7 @@ var ExecutePageScriptsTemplate = template.Must(template.New("execute_scripts").P
function saveResult() {
const resultDataField = document.getElementById('resultData');
const prompt = document.getElementById('prompt').value;
const csrfToken = document.querySelector('input[name="csrf_token"]').value;
if (!resultDataField.value || !prompt.trim()) {
alert('Нет данных для сохранения');
@@ -104,10 +105,11 @@ var ExecutePageScriptsTemplate = template.Must(template.New("execute_scripts").P
model: resultData.model || 'Unknown'
};
fetch('/api/save-result', {
fetch('{{.BasePath}}/api/save-result', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify(requestData)
})
@@ -134,6 +136,7 @@ var ExecutePageScriptsTemplate = template.Must(template.New("execute_scripts").P
const resultDataField = document.getElementById('resultData');
const prompt = document.getElementById('prompt').value;
const systemId = document.getElementById('system_id').value;
const csrfToken = document.querySelector('input[name="csrf_token"]').value;
if (!resultDataField.value || !prompt.trim()) {
alert('Нет данных для сохранения в историю');
@@ -152,10 +155,11 @@ var ExecutePageScriptsTemplate = template.Must(template.New("execute_scripts").P
system: systemName
};
fetch('/api/add-to-history', {
fetch('{{.BasePath}}/api/add-to-history', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify(requestData)
})

View File

@@ -155,13 +155,13 @@ const HistoryPageTemplate = `
<div class="container">
<div class="header">
<h1>📝 История запросов</h1>
<p>Управление историей запросов Linux Command GPT</p>
<p>Управление историей запросов {{.AppName}}</p>
</div>
<div class="content">
<div class="nav-buttons">
<a href="/" class="nav-btn">🏠 Главная</a>
<a href="/run" class="nav-btn">🚀 Выполнение</a>
<a href="/prompts" class="nav-btn">⚙️ Промпты</a>
<a href="{{.BasePath}}/" class="nav-btn">🏠 Главная</a>
<a href="{{.BasePath}}/run" class="nav-btn">🚀 Выполнение</a>
<a href="{{.BasePath}}/prompts" class="nav-btn">⚙️ Промпты</a>
<button class="nav-btn clear-btn" onclick="clearHistory()">🗑️ Очистить всю историю</button>
</div>
@@ -197,12 +197,12 @@ const HistoryPageTemplate = `
<script>
function viewHistoryEntry(index) {
window.location.href = '/history/view/' + index;
window.location.href = '{{.BasePath}}/history/view/' + index;
}
function deleteHistoryEntry(index) {
if (confirm('Вы уверены, что хотите удалить запись #' + index + '?')) {
fetch('/history/delete/' + index, {
fetch('{{.BasePath}}/history/delete/' + index, {
method: 'DELETE'
})
.then(response => {
@@ -221,7 +221,7 @@ const HistoryPageTemplate = `
function clearHistory() {
if (confirm('Вы уверены, что хотите очистить всю историю?\\n\\nЭто действие нельзя отменить.')) {
fetch('/history/clear', {
fetch('{{.BasePath}}/history/clear', {
method: 'DELETE'
})
.then(response => {

View File

@@ -224,7 +224,7 @@ const HistoryViewTemplate = `
<div class="container">
<div class="header">
<h1>📝 Запись #{{.Index}}</h1>
<a href="/history" class="back-btn">← Назад к истории</a>
<a href="{{.BasePath}}/history" class="back-btn">← Назад к истории</a>
</div>
<div class="content">
<div class="history-meta">
@@ -249,7 +249,7 @@ const HistoryViewTemplate = `
{{.ExplanationHTML}}
<div class="actions">
<a href="/history" class="action-btn">📝 К истории</a>
<a href="{{.BasePath}}/history" class="action-btn">📝 К истории</a>
<button class="action-btn delete-btn" onclick="deleteHistoryEntry({{.Index}})">🗑️ Удалить запись</button>
</div>
</div>
@@ -258,12 +258,12 @@ const HistoryViewTemplate = `
<script>
function deleteHistoryEntry(index) {
if (confirm('Вы уверены, что хотите удалить запись #' + index + '?')) {
fetch('/history/delete/' + index, {
fetch('{{.BasePath}}/history/delete/' + index, {
method: 'DELETE'
})
.then(response => {
if (response.ok) {
window.location.href = '/history';
window.location.href = '{{.BasePath}}/history';
} else {
alert('Ошибка при удалении записи');
}

323
serve/templates/login.go Normal file
View File

@@ -0,0 +1,323 @@
package templates
// LoginPageTemplate шаблон страницы авторизации
const LoginPageTemplate = `
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(-45deg, #667eea, #764ba2, #f093fb, #f5576c, #4facfe, #00f2fe);
background-size: 400% 400%;
animation: gradientShift 15s ease infinite;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* Плавающие элементы */
.floating-elements {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 1;
}
.floating-element {
position: absolute;
opacity: 0.1;
animation: float 20s infinite linear;
}
.floating-element:nth-child(1) { left: 10%; animation-delay: 0s; animation-duration: 25s; }
.floating-element:nth-child(2) { left: 20%; animation-delay: 2s; animation-duration: 30s; }
.floating-element:nth-child(3) { left: 30%; animation-delay: 4s; animation-duration: 20s; }
.floating-element:nth-child(4) { left: 40%; animation-delay: 6s; animation-duration: 35s; }
.floating-element:nth-child(5) { left: 50%; animation-delay: 8s; animation-duration: 28s; }
.floating-element:nth-child(6) { left: 60%; animation-delay: 10s; animation-duration: 22s; }
.floating-element:nth-child(7) { left: 70%; animation-delay: 12s; animation-duration: 32s; }
.floating-element:nth-child(8) { left: 80%; animation-delay: 14s; animation-duration: 26s; }
.floating-element:nth-child(9) { left: 90%; animation-delay: 16s; animation-duration: 24s; }
@keyframes float {
0% { transform: translateY(100vh) rotate(0deg); opacity: 0; }
10% { opacity: 0.1; }
90% { opacity: 0.1; }
100% { transform: translateY(-100px) rotate(360deg); opacity: 0; }
}
.lock-icon {
font-size: 2rem;
color: rgba(255, 255, 255, 0.3);
}
.key-icon {
font-size: 1.5rem;
color: rgba(255, 255, 255, 0.3);
}
.shield-icon {
font-size: 1.8rem;
color: rgba(255, 255, 255, 0.3);
}
.star-icon {
font-size: 1.2rem;
color: rgba(255, 255, 255, 0.3);
}
.login-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
padding: 2rem;
border-radius: 20px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 400px;
position: relative;
z-index: 10;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header h1 {
color: #333;
font-size: 1.8rem;
margin-bottom: 0.5rem;
}
.login-header p {
color: #666;
font-size: 0.9rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #333;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 2px solid #e1e5e9;
border-radius: 10px;
font-size: 1rem;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.9);
}
.form-group input:focus {
outline: none;
border-color: #667eea;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.2);
background: rgba(255, 255, 255, 1);
}
.login-button {
width: 100%;
padding: 0.75rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 10px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.login-button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.login-button:hover {
transform: translateY(-3px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.4);
}
.login-button:hover::before {
left: 100%;
}
.login-button:active {
transform: translateY(-1px);
}
.message {
margin-top: 1rem;
padding: 0.75rem;
border-radius: 5px;
text-align: center;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.loading {
display: none;
text-align: center;
margin-top: 1rem;
}
.spinner {
border: 2px solid #f3f3f3;
border-top: 2px solid #667eea;
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<!-- Плавающие элементы фона -->
<div class="floating-elements">
<div class="floating-element lock-icon">🔒</div>
<div class="floating-element key-icon">🔑</div>
<div class="floating-element shield-icon">🛡️</div>
<div class="floating-element star-icon">⭐</div>
<div class="floating-element lock-icon">🔐</div>
<div class="floating-element key-icon">🗝️</div>
<div class="floating-element shield-icon">🔒</div>
<div class="floating-element star-icon">✨</div>
<div class="floating-element lock-icon">🔒</div>
</div>
<div class="login-container">
<div class="login-header">
<h1>🔐 Авторизация</h1>
<p>Войдите в систему для доступа к LCG</p>
</div>
<form id="loginForm">
<input type="hidden" id="csrf_token" name="csrf_token" value="{{.CSRFToken}}">
<div class="form-group">
<label for="username">Имя пользователя:</label>
<input type="text" id="username" name="username" required placeholder="Введите имя пользователя">
</div>
<div class="form-group">
<label for="password">Пароль:</label>
<input type="password" id="password" name="password" required placeholder="Введите пароль">
</div>
<button type="submit" class="login-button">Войти</button>
</form>
<div class="loading" id="loading">
<div class="spinner"></div>
<p>Проверка авторизации...</p>
</div>
<div id="message"></div>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async function(e) {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const username = formData.get('username');
const password = formData.get('password');
// Показываем загрузку
document.getElementById('loading').style.display = 'block';
document.getElementById('message').innerHTML = '';
try {
const csrfToken = document.getElementById('csrf_token').value;
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({
username: username,
password: password,
csrf_token: csrfToken
})
});
const data = await response.json();
if (data.success) {
// Успешная авторизация, перенаправляем на главную страницу
window.location.href = '/';
} else {
// Ошибка авторизации
showMessage(data.error || 'Ошибка авторизации', 'error');
}
} catch (error) {
showMessage('Ошибка соединения с сервером', 'error');
} finally {
document.getElementById('loading').style.display = 'none';
}
});
function showMessage(text, type) {
const messageDiv = document.getElementById('message');
messageDiv.innerHTML = '<div class="message ' + type + '">' + text + '</div>';
}
</script>
</body>
</html>`

View File

@@ -235,13 +235,13 @@ const PromptsPageTemplate = `
<div class="container">
<div class="header">
<h1>⚙️ Системные промпты</h1>
<p>Управление системными промптами Linux Command GPT</p>
<p>Управление системными промптами {{.AppName}}</p>
</div>
<div class="content">
<div class="nav-buttons">
<a href="/" class="nav-btn">🏠 Главная</a>
<a href="/run" class="nav-btn">🚀 Выполнение</a>
<a href="/history" class="nav-btn">📝 История</a>
<a href="{{.BasePath}}/" class="nav-btn">🏠 Главная</a>
<a href="{{.BasePath}}/run" class="nav-btn">🚀 Выполнение</a>
<a href="{{.BasePath}}/history" class="nav-btn">📝 История</a>
<button class="nav-btn add-btn" onclick="showAddForm()"> Добавить промпт</button>
<div class="lang-switcher">
<button class="lang-btn {{if eq .Lang "ru"}}active{{end}}" onclick="switchLang('ru')">🇷🇺 RU</button>
@@ -391,7 +391,7 @@ const PromptsPageTemplate = `
function saveCurrentPrompts(lang) {
// Отправляем запрос для сохранения текущих промптов с новым языком
fetch('/prompts/save-lang', {
fetch('{{.BasePath}}/prompts/save-lang', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -417,7 +417,7 @@ const PromptsPageTemplate = `
function deletePrompt(id) {
if (confirm('Вы уверены, что хотите удалить промпт #' + id + '?')) {
fetch('/prompts/delete/' + id, {
fetch('{{.BasePath}}/prompts/delete/' + id, {
method: 'DELETE'
})
.then(response => {
@@ -467,10 +467,10 @@ const PromptsPageTemplate = `
let url, method;
if (isVerbosePrompt) {
url = '/prompts/edit-verbose/' + id;
url = '{{.BasePath}}/prompts/edit-verbose/' + id;
method = 'PUT';
} else {
url = id ? '/prompts/edit/' + id : '/prompts/add';
url = id ? '{{.BasePath}}/prompts/edit/' + id : '{{.BasePath}}/prompts/add';
method = id ? 'PUT' : 'POST';
}
@@ -501,7 +501,7 @@ const PromptsPageTemplate = `
// Функция восстановления системного промпта
function restorePrompt(id) {
if (confirm('Восстановить промпт к значению по умолчанию?')) {
fetch('/prompts/restore/' + id, {
fetch('{{.BasePath}}/prompts/restore/' + id, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -526,7 +526,7 @@ const PromptsPageTemplate = `
// Функция восстановления verbose промпта
function restoreVerbosePrompt(mode) {
if (confirm('Восстановить промпт к значению по умолчанию?')) {
fetch('/prompts/restore-verbose/' + mode, {
fetch('{{.BasePath}}/prompts/restore-verbose/' + mode, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -7,7 +7,7 @@ const ResultsPageTemplate = `
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LCG Results - Linux Command GPT</title>
<title>{{.AppAbbreviation}} Результаты - {{.AppName}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@@ -182,15 +182,15 @@ const ResultsPageTemplate = `
<body>
<div class="container">
<div class="header">
<h1>🚀 LCG Results</h1>
<p>Просмотр сохраненных результатов Linux Command GPT</p>
<h1>🚀 {{.AppAbbreviation}} - {{.AppName}}</h1>
<p>Просмотр сохраненных результатов {{.AppName}}</p>
</div>
<div class="content">
<div class="nav-buttons">
<button class="nav-btn" onclick="location.reload()">🔄 Обновить</button>
<a href="/run" class="nav-btn">🚀 Выполнение</a>
<a href="/history" class="nav-btn">📝 История</a>
<a href="/prompts" class="nav-btn">⚙️ Промпты</a>
<a href="{{.BasePath}}/run" class="nav-btn">🚀 Выполнение</a>
<a href="{{.BasePath}}/history" class="nav-btn">📝 История</a>
<a href="{{.BasePath}}/prompts" class="nav-btn">⚙️ Промпты</a>
</div>
<!-- Поиск -->
@@ -218,7 +218,7 @@ const ResultsPageTemplate = `
<div class="file-actions">
<button class="delete-btn" onclick="deleteFile('{{.Name}}')" title="Удалить файл">🗑️</button>
</div>
<div class="file-card-content" onclick="window.location.href='/file/{{.Name}}'">
<div class="file-card-content" onclick="window.location.href='{{$.BasePath}}/file/{{.Name}}'">
<div class="file-name">{{.Name}}</div>
<div class="file-info">
📅 {{.ModTime}} | 📏 {{.Size}}
@@ -240,7 +240,7 @@ const ResultsPageTemplate = `
<script>
function deleteFile(filename) {
if (confirm('Вы уверены, что хотите удалить файл "' + filename + '"?\\n\\nЭто действие нельзя отменить.')) {
fetch('/delete/' + encodeURIComponent(filename), {
fetch('{{.BasePath}}/delete/' + encodeURIComponent(filename), {
method: 'DELETE'
})
.then(response => {