diff --git a/console/deploy/console.yaml b/console/deploy/console.yaml index 289ec34..961e24b 100644 --- a/console/deploy/console.yaml +++ b/console/deploy/console.yaml @@ -52,7 +52,7 @@ spec: serviceAccountName: fission-console containers: - name: console - image: naeel/fission-console:v1.3.36 + image: naeel/fission-console:v1.3.38 imagePullPolicy: Always ports: - containerPort: 8090 diff --git a/console/internal/api/lint_archive.go b/console/internal/api/lint_archive.go new file mode 100644 index 0000000..6ba625d --- /dev/null +++ b/console/internal/api/lint_archive.go @@ -0,0 +1,196 @@ +package api + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const ( + // maxArchiveBytes — максимальный размер zip-архива (100 KB). + // Serverless-функции не должны быть большими — только скрипты и небольшие зависимости. + maxArchiveBytes = 100 * 1024 + + // maxUnzippedBytes — ограничение на суммарный распакованный размер (защита от zip bomb). + // 100 KB реального кода — достаточно для любой serverless-функции. + maxUnzippedBytes = 100 * 1024 +) + +// lintResult — результат проверки одного файла. +type lintResult struct { + File string `json:"file"` + OK bool `json:"ok"` + Output string `json:"output,omitempty"` +} + +// handleLintArchive — POST /console/api/ai/lint-archive +// Принимает zip-архив (multipart/form-data, поле "archive"), +// прогоняет линтер по всем файлам с известным расширением, +// возвращает список результатов по файлам. +func (s *Server) handleLintArchive(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + // Ограничиваем тело запроса — не более maxArchiveBytes (+ небольшой overhead для multipart) + r.Body = http.MaxBytesReader(w, r.Body, maxArchiveBytes+4*1024) + + if err := r.ParseMultipartForm(maxArchiveBytes); err != nil { + writeJSONError(w, http.StatusRequestEntityTooLarge, + fmt.Sprintf("архив слишком большой: максимум %d KB", maxArchiveBytes/1024)) + return + } + + f, _, err := r.FormFile("archive") + if err != nil { + writeJSONError(w, http.StatusBadRequest, "поле 'archive' обязательно") + return + } + defer f.Close() + + // Читаем архив в память + var buf bytes.Buffer + if _, err := buf.ReadFrom(f); err != nil { + writeJSONError(w, http.StatusBadRequest, "ошибка чтения архива: "+err.Error()) + return + } + if buf.Len() > maxArchiveBytes { + writeJSONError(w, http.StatusRequestEntityTooLarge, + fmt.Sprintf("архив слишком большой: %d байт, максимум %d KB", buf.Len(), maxArchiveBytes/1024)) + return + } + + // Открываем zip из памяти + zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "не удалось открыть zip: "+err.Error()) + return + } + + // Считаем суммарный распакованный размер (защита от zip bomb) + var totalUnzipped uint64 + for _, zf := range zr.File { + totalUnzipped += zf.UncompressedSize64 + } + if totalUnzipped > maxUnzippedBytes { + writeJSONError(w, http.StatusRequestEntityTooLarge, + fmt.Sprintf("суммарный размер распакованных файлов превышает %d KB", maxUnzippedBytes/1024)) + return + } + + // Карта линтеров по расширению + type linterCfg struct { + cmd []string + stdinOk bool // поддерживает stdin + stdinArg string // аргумент для stdin-режима (имя "файла") + } + linters := map[string]linterCfg{ + ".py": {cmd: []string{"python3", "-m", "py_compile"}, stdinOk: false}, + ".js": {cmd: []string{"node", "--check"}, stdinOk: false}, + ".rb": {cmd: []string{"ruby", "-c"}, stdinOk: true}, + ".php": {cmd: []string{"php", "-l"}, stdinOk: false}, + } + + var results []lintResult + var hasError bool + + for _, zf := range zr.File { + if zf.FileInfo().IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(zf.Name)) + cfg, supported := linters[ext] + if !supported { + continue + } + + // Читаем содержимое файла из zip в память + rc, err := zf.Open() + if err != nil { + results = append(results, lintResult{File: zf.Name, OK: false, Output: "ошибка чтения: " + err.Error()}) + hasError = true + continue + } + var content bytes.Buffer + _, readErr := content.ReadFrom(rc) + rc.Close() + if readErr != nil { + results = append(results, lintResult{File: zf.Name, OK: false, Output: "ошибка чтения: " + readErr.Error()}) + hasError = true + continue + } + + var res lintResult + res.File = zf.Name + + // Записываем во временный файл в /dev/shm (RAM) если доступен, иначе os.TempDir() + tmpDir := "/dev/shm" + if _, statErr := os.Stat(tmpDir); statErr != nil { + tmpDir = "" + } + tmpf, tmpErr := os.CreateTemp(tmpDir, "fission-lint-*"+ext) + if tmpErr != nil { + res.OK = false + res.Output = "не удалось создать tmp: " + tmpErr.Error() + results = append(results, res) + hasError = true + continue + } + tmpName := tmpf.Name() + _, writeErr := tmpf.Write(content.Bytes()) + tmpf.Close() + + if writeErr != nil { + os.Remove(tmpName) + res.OK = false + res.Output = "ошибка записи tmp: " + writeErr.Error() + results = append(results, res) + hasError = true + continue + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + args := append(cfg.cmd, tmpName) //nolint:gocritic — намеренно создаём новый слайс + //nolint:gosec — cmd содержит только захардкоженные команды из linters + out, lintErr := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput() + cancel() + os.Remove(tmpName) + + outStr := strings.TrimSpace(string(out)) + // Скрываем путь к tmp-файлу от пользователя + outStr = strings.ReplaceAll(outStr, tmpName, zf.Name) + + if lintErr == nil { + res.OK = true + } else { + res.OK = false + hasError = true + if outStr != "" { + res.Output = outStr + } else { + res.Output = fmt.Sprintf("ошибка синтаксиса (%v)", lintErr) + } + } + results = append(results, res) + } + + if len(results) == 0 { + writeJSONError(w, http.StatusBadRequest, "архив не содержит файлов поддерживаемых языков (.py, .js, .rb, .php)") + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "ok": !hasError, + "results": results, + }) +} diff --git a/console/internal/api/server.go b/console/internal/api/server.go index 9d52534..46b42fe 100644 --- a/console/internal/api/server.go +++ b/console/internal/api/server.go @@ -156,6 +156,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus)) mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug)) mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck)) + mux.HandleFunc("/console/api/ai/lint-archive", auth(s.handleLintArchive)) // --- ai/ask feature (удалить строку чтобы выкосить роут) --- mux.HandleFunc("/console/api/ai/ask", auth(s.handleAIAsk)) // --- end ai/ask feature --- diff --git a/console/ui/index.html b/console/ui/index.html index 88ee63a..744f6fa 100644 --- a/console/ui/index.html +++ b/console/ui/index.html @@ -100,7 +100,7 @@
NUBES
FISSION CONSOLE
-
v1.3.36
+
v1.3.38
@@ -228,7 +228,7 @@