package api import ( "bytes" "context" "encoding/binary" "encoding/json" "errors" "io" "net/http" "net/url" "os" "regexp" "strings" "syscall" "time" "git.misaka.ren/m1saka/dsh-web/internal/store" "gopkg.in/yaml.v3" ) const previewLimit = 1 << 20 var bundleReference = regexp.MustCompile("^(@[a-z0-9._-]+/)?[a-z0-9._-]+$") // The caller must authenticate and check instance ownership before dispatching. func (a *API) profilePreview(w http.ResponseWriter, r *http.Request, i store.Instance) { w.Header().Set("Cache-Control", "no-store") if r.Method != http.MethodGet { fail(w, 405, "method not allowed") return } bundles, err := a.profileBundles(i) if err != nil { fail(w, 400, "bundle manifest unavailable") return } result := map[string]any{"bundles": bundles, "config": "", "redacted": true} if i.ContainerID == "" || i.Status != "running" { result["preview_error"] = "启动实例后可预览已保存的配置。" writeJSON(w, result) return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() content, err := a.dumpProfile(ctx, i.ContainerID) if err != nil { result["preview_error"] = "配置预览不可用;命令输出和错误详情已隐藏。" } else { result["config"] = content } writeJSON(w, result) } func (a *API) profileBundles(i store.Instance) (any, error) { parent, err := upgradeRoot(a.root, i) if err != nil { return nil, err } defer parent.Close() if err = configCheckPath(parent, "home/profiles/web/package.json"); err != nil { return nil, err } root, err := configProfileRoot(parent, false) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, err } defer root.Close() f, err := root.OpenFile("package.json", os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, err } defer f.Close() st, err := f.Stat() if err != nil || !st.Mode().IsRegular() || st.Size() > previewLimit { return nil, errors.New("invalid manifest") } data, err := io.ReadAll(io.LimitReader(f, previewLimit+1)) if err != nil || len(data) > previewLimit { return nil, errors.New("invalid manifest") } var pkg map[string]json.RawMessage if json.Unmarshal(data, &pkg) != nil { return nil, errors.New("invalid manifest") } var profile any raw := pkg["dsh.profile"] if raw == nil { var dsh map[string]json.RawMessage if pkg["dsh"] != nil && json.Unmarshal(pkg["dsh"], &dsh) != nil { return nil, errors.New("invalid manifest") } raw = dsh["profile"] } if raw == nil { return nil, nil } if json.Unmarshal(raw, &profile) != nil { return nil, errors.New("invalid manifest") } return redactPreview(profile, false), nil } func (a *API) dumpProfile(ctx context.Context, container string) (string, error) { var exec struct{ ID string } err := a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(container)+"/exec", map[string]any{ "Cmd": []string{"dsh", "--profile", "web", "--dump-config"}, "AttachStdout": true, "AttachStderr": true, "Tty": false, }, &exec) if err != nil { return "", err } if exec.ID == "" { return "", errors.New("missing exec ID") } path := "/exec/" + url.PathEscape(exec.ID) body, _ := json.Marshal(map[string]bool{"Detach": false, "Tty": false}) resp, err := a.docker.Stream(ctx, "POST", path+"/start", bytes.NewReader(body), "application/json") if err != nil { return "", err } defer resp.Body.Close() data, err := previewStdout(resp.Body) if err != nil { return "", err } var state struct { Running bool ExitCode int } if err = a.docker.Do(ctx, "GET", path+"/json", nil, &state); err != nil { return "", err } if state.Running || state.ExitCode != 0 { return "", errors.New("preview command failed") } var document any decoder := yaml.NewDecoder(bytes.NewReader(data)) if err = decoder.Decode(&document); err != nil { return "", errors.New("invalid preview") } var extra any if decoder.Decode(&extra) != io.EOF { return "", errors.New("multiple preview documents") } switch document.(type) { case map[string]any, []any: // DSH exports its composed plugin tree as a YAML sequence. default: return "", errors.New("invalid preview structure") } safe, err := yaml.Marshal(redactPreview(document, true)) return string(safe), err } func previewStdout(r io.Reader) ([]byte, error) { var output bytes.Buffer total := 0 for { var header [8]byte _, err := io.ReadFull(r, header[:]) if err == io.EOF { return output.Bytes(), nil } if err != nil { return nil, err } if (header[0] != 1 && header[0] != 2) || header[1] != 0 || header[2] != 0 || header[3] != 0 { return nil, errors.New("invalid exec stream") } size := int64(binary.BigEndian.Uint32(header[4:])) total += 8 if size > int64(previewLimit-total) { return nil, errors.New("preview too large") } total += int(size) var dst io.Writer = io.Discard if header[0] == 1 { dst = &output } if _, err = io.CopyN(dst, r, size); err != nil { return nil, err } } } func redactPreview(value any, strict bool) any { switch v := value.(type) { case map[string]any: out := make(map[string]any, len(v)) for key, item := range v { normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "", ".", "").Replace(key)) sensitive := false for _, word := range []string{"key", "secret", "token", "password", "passwd", "credential", "authorization", "cookie", "env", "header"} { sensitive = sensitive || strings.Contains(normalized, word) } if sensitive { out[key] = "[REDACTED]" } else { out[key] = redactPreview(item, strict) } } return out case []any: out := make([]any, len(v)) for j, item := range v { out[j] = redactPreview(item, strict) } return out case string: // Plugins can resolve credentials into arbitrary string fields, not just apiKey. if strict || !bundleReference.MatchString(v) { return "[REDACTED]" } return v case map[any]any: return "[REDACTED]" default: return v } }