package nginx import ( "fmt" "os" "path/filepath" "sort" "strings" "time" "github.com/havline/havline/internal/config" "github.com/havline/havline/internal/fsutil" ) const maxCustomBackups = 10 type BackupEntry struct { Name string `json:"name"` CreatedAt time.Time `json:"created_at"` } func EnsureCustomDirs(cfg config.Config) error { for _, dir := range []string{ cfg.NginxCustomDir(), cfg.NginxRulesDir(), cfg.NginxGlobalBackupsDir(), } { if err := os.MkdirAll(dir, 0o755); err != nil { return err } } return nil } func ReadGlobalCustom(cfg config.Config) (string, error) { data, err := os.ReadFile(cfg.NginxGlobalCustomPath()) if err != nil { if os.IsNotExist(err) { return "", nil } return "", err } return string(data), nil } func ReadRuleCustom(cfg config.Config, ruleID int64) (string, error) { data, err := os.ReadFile(cfg.NginxRuleCustomPath(ruleID)) if err != nil { if os.IsNotExist(err) { return "", nil } return "", err } return string(data), nil } func ruleCustomContent(cfg config.Config, ruleID int64, opts GenerateOptions) string { if opts.RuleCustomOverrides != nil { if override, ok := opts.RuleCustomOverrides[ruleID]; ok { return override } } content, _ := ReadRuleCustom(cfg, ruleID) return content } func globalCustomPath(cfg config.Config, opts GenerateOptions) string { if opts.GlobalCustomPathOverride != "" { return opts.GlobalCustomPathOverride } return cfg.NginxGlobalCustomPath() } func globalCustomShouldInclude(cfg config.Config, opts GenerateOptions) bool { if strings.TrimSpace(opts.GlobalCustomOverride) != "" { return true } if opts.GlobalCustomPathOverride != "" { data, err := os.ReadFile(opts.GlobalCustomPathOverride) return err == nil && strings.TrimSpace(string(data)) != "" } content, _ := ReadGlobalCustom(cfg) return strings.TrimSpace(content) != "" } func BackupFile(srcPath, backupDir string) (string, error) { if err := os.MkdirAll(backupDir, 0o755); err != nil { return "", err } data, err := os.ReadFile(srcPath) if err != nil { if os.IsNotExist(err) { return "", nil } return "", err } name := time.Now().UTC().Format("20060102T150405.000Z") + ".conf" dst := filepath.Join(backupDir, name) if err := os.WriteFile(dst, data, 0o644); err != nil { return "", err } if err := pruneBackups(backupDir, maxCustomBackups); err != nil { return "", err } return name, nil } func ListBackups(backupDir string) ([]BackupEntry, error) { if err := os.MkdirAll(backupDir, 0o755); err != nil { return nil, err } entries, err := os.ReadDir(backupDir) if err != nil { return nil, err } var out []BackupEntry for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".conf") { continue } info, err := entry.Info() if err != nil { continue } out = append(out, BackupEntry{Name: entry.Name(), CreatedAt: info.ModTime().UTC()}) } sort.Slice(out, func(i, j int) bool { return out[i].Name > out[j].Name }) return out, nil } func RestoreBackup(backupDir, backupName, targetPath string) error { src := filepath.Join(backupDir, backupName) if _, err := os.Stat(src); err != nil { return fmt.Errorf("备份不存在") } if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { return err } data, err := os.ReadFile(src) if err != nil { return err } return fsutil.WriteFileAtomic(targetPath, data, 0o644) } func WriteGlobalCustom(cfg config.Config, content string) error { if err := EnsureCustomDirs(cfg); err != nil { return err } return fsutil.WriteFileAtomic(cfg.NginxGlobalCustomPath(), []byte(content), 0o644) } func WriteRuleCustom(cfg config.Config, ruleID int64, content string) error { if err := os.MkdirAll(cfg.NginxRulesDir(), 0o755); err != nil { return err } return fsutil.WriteFileAtomic(cfg.NginxRuleCustomPath(ruleID), []byte(content), 0o644) } func RemoveRuleCustom(cfg config.Config, ruleID int64) error { path := cfg.NginxRuleCustomPath(ruleID) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return err } backupDir := cfg.NginxRuleBackupsDir(ruleID) _ = os.RemoveAll(backupDir) return nil } func SaveGlobalCustomWithBackup(cfg config.Config, content string) (string, error) { path := cfg.NginxGlobalCustomPath() var backupName string if _, err := os.Stat(path); err == nil { name, err := BackupFile(path, cfg.NginxGlobalBackupsDir()) if err != nil { return "", err } backupName = name } if err := WriteGlobalCustom(cfg, content); err != nil { return "", err } return backupName, nil } func SaveRuleCustomWithBackup(cfg config.Config, ruleID int64, content string) (string, error) { path := cfg.NginxRuleCustomPath(ruleID) var backupName string if _, err := os.Stat(path); err == nil { name, err := BackupFile(path, cfg.NginxRuleBackupsDir(ruleID)) if err != nil { return "", err } backupName = name } if err := WriteRuleCustom(cfg, ruleID, content); err != nil { return "", err } return backupName, nil } func pruneBackups(dir string, keep int) error { entries, err := ListBackups(dir) if err != nil { return err } for i := keep; i < len(entries); i++ { _ = os.Remove(filepath.Join(dir, entries[i].Name)) } return nil } func DefaultGlobalHTTPSnippet() string { return `# 全局 http 块片段(自动生成) # 上传大小限制(全局默认 50M,单条规则可在 server 块覆盖) client_max_body_size 50m; ` } func GenerateGlobalFramework(cfg config.Config) string { return fmt.Sprintf(`# 以下部分由 Havline 自动生成,请勿在此编辑 # 主进程 worker_processes auto; # 错误日志 error_log %s warn; # PID 文件 pid %s; # 事件模型 events { worker_connections 1024; } http { # MIME 类型 include %s; default_type application/octet-stream; # 访问日志格式与路径 log_format havline_access '$time_iso8601 $host $server_port $request_method $request_uri $status $request_time $remote_addr $upstream_addr $request_length $bytes_sent'; access_log %s havline_access; # 传输优化 sendfile on; keepalive_timeout 65; # WebSocket 升级映射 # map $http_upgrade $connection_upgrade { ... } # limit_req_zone / geo / real_ip 等由规则与系统设置动态生成 # 全局自定义片段插入在上述动态块之后、各 server {} 之前 } `, absNginxPath(filepath.Join(cfg.LogsDir(), "error.log")), absNginxPath(cfg.NginxPIDFile), absNginxPath(cfg.NginxMimeTypes), absNginxPath(filepath.Join(cfg.LogsDir(), "access.log"))) }