feat: lint-archive endpoint, UI result inline v1.3.38

This commit is contained in:
“Naeel”
2026-05-03 21:08:32 +04:00
parent 971c7e7ab4
commit b7a8074c73
7 changed files with 483 additions and 9 deletions
+1 -1
View File
@@ -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
+196
View File
@@ -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,
})
}
+1
View File
@@ -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 ---
+4 -4
View File
@@ -100,7 +100,7 @@
<div class="nubes">NUBES</div>
<div class="product">FISSION CONSOLE</div>
</div>
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.36</div>
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.38</div>
</div>
<div class="row" style="margin:0;">
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
@@ -228,7 +228,7 @@
<div id="c-archive-area" style="display:none;">
<input type="file" id="c-archive-file" accept=".zip" style="display:block; margin-bottom:8px; color:var(--text-primary);">
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<button class="btn ghost" onclick="lintArchiveFile('c')">&#x1F50D; Проверить архив линтером</button>
<button id="c-lint-btn" class="btn ghost" onclick="lintArchiveFile('c')">&#x1F50D; Проверка архива линтером</button>
</div>
</div>
<div id="c-gen-prompt" style="display:none; margin-top:8px; display:none; gap:6px; align-items:center;">
@@ -306,7 +306,7 @@
<div id="e-archive-area" style="display:none;">
<input type="file" id="e-archive-file" accept=".zip" style="display:block; margin-bottom:8px; color:var(--text-primary);">
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<button class="btn ghost" onclick="lintArchiveFile('e')">&#x1F50D; Проверить архив линтером</button>
<button id="e-lint-btn" class="btn ghost" onclick="lintArchiveFile('e')">&#x1F50D; Проверка архива линтером</button>
</div>
</div>
<div id="e-ai-result"
@@ -398,7 +398,7 @@
</div>
<div class="actions" style="justify-content:space-between; align-items:center;">
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.36</span>
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.38</span>
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
</div>
</div>
+51 -4
View File
@@ -17,15 +17,62 @@ function setCodeMode(prefix, mode) {
if (btnArchive) btnArchive.style.opacity = isCode ? '0.5' : '1';
}
// lintArchiveFile — заглушка линтера для архивов.
// Реализация (распаковка + пофайловая проверка) добавляется позже.
// lintArchiveFile — отправляет zip-архив на /console/api/ai/lint-archive,
// показывает результат пофайлово в блоке {prefix}-ai-result рядом с кнопкой.
function lintArchiveFile(prefix) {
var input = document.getElementById(prefix + '-archive-file');
var resEl = document.getElementById(prefix + '-ai-result');
var btn = document.getElementById(prefix + '-lint-btn');
function showRes(text, bg, color) {
if (resEl) {
resEl.style.display = 'block';
resEl.style.background = bg;
resEl.style.color = color;
resEl.textContent = text;
}
}
if (!input || !input.files || !input.files[0]) {
showStatus('Выберите .zip файл', 'err');
showRes('Выберите .zip файл', '#3a1a1a', '#f88');
return;
}
showStatus('Линтинг архива — пока не реализован (скоро)', 'info');
var file = input.files[0];
if (file.size > 100 * 1024) {
showRes('Архив слишком большой: максимум 100 KB', '#3a1a1a', '#f88');
return;
}
if (btn) { btn.disabled = true; btn.textContent = '⏳ Проверяю…'; }
showRes('Запускаю линтер…', 'var(--bg-alt)', 'var(--fg)');
var fd = new FormData();
fd.append('archive', file);
fetch(API_BASE + '/ai/lint-archive', {
method: 'POST',
headers: authHeaders(),
body: fd
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (btn) { btn.disabled = false; btn.textContent = '🔍 Проверка архива линтером'; }
if (data.error) {
showRes('Ошибка: ' + data.error, '#3a1a1a', '#f88');
return;
}
var results = data.results || [];
if (results.length === 0) {
showRes('Нет поддерживаемых файлов в архиве (.py, .js, .rb, .php)', '#3a2a00', '#ffa');
return;
}
var ok = results.filter(function(r) { return r.ok; }).length;
var fail = results.filter(function(r) { return !r.ok; }).length;
var lines = results.map(function(r) {
return (r.ok ? '✅ ' : '❌ ') + r.file + (r.output ? '\n ' + r.output : '');
});
var summary = ok + ' OK, ' + fail + ' ошибок\n\n' + lines.join('\n');
showRes(summary, fail > 0 ? '#3a1a1a' : '#1a3a1a', fail > 0 ? '#f88' : '#8f8');
})
.catch(function(e) {
if (btn) { btn.disabled = false; btn.textContent = '🔍 Проверка архива линтером'; }
showRes('Ошибка сети: ' + e.message, '#3a2a00', '#ffa');
});
}
function parseMethods(v) {