Files
dsh-web/internal/api/profile_preview.go

227 lines
5.9 KiB
Go

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
}
}