diff --git a/Backend/WebServer/compression.go b/Backend/WebServer/compression.go new file mode 100644 index 0000000..0f6542e --- /dev/null +++ b/Backend/WebServer/compression.go @@ -0,0 +1,109 @@ +package webserver + +import ( + "compress/gzip" + "io" + "net/http" + "strings" + "sync" +) + +var compressibleTypes = map[string]bool{ + "text/html": true, + "text/css": true, + "text/javascript": true, + "text/plain": true, + "text/xml": true, + "text/csv": true, + "application/javascript": true, + "application/json": true, + "application/xml": true, + "application/wasm": true, + "application/xhtml+xml": true, + "application/rss+xml": true, + "application/atom+xml": true, + "application/manifest+json": true, + "image/svg+xml": true, +} + +var gzipWriterPool = sync.Pool{ + New: func() interface{} { + w, _ := gzip.NewWriterLevel(io.Discard, gzip.DefaultCompression) + return w + }, +} + +type gzipResponseWriter struct { + http.ResponseWriter + gzWriter *gzip.Writer + headerWritten bool + compressed bool +} + +func (g *gzipResponseWriter) Write(data []byte) (int, error) { + if !g.headerWritten { + g.detectAndSetHeaders() + } + if g.compressed { + return g.gzWriter.Write(data) + } + return g.ResponseWriter.Write(data) +} + +func (g *gzipResponseWriter) WriteHeader(statusCode int) { + if !g.headerWritten { + g.detectAndSetHeaders() + } + g.ResponseWriter.WriteHeader(statusCode) +} + +func (g *gzipResponseWriter) detectAndSetHeaders() { + g.headerWritten = true + contentType := g.ResponseWriter.Header().Get("Content-Type") + if contentType == "" { + return + } + + mimeType := strings.SplitN(contentType, ";", 2)[0] + mimeType = strings.TrimSpace(mimeType) + + if compressibleTypes[mimeType] { + g.ResponseWriter.Header().Set("Content-Encoding", "gzip") + g.ResponseWriter.Header().Add("Vary", "Accept-Encoding") + g.ResponseWriter.Header().Del("Content-Length") + g.compressed = true + } +} + +func (g *gzipResponseWriter) Flush() { + if g.compressed { + g.gzWriter.Flush() + } + if flusher, ok := g.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +func (g *gzipResponseWriter) close() { + if g.compressed { + g.gzWriter.Close() + gzipWriterPool.Put(g.gzWriter) + } +} + +func clientAcceptsGzip(r *http.Request) bool { + return strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") +} + +func isAlreadyCompressed(header http.Header) bool { + return header.Get("Content-Encoding") != "" +} + +func newGzipResponseWriter(w http.ResponseWriter) *gzipResponseWriter { + gz := gzipWriterPool.Get().(*gzip.Writer) + gz.Reset(w) + return &gzipResponseWriter{ + ResponseWriter: w, + gzWriter: gz, + } +} diff --git a/Backend/WebServer/handler.go b/Backend/WebServer/handler.go index 368c938..95e65af 100644 --- a/Backend/WebServer/handler.go +++ b/Backend/WebServer/handler.go @@ -170,10 +170,19 @@ func isRootFileRoutingEnabled(host string) bool { return site.Root_file_routing } } - // По умолчанию роутинг выключен return false } +// Проверяет включено ли сжатие для сайта +func isSiteCompressionEnabled(host string) bool { + for _, site := range config.ConfigData.Site_www { + if site.Host == host { + return site.IsCompressionEnabled() + } + } + return true +} + // Проверка vAccess с обработкой ошибки // Возвращает true если доступ разрешён, false если заблокирован func checkVAccessAndHandle(w http.ResponseWriter, r *http.Request, filePath string, host string) bool { @@ -246,6 +255,13 @@ func handler(w http.ResponseWriter, r *http.Request) { } + // Сжатие ответа (gzip) + if isSiteCompressionEnabled(host) && clientAcceptsGzip(r) { + gzw := newGzipResponseWriter(w) + defer gzw.close() + w = gzw + } + // Проверяем существование директории сайта if _, err := os.Stat("WebServer/www/" + host + "/public_www"); err != nil { http.ServeFile(w, r, "WebServer/tools/error_page/index.html") diff --git a/Backend/WebServer/proxy_server.go b/Backend/WebServer/proxy_server.go index b09795f..667053b 100644 --- a/Backend/WebServer/proxy_server.go +++ b/Backend/WebServer/proxy_server.go @@ -306,26 +306,30 @@ func StartHandlerProxy(w http.ResponseWriter, r *http.Request) (valid bool) { } } + // Сжатие ответа (gzip) — только если бэкенд не сжал сам + var gzw *gzipResponseWriter + if proxyConfig.IsCompressionEnabled() && clientAcceptsGzip(r) && !isAlreadyCompressed(resp.Header) { + gzw = newGzipResponseWriter(w) + defer gzw.close() + w = gzw + } + // Устанавливаем статус код w.WriteHeader(resp.StatusCode) // Копируем тело ответа с поддержкой streaming (SSE, chunked responses) - // Используем буферизированное копирование с принудительной отправкой данных flusher, canFlush := w.(http.Flusher) - // Буфер для чанков (32KB - оптимальный размер для баланса производительности) buffer := make([]byte, 32*1024) for { n, err := resp.Body.Read(buffer) if n > 0 { - // Записываем прочитанные данные if _, writeErr := w.Write(buffer[:n]); writeErr != nil { log.Printf("Ошибка записи тела ответа: %v", writeErr) break } - // Принудительно отправляем данные клиенту (критично для SSE) if canFlush { flusher.Flush() } diff --git a/Backend/admin/go/proxy/types.go b/Backend/admin/go/proxy/types.go index 640f71f..e842ba8 100644 --- a/Backend/admin/go/proxy/types.go +++ b/Backend/admin/go/proxy/types.go @@ -9,6 +9,7 @@ type ProxyInfo struct { ServiceHTTPSuse bool `json:"ServiceHTTPSuse"` AutoHTTPS bool `json:"AutoHTTPS"` AutoCreateSSL bool `json:"AutoCreateSSL"` + Compression *bool `json:"Compression"` Status string `json:"Status"` } diff --git a/Backend/admin/go/sites/methods.go b/Backend/admin/go/sites/methods.go index 8b4cba1..455aaa5 100644 --- a/Backend/admin/go/sites/methods.go +++ b/Backend/admin/go/sites/methods.go @@ -156,6 +156,7 @@ func AddSiteToConfig(siteData SiteInfo) error { Status: siteData.Status, Root_file: siteData.RootFile, Root_file_routing: siteData.RootFileRouting, + Compression: siteData.Compression, } // Добавляем в массив diff --git a/Backend/admin/go/sites/sites.go b/Backend/admin/go/sites/sites.go index a6a11a6..7287b87 100644 --- a/Backend/admin/go/sites/sites.go +++ b/Backend/admin/go/sites/sites.go @@ -16,6 +16,7 @@ func GetSitesList() []SiteInfo { RootFile: site.Root_file, RootFileRouting: site.Root_file_routing, AutoCreateSSL: site.AutoCreateSSL, + Compression: site.Compression, } sites = append(sites, siteInfo) } diff --git a/Backend/admin/go/sites/types.go b/Backend/admin/go/sites/types.go index 3a84c77..dd85548 100644 --- a/Backend/admin/go/sites/types.go +++ b/Backend/admin/go/sites/types.go @@ -8,5 +8,6 @@ type SiteInfo struct { RootFile string `json:"root_file"` RootFileRouting bool `json:"root_file_routing"` AutoCreateSSL bool `json:"auto_create_ssl"` + Compression *bool `json:"Compression"` } diff --git a/Backend/config/config.go b/Backend/config/config.go index 089c28c..8e15d27 100644 --- a/Backend/config/config.go +++ b/Backend/config/config.go @@ -22,6 +22,14 @@ type Site_www struct { Root_file string `json:"root_file"` Root_file_routing bool `json:"root_file_routing"` AutoCreateSSL bool `json:"AutoCreateSSL"` + Compression *bool `json:"Compression"` +} + +func (s Site_www) IsCompressionEnabled() bool { + if s.Compression == nil { + return true + } + return *s.Compression } type Soft_Settings struct { @@ -42,6 +50,14 @@ type Proxy_Service struct { ServiceHTTPSuse bool `json:"ServiceHTTPSuse"` AutoHTTPS bool `json:"AutoHTTPS"` AutoCreateSSL bool `json:"AutoCreateSSL"` + Compression *bool `json:"Compression"` +} + +func (p Proxy_Service) IsCompressionEnabled() bool { + if p.Compression == nil { + return true + } + return *p.Compression } func LoadConfig() { @@ -88,6 +104,20 @@ func migrateConfig(originalData []byte) { } } + // Проверяем Site_www на наличие Compression + if rawSites, ok := rawConfig["Site_www"]; ok { + var sites2 []map[string]interface{} + if err := json.Unmarshal(rawSites, &sites2); err == nil { + for i, site := range sites2 { + if _, exists := site["Compression"]; !exists { + needsSave = true + compressionTrue := true + ConfigData.Site_www[i].Compression = &compressionTrue + } + } + } + } + // Проверяем Proxy_Service if rawProxies, ok := rawConfig["Proxy_Service"]; ok { var proxies []map[string]interface{} @@ -101,6 +131,20 @@ func migrateConfig(originalData []byte) { } } + // Проверяем Proxy_Service на наличие Compression + if rawProxies, ok := rawConfig["Proxy_Service"]; ok { + var proxies2 []map[string]interface{} + if err := json.Unmarshal(rawProxies, &proxies2); err == nil { + for i, proxy := range proxies2 { + if _, exists := proxy["Compression"]; !exists { + needsSave = true + compressionTrue := true + ConfigData.Proxy_Service[i].Compression = &compressionTrue + } + } + } + } + // Проверяем Soft_Settings на наличие ACME_enabled if rawSettings, ok := rawConfig["Soft_Settings"]; ok { var settings map[string]interface{} diff --git a/front_vue/src/Core/i18n/en.json b/front_vue/src/Core/i18n/en.json index ff994ad..fce52f2 100644 --- a/front_vue/src/Core/i18n/en.json +++ b/front_vue/src/Core/i18n/en.json @@ -64,6 +64,8 @@ "formStatus": "Status", "formRouting": "Root file routing", "formRoutingHint": "If enabled, all requests to non-existent files will be redirected to the root file", + "formCompression": "Gzip compression", + "formCompressionHint": "Compress text responses (HTML, CSS, JS, JSON, SVG) to reduce traffic", "deleteTitle": "Delete Site", "deleteConfirm": "Are you sure you want to delete site \"{name}\" ({host})?", "deleteWarning": "This action is IRREVERSIBLE!" @@ -88,6 +90,8 @@ "formLocalPort": "Local Port", "formServiceHttps": "HTTPS to service", "formServiceHttpsHint": "Use HTTPS when connecting to the local service", + "formCompression": "Gzip compression", + "formCompressionHint": "Compress text responses (HTML, CSS, JS, JSON, SVG) to reduce traffic", "formAutoHttps": "Auto HTTPS", "formAutoHttpsHint": "Automatically redirect HTTP requests to HTTPS", "deleteTitle": "Delete Proxy", diff --git a/front_vue/src/Core/i18n/ru.json b/front_vue/src/Core/i18n/ru.json index 3a9af22..497dde5 100644 --- a/front_vue/src/Core/i18n/ru.json +++ b/front_vue/src/Core/i18n/ru.json @@ -64,6 +64,8 @@ "formStatus": "Статус", "formRouting": "Root file routing", "formRoutingHint": "Если включено, все запросы к несуществующим файлам будут перенаправляться на root файл", + "formCompression": "Gzip сжатие", + "formCompressionHint": "Сжатие текстовых ответов (HTML, CSS, JS, JSON, SVG) для уменьшения трафика", "deleteTitle": "Удалить сайт", "deleteConfirm": "Вы действительно хотите удалить сайт \"{name}\" ({host})?", "deleteWarning": "Это действие НЕОБРАТИМО!" @@ -88,6 +90,8 @@ "formLocalPort": "Локальный порт", "formServiceHttps": "HTTPS к сервису", "formServiceHttpsHint": "Использовать HTTPS при подключении к локальному сервису", + "formCompression": "Gzip сжатие", + "formCompressionHint": "Сжатие текстовых ответов (HTML, CSS, JS, JSON, SVG) для уменьшения трафика", "formAutoHttps": "Авто HTTPS", "formAutoHttpsHint": "Автоматически перенаправлять HTTP запросы на HTTPS", "deleteTitle": "Удалить прокси", diff --git a/front_vue/src/Core/stores/proxies.js b/front_vue/src/Core/stores/proxies.js index 97393cc..06817ac 100644 --- a/front_vue/src/Core/stores/proxies.js +++ b/front_vue/src/Core/stores/proxies.js @@ -30,6 +30,7 @@ export const useProxiesStore = defineStore('proxies', { LocalPort: proxyData.localPort, ServiceHTTPSuse: proxyData.serviceHttps, AutoHTTPS: proxyData.autoHttps, + Compression: proxyData.compression !== undefined ? proxyData.compression : true, AutoCreateSSL: proxyData.autoSSL, }) const result = await api.saveConfig(JSON.stringify(config)) diff --git a/front_vue/src/Design/views/ProxyCreateView.vue b/front_vue/src/Design/views/ProxyCreateView.vue index 9a50b7e..1f71867 100644 --- a/front_vue/src/Design/views/ProxyCreateView.vue +++ b/front_vue/src/Design/views/ProxyCreateView.vue @@ -10,6 +10,7 @@ const form = reactive({ localAddr: '127.0.0.1', localPort: '', serviceHttps: false, + compression: true, certMode: 'none', }) @@ -26,6 +27,7 @@ const createProxy = async () => { enabled: true, serviceHttps: form.serviceHttps, autoHttps: form.serviceHttps, + compression: form.compression, autoSSL: false, }) creating.value = false @@ -70,6 +72,14 @@ const createProxy = async () => { +
+
+ + +
+ +
+ diff --git a/front_vue/src/Design/views/ProxyEditView.vue b/front_vue/src/Design/views/ProxyEditView.vue index ef47297..2a596bf 100644 --- a/front_vue/src/Design/views/ProxyEditView.vue +++ b/front_vue/src/Design/views/ProxyEditView.vue @@ -16,6 +16,7 @@ const form = reactive({ localPort: '', enabled: true, serviceHttps: false, + compression: true, autoHttps: true, autoSSL: false, }) @@ -33,6 +34,7 @@ onMounted(async () => { form.localPort = proxy.LocalPort form.enabled = proxy.Enable form.serviceHttps = proxy.ServiceHTTPSuse + form.compression = proxy.Compression !== false form.autoHttps = proxy.AutoHTTPS form.autoSSL = proxy.AutoCreateSSL || false } @@ -50,6 +52,7 @@ const saveProxy = async () => { config.Proxy_Service[idx].Enable = form.enabled config.Proxy_Service[idx].ServiceHTTPSuse = form.serviceHttps config.Proxy_Service[idx].AutoHTTPS = form.serviceHttps + config.Proxy_Service[idx].Compression = form.compression config.Proxy_Service[idx].AutoCreateSSL = form.autoSSL const result = await api.saveConfig(JSON.stringify(config)) if (isSuccess(result)) { @@ -120,7 +123,11 @@ const confirmDelete = async () => { -
+
+
+ + +
diff --git a/front_vue/src/Design/views/SiteCreateView.vue b/front_vue/src/Design/views/SiteCreateView.vue index 01042e7..e03196a 100644 --- a/front_vue/src/Design/views/SiteCreateView.vue +++ b/front_vue/src/Design/views/SiteCreateView.vue @@ -11,6 +11,7 @@ const form = reactive({ rootFile: 'index.html', status: 'active', routing: true, + compression: true, certMode: 'none', }) @@ -36,6 +37,7 @@ const createSite = async () => { root_file: form.rootFile, status: form.status, root_file_routing: form.routing, + Compression: form.compression, AutoCreateSSL: form.certMode === 'auto', } const result = await sitesStore.create(siteData) @@ -82,6 +84,14 @@ const createSite = async () => {
+
+
+ + +
+ +
+
diff --git a/front_vue/src/Design/views/SiteEditView.vue b/front_vue/src/Design/views/SiteEditView.vue index 8424549..68c76ad 100644 --- a/front_vue/src/Design/views/SiteEditView.vue +++ b/front_vue/src/Design/views/SiteEditView.vue @@ -16,6 +16,7 @@ const form = reactive({ rootFile: 'index.html', status: 'active', routing: true, + compression: true, autoSSL: false, }) @@ -38,6 +39,7 @@ onMounted(async () => { form.rootFile = site.root_file form.status = site.status form.routing = site.root_file_routing + form.compression = site.Compression !== false form.autoSSL = site.AutoCreateSSL || false } }) @@ -65,6 +67,7 @@ const saveSite = async () => { config.Site_www[idx].root_file = form.rootFile config.Site_www[idx].status = form.status config.Site_www[idx].root_file_routing = form.routing + config.Site_www[idx].Compression = form.compression config.Site_www[idx].AutoCreateSSL = form.autoSSL const result = await api.saveConfig(JSON.stringify(config)) if (isSuccess(result)) { @@ -147,7 +150,11 @@ const confirmDelete = async () => { -
+
+
+ + +