package api import ( "archive/zip" "bytes" "context" "encoding/json" "errors" "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 } // Hard-limit 10 MB чтобы соединение не обрывалось (MaxBytesReader с маленьким лимитом // закрывает соединение до отправки ответа — клиент получает 000 вместо 413). // Реальный лимит размера архива проверяется после чтения файла. r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024) if err := r.ParseMultipartForm(maxArchiveBytes); err != nil { var maxErr *http.MaxBytesError if errors.As(err, &maxErr) { writeJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("архив слишком большой: максимум %d KB", maxArchiveBytes/1024)) } else { writeJSONError(w, http.StatusBadRequest, "ошибка разбора multipart: "+err.Error()) } return } // Опциональный entrypoint для проверки наличия файла в архиве (формат: "module.function") entrypoint := strings.TrimSpace(r.FormValue("entrypoint")) // Опциональный язык для проверки соответствия файлов в архиве selectedLang := strings.ToLower(strings.TrimSpace(r.FormValue("language"))) 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 } // Проверяем соответствие выбранного языка файлам в архиве if selectedLang != "" { langExt := map[string]string{ "python": ".py", "python3": ".py", "node.js": ".js", "nodejs": ".js", "javascript": ".js", "node": ".js", "ruby": ".rb", "php": ".php", "go": ".go", "perl": ".pl", } expectedExt, known := langExt[selectedLang] if known { // Собираем расширения файлов в архиве extCount := map[string]int{} for _, zf := range zr.File { if !zf.FileInfo().IsDir() { ext := strings.ToLower(filepath.Ext(zf.Name)) if ext != "" { extCount[ext]++ } } } // Если нет ни одного файла с ожидаемым расширением — предупреждение if extCount[expectedExt] == 0 { found := []string{} for ext := range extCount { found = append(found, ext) } results = append(results, lintResult{ File: "язык", OK: false, Output: fmt.Sprintf("выбран язык '%s' (ожидается %s), но в архиве таких файлов нет (найдено: %s)", selectedLang, expectedExt, strings.Join(found, ", ")), }) hasError = true } } } // Проверяем entrypoint: формат "module.function" → файл "module.py" должен быть в архиве if entrypoint != "" { parts := strings.SplitN(entrypoint, ".", 2) if len(parts) == 2 { module := parts[0] funcName := parts[1] // Ищем файл module.py в архиве (поддерживаем вложенные пути) foundFile := false funcDefined := false for _, zf := range zr.File { base := strings.TrimSuffix(filepath.Base(zf.Name), ".py") if base == module && strings.HasSuffix(zf.Name, ".py") { foundFile = true // Читаем содержимое и ищем определение функции rc, openErr := zf.Open() if openErr == nil { var content bytes.Buffer content.ReadFrom(rc) //nolint:errcheck rc.Close() for _, line := range strings.Split(content.String(), "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "def "+funcName+"(") || trimmed == "def "+funcName+":" { funcDefined = true break } } } break } } if !foundFile { results = append(results, lintResult{ File: entrypoint, OK: false, Output: fmt.Sprintf("файл '%s.py' не найден в архиве — проверьте Entrypoint", module), }) hasError = true } else if !funcDefined { results = append(results, lintResult{ File: entrypoint, OK: false, Output: fmt.Sprintf("функция '%s' не найдена в '%s.py' — проверьте Entrypoint", funcName, module), }) hasError = true } else { results = append(results, lintResult{ File: entrypoint, OK: true, Output: fmt.Sprintf("entrypoint '%s' найден", entrypoint), }) } } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "ok": !hasError, "results": results, }) }