716 lines
20 KiB
Go
716 lines
20 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/gorilla/websocket"
|
||
"golang.org/x/crypto/ssh"
|
||
)
|
||
|
||
// ─── 持久化数据类型 ───────────────────────────────────────────────
|
||
|
||
type SSHProfile struct {
|
||
Name string `json:"name,omitempty"` // 文件名(不含 .json)
|
||
Alias string `json:"alias"`
|
||
Host string `json:"host"`
|
||
Port int `json:"port"`
|
||
Username string `json:"username"`
|
||
Password string `json:"password,omitempty"`
|
||
PrivateKey string `json:"privateKey,omitempty"`
|
||
Passphrase string `json:"passphrase,omitempty"`
|
||
}
|
||
|
||
type Command struct {
|
||
Alias string `json:"alias"`
|
||
Command string `json:"command"`
|
||
}
|
||
|
||
type ScriptInfo struct {
|
||
Name string `json:"name"`
|
||
Content string `json:"content,omitempty"`
|
||
}
|
||
|
||
// 配置与数据目录辅助函数见 config.go
|
||
|
||
type wsMessage struct {
|
||
Type string `json:"type"`
|
||
Host string `json:"host,omitempty"`
|
||
Port int `json:"port,omitempty"`
|
||
Username string `json:"username,omitempty"`
|
||
Password string `json:"password,omitempty"`
|
||
PrivateKey string `json:"privateKey,omitempty"`
|
||
Passphrase string `json:"passphrase,omitempty"`
|
||
Data string `json:"data,omitempty"`
|
||
Cols int `json:"cols,omitempty"`
|
||
Rows int `json:"rows,omitempty"`
|
||
Status string `json:"status,omitempty"`
|
||
Message string `json:"message,omitempty"`
|
||
}
|
||
|
||
type wsWriter struct {
|
||
conn *websocket.Conn
|
||
mu sync.Mutex
|
||
}
|
||
|
||
func (w *wsWriter) send(msg wsMessage) {
|
||
w.mu.Lock()
|
||
defer w.mu.Unlock()
|
||
_ = w.conn.WriteJSON(msg)
|
||
}
|
||
|
||
func main() {
|
||
if mode := os.Getenv("GIN_MODE"); mode != "" {
|
||
gin.SetMode(mode)
|
||
}
|
||
|
||
router := gin.New()
|
||
router.Use(gin.Logger(), gin.Recovery(), corsMiddleware())
|
||
|
||
allowedOrigins := parseListEnv("ALLOWED_ORIGINS")
|
||
upgrader := websocket.Upgrader{
|
||
ReadBufferSize: 1024,
|
||
WriteBufferSize: 1024,
|
||
CheckOrigin: func(r *http.Request) bool {
|
||
return isOriginAllowed(r.Header.Get("Origin"), allowedOrigins)
|
||
},
|
||
}
|
||
|
||
// ─── 基本配置 CRUD ──────────────────────────────────────────
|
||
router.GET("/health", func(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"status": "ok",
|
||
"time": time.Now().Format(time.RFC3339),
|
||
})
|
||
})
|
||
|
||
router.GET("/api/ws/ssh", func(c *gin.Context) {
|
||
handleSSHWebSocket(c, upgrader)
|
||
})
|
||
|
||
// ─── SSH 配置 CRUD ──────────────────────────────────────────
|
||
router.GET("/api/ssh", handleListSSH)
|
||
router.POST("/api/ssh", handleCreateSSH)
|
||
router.PUT("/api/ssh/:name", handleUpdateSSH)
|
||
router.DELETE("/api/ssh/:name", handleDeleteSSH)
|
||
|
||
// ─── 快捷命令 CRUD ─────────────────────────────────────────
|
||
router.GET("/api/commands", handleListCommands)
|
||
router.POST("/api/commands", handleCreateCommand)
|
||
router.PUT("/api/commands/:index", handleUpdateCommand)
|
||
router.DELETE("/api/commands/:index", handleDeleteCommand)
|
||
|
||
// ─── 脚本 CRUD ─────────────────────────────────────────────
|
||
router.GET("/api/scripts", handleListScripts)
|
||
router.GET("/api/scripts/:name", handleGetScript)
|
||
router.POST("/api/scripts", handleCreateScript)
|
||
router.PUT("/api/scripts/:name", handleUpdateScript)
|
||
router.DELETE("/api/scripts/:name", handleDeleteScript)
|
||
|
||
addr := getEnv("ADDR", ":"+getEnv("PORT", "8080"))
|
||
server := &http.Server{
|
||
Addr: addr,
|
||
Handler: router,
|
||
ReadHeaderTimeout: 10 * time.Second,
|
||
}
|
||
|
||
log.Printf("SSH WebSocket server listening on %s", addr)
|
||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||
log.Fatalf("server error: %v", err)
|
||
}
|
||
}
|
||
|
||
func handleSSHWebSocket(c *gin.Context, upgrader websocket.Upgrader) {
|
||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||
if err != nil {
|
||
return
|
||
}
|
||
defer conn.Close()
|
||
conn.SetReadLimit(1 << 20)
|
||
|
||
writer := &wsWriter{conn: conn}
|
||
writer.send(wsMessage{Type: "status", Status: "connected", Message: "WebSocket connected"})
|
||
|
||
var (
|
||
sshClient *ssh.Client
|
||
sshSession *ssh.Session
|
||
sshStdin io.WriteCloser
|
||
stdout io.Reader
|
||
stderr io.Reader
|
||
cancelFn context.CancelFunc
|
||
)
|
||
|
||
cleanup := func() {
|
||
if cancelFn != nil {
|
||
cancelFn()
|
||
}
|
||
if sshSession != nil {
|
||
_ = sshSession.Close()
|
||
}
|
||
if sshClient != nil {
|
||
_ = sshClient.Close()
|
||
}
|
||
}
|
||
defer cleanup()
|
||
|
||
for {
|
||
var msg wsMessage
|
||
if err := conn.ReadJSON(&msg); err != nil {
|
||
writer.send(wsMessage{Type: "status", Status: "closed", Message: "WebSocket closed"})
|
||
return
|
||
}
|
||
|
||
switch msg.Type {
|
||
case "connect":
|
||
if sshSession != nil {
|
||
writer.send(wsMessage{Type: "error", Message: "SSH session already exists"})
|
||
continue
|
||
}
|
||
|
||
client, session, stdin, out, errOut, err := startSSHSession(msg)
|
||
if err != nil {
|
||
writer.send(wsMessage{Type: "error", Message: err.Error()})
|
||
continue
|
||
}
|
||
|
||
sshClient = client
|
||
sshSession = session
|
||
sshStdin = stdin
|
||
stdout = out
|
||
stderr = errOut
|
||
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
cancelFn = cancel
|
||
go streamToWebSocket(ctx, writer, stdout)
|
||
go streamToWebSocket(ctx, writer, stderr)
|
||
go func() {
|
||
_ = session.Wait()
|
||
writer.send(wsMessage{Type: "status", Status: "closed", Message: "SSH session closed"})
|
||
cleanup()
|
||
}()
|
||
|
||
writer.send(wsMessage{Type: "status", Status: "ready", Message: "SSH connected"})
|
||
|
||
case "input":
|
||
if sshStdin == nil {
|
||
writer.send(wsMessage{Type: "error", Message: "SSH session not ready"})
|
||
continue
|
||
}
|
||
if msg.Data != "" {
|
||
_, _ = sshStdin.Write([]byte(msg.Data))
|
||
}
|
||
|
||
case "resize":
|
||
if sshSession == nil {
|
||
continue
|
||
}
|
||
rows := msg.Rows
|
||
cols := msg.Cols
|
||
if rows > 0 && cols > 0 {
|
||
_ = sshSession.WindowChange(rows, cols)
|
||
}
|
||
|
||
case "ping":
|
||
writer.send(wsMessage{Type: "pong"})
|
||
|
||
case "close":
|
||
writer.send(wsMessage{Type: "status", Status: "closing", Message: "Closing SSH session"})
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func startSSHSession(msg wsMessage) (*ssh.Client, *ssh.Session, io.WriteCloser, io.Reader, io.Reader, error) {
|
||
host := strings.TrimSpace(msg.Host)
|
||
if host == "" {
|
||
return nil, nil, nil, nil, nil, errors.New("host is required")
|
||
}
|
||
port := msg.Port
|
||
if port == 0 {
|
||
port = 22
|
||
}
|
||
user := strings.TrimSpace(msg.Username)
|
||
if user == "" {
|
||
return nil, nil, nil, nil, nil, errors.New("username is required")
|
||
}
|
||
|
||
auths, err := buildAuthMethods(msg)
|
||
if err != nil {
|
||
return nil, nil, nil, nil, nil, err
|
||
}
|
||
|
||
cfg := &ssh.ClientConfig{
|
||
User: user,
|
||
Auth: auths,
|
||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||
Timeout: 12 * time.Second,
|
||
}
|
||
|
||
addr := fmt.Sprintf("%s:%d", host, port)
|
||
client, err := ssh.Dial("tcp", addr, cfg)
|
||
if err != nil {
|
||
return nil, nil, nil, nil, nil, fmt.Errorf("ssh dial failed: %w", err)
|
||
}
|
||
|
||
session, err := client.NewSession()
|
||
if err != nil {
|
||
_ = client.Close()
|
||
return nil, nil, nil, nil, nil, fmt.Errorf("ssh session failed: %w", err)
|
||
}
|
||
|
||
rows := msg.Rows
|
||
cols := msg.Cols
|
||
if rows == 0 {
|
||
rows = 24
|
||
}
|
||
if cols == 0 {
|
||
cols = 80
|
||
}
|
||
|
||
modes := ssh.TerminalModes{
|
||
ssh.ECHO: 1,
|
||
ssh.TTY_OP_ISPEED: 14400,
|
||
ssh.TTY_OP_OSPEED: 14400,
|
||
}
|
||
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
|
||
_ = session.Close()
|
||
_ = client.Close()
|
||
return nil, nil, nil, nil, nil, fmt.Errorf("request pty failed: %w", err)
|
||
}
|
||
|
||
stdin, err := session.StdinPipe()
|
||
if err != nil {
|
||
_ = session.Close()
|
||
_ = client.Close()
|
||
return nil, nil, nil, nil, nil, fmt.Errorf("stdin pipe failed: %w", err)
|
||
}
|
||
|
||
stdout, err := session.StdoutPipe()
|
||
if err != nil {
|
||
_ = session.Close()
|
||
_ = client.Close()
|
||
return nil, nil, nil, nil, nil, fmt.Errorf("stdout pipe failed: %w", err)
|
||
}
|
||
|
||
stderr, err := session.StderrPipe()
|
||
if err != nil {
|
||
_ = session.Close()
|
||
_ = client.Close()
|
||
return nil, nil, nil, nil, nil, fmt.Errorf("stderr pipe failed: %w", err)
|
||
}
|
||
|
||
if err := session.Shell(); err != nil {
|
||
_ = session.Close()
|
||
_ = client.Close()
|
||
return nil, nil, nil, nil, nil, fmt.Errorf("shell start failed: %w", err)
|
||
}
|
||
|
||
return client, session, stdin, stdout, stderr, nil
|
||
}
|
||
|
||
func buildAuthMethods(msg wsMessage) ([]ssh.AuthMethod, error) {
|
||
var methods []ssh.AuthMethod
|
||
if strings.TrimSpace(msg.PrivateKey) != "" {
|
||
signer, err := parsePrivateKey(msg.PrivateKey, msg.Passphrase)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("private key error: %w", err)
|
||
}
|
||
methods = append(methods, ssh.PublicKeys(signer))
|
||
}
|
||
if msg.Password != "" {
|
||
methods = append(methods, ssh.Password(msg.Password))
|
||
}
|
||
if len(methods) == 0 {
|
||
return nil, errors.New("no auth method provided")
|
||
}
|
||
return methods, nil
|
||
}
|
||
|
||
func parsePrivateKey(key, passphrase string) (ssh.Signer, error) {
|
||
key = strings.TrimSpace(key)
|
||
if passphrase != "" {
|
||
return ssh.ParsePrivateKeyWithPassphrase([]byte(key), []byte(passphrase))
|
||
}
|
||
return ssh.ParsePrivateKey([]byte(key))
|
||
}
|
||
|
||
func streamToWebSocket(ctx context.Context, writer *wsWriter, reader io.Reader) {
|
||
buf := make([]byte, 8192)
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
default:
|
||
}
|
||
|
||
n, err := reader.Read(buf)
|
||
if n > 0 {
|
||
writer.send(wsMessage{Type: "output", Data: string(buf[:n])})
|
||
}
|
||
if err != nil {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// CORS、中间件与环境变量工具函数见 config.go
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// SSH 配置 CRUD
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
// GET /api/ssh — 列出所有 SSH 配置
|
||
func handleListSSH(c *gin.Context) {
|
||
entries, err := os.ReadDir(sshDir())
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"data": []SSHProfile{}})
|
||
return
|
||
}
|
||
var profiles []SSHProfile
|
||
for _, e := range entries {
|
||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||
continue
|
||
}
|
||
raw, err := os.ReadFile(filepath.Join(sshDir(), e.Name()))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
var p SSHProfile
|
||
if err := json.Unmarshal(raw, &p); err != nil {
|
||
continue
|
||
}
|
||
p.Name = strings.TrimSuffix(e.Name(), ".json")
|
||
profiles = append(profiles, p)
|
||
}
|
||
if profiles == nil {
|
||
profiles = []SSHProfile{}
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": profiles})
|
||
}
|
||
|
||
// POST /api/ssh — 新建 SSH 配置
|
||
func handleCreateSSH(c *gin.Context) {
|
||
var p SSHProfile
|
||
if err := c.ShouldBindJSON(&p); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||
return
|
||
}
|
||
if p.Alias == "" || p.Host == "" || p.Username == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "alias、host 和 username 为必填项"})
|
||
return
|
||
}
|
||
name := p.Name
|
||
if name == "" {
|
||
name = p.Alias
|
||
}
|
||
safe, err := sanitizeName(strings.ReplaceAll(name, " ", "-"))
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
|
||
return
|
||
}
|
||
p.Name = ""
|
||
raw, _ := json.MarshalIndent(p, "", " ")
|
||
if err := os.MkdirAll(sshDir(), 0o750); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dir"})
|
||
return
|
||
}
|
||
if err := os.WriteFile(filepath.Join(sshDir(), safe+".json"), raw, 0o600); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"})
|
||
return
|
||
}
|
||
p.Name = safe
|
||
c.JSON(http.StatusOK, gin.H{"data": p})
|
||
}
|
||
|
||
// PUT /api/ssh/:name — 更新 SSH 配置
|
||
func handleUpdateSSH(c *gin.Context) {
|
||
name, err := sanitizeName(c.Param("name"))
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
|
||
return
|
||
}
|
||
var p SSHProfile
|
||
if err := c.ShouldBindJSON(&p); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||
return
|
||
}
|
||
if p.Alias == "" || p.Host == "" || p.Username == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "alias、host 和 username 为必填项"})
|
||
return
|
||
}
|
||
p.Name = ""
|
||
raw, _ := json.MarshalIndent(p, "", " ")
|
||
filePath := filepath.Join(sshDir(), name+".json")
|
||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||
return
|
||
}
|
||
if err := os.WriteFile(filePath, raw, 0o600); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"})
|
||
return
|
||
}
|
||
p.Name = name
|
||
c.JSON(http.StatusOK, gin.H{"data": p})
|
||
}
|
||
|
||
// DELETE /api/ssh/:name — 删除 SSH 配置
|
||
func handleDeleteSSH(c *gin.Context) {
|
||
name, err := sanitizeName(c.Param("name"))
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
|
||
return
|
||
}
|
||
if err := os.Remove(filepath.Join(sshDir(), name+".json")); err != nil {
|
||
if os.IsNotExist(err) {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||
} else {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"})
|
||
}
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// 快捷命令 CRUD
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
func readCommands() ([]Command, error) {
|
||
raw, err := os.ReadFile(cmdFilePath())
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return []Command{}, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
var cmds []Command
|
||
if err := json.Unmarshal(raw, &cmds); err != nil {
|
||
return nil, err
|
||
}
|
||
return cmds, nil
|
||
}
|
||
|
||
func writeCommands(cmds []Command) error {
|
||
raw, err := json.MarshalIndent(cmds, "", " ")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(cmdFilePath()), 0o750); err != nil {
|
||
return err
|
||
}
|
||
return os.WriteFile(cmdFilePath(), raw, 0o600)
|
||
}
|
||
|
||
// GET /api/commands
|
||
func handleListCommands(c *gin.Context) {
|
||
cmds, err := readCommands()
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": cmds})
|
||
}
|
||
|
||
// POST /api/commands
|
||
func handleCreateCommand(c *gin.Context) {
|
||
var cmd Command
|
||
if err := c.ShouldBindJSON(&cmd); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||
return
|
||
}
|
||
if cmd.Alias == "" || cmd.Command == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "alias 和 command 为必填项"})
|
||
return
|
||
}
|
||
cmds, err := readCommands()
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"})
|
||
return
|
||
}
|
||
cmds = append(cmds, cmd)
|
||
if err := writeCommands(cmds); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save commands"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": cmds})
|
||
}
|
||
|
||
// PUT /api/commands/:index
|
||
func handleUpdateCommand(c *gin.Context) {
|
||
idx, err := strconv.Atoi(c.Param("index"))
|
||
if err != nil || idx < 0 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid index"})
|
||
return
|
||
}
|
||
var cmd Command
|
||
if err := c.ShouldBindJSON(&cmd); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||
return
|
||
}
|
||
if cmd.Alias == "" || cmd.Command == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "alias 和 command 为必填项"})
|
||
return
|
||
}
|
||
cmds, err := readCommands()
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"})
|
||
return
|
||
}
|
||
if idx >= len(cmds) {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "index out of range"})
|
||
return
|
||
}
|
||
cmds[idx] = cmd
|
||
if err := writeCommands(cmds); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save commands"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": cmds})
|
||
}
|
||
|
||
// DELETE /api/commands/:index
|
||
func handleDeleteCommand(c *gin.Context) {
|
||
idx, err := strconv.Atoi(c.Param("index"))
|
||
if err != nil || idx < 0 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid index"})
|
||
return
|
||
}
|
||
cmds, err := readCommands()
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read commands"})
|
||
return
|
||
}
|
||
if idx >= len(cmds) {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "index out of range"})
|
||
return
|
||
}
|
||
cmds = append(cmds[:idx], cmds[idx+1:]...)
|
||
if err := writeCommands(cmds); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save commands"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": cmds})
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// 脚本 CRUD
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
// GET /api/scripts — 列出所有脚本名称
|
||
func handleListScripts(c *gin.Context) {
|
||
entries, err := os.ReadDir(scriptDir())
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"data": []ScriptInfo{}})
|
||
return
|
||
}
|
||
var scripts []ScriptInfo
|
||
for _, e := range entries {
|
||
if !e.IsDir() {
|
||
scripts = append(scripts, ScriptInfo{Name: e.Name()})
|
||
}
|
||
}
|
||
if scripts == nil {
|
||
scripts = []ScriptInfo{}
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": scripts})
|
||
}
|
||
|
||
// GET /api/scripts/:name — 获取脚本内容
|
||
func handleGetScript(c *gin.Context) {
|
||
name, err := sanitizeName(c.Param("name"))
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
|
||
return
|
||
}
|
||
raw, err := os.ReadFile(filepath.Join(scriptDir(), name))
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||
} else {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read"})
|
||
}
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": ScriptInfo{Name: name, Content: string(raw)}})
|
||
}
|
||
|
||
// POST /api/scripts — 新建脚本
|
||
func handleCreateScript(c *gin.Context) {
|
||
var s ScriptInfo
|
||
if err := c.ShouldBindJSON(&s); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||
return
|
||
}
|
||
if s.Name == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "name 为必填项"})
|
||
return
|
||
}
|
||
name, err := sanitizeName(s.Name)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
|
||
return
|
||
}
|
||
if err := os.MkdirAll(scriptDir(), 0o750); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dir"})
|
||
return
|
||
}
|
||
if err := os.WriteFile(filepath.Join(scriptDir(), name), []byte(s.Content), 0o640); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": ScriptInfo{Name: name, Content: s.Content}})
|
||
}
|
||
|
||
// PUT /api/scripts/:name — 更新脚本内容
|
||
func handleUpdateScript(c *gin.Context) {
|
||
name, err := sanitizeName(c.Param("name"))
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
|
||
return
|
||
}
|
||
filePath := filepath.Join(scriptDir(), name)
|
||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||
return
|
||
}
|
||
var s ScriptInfo
|
||
if err := c.ShouldBindJSON(&s); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||
return
|
||
}
|
||
if err := os.WriteFile(filePath, []byte(s.Content), 0o640); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": ScriptInfo{Name: name, Content: s.Content}})
|
||
}
|
||
|
||
// DELETE /api/scripts/:name — 删除脚本
|
||
func handleDeleteScript(c *gin.Context) {
|
||
name, err := sanitizeName(c.Param("name"))
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
|
||
return
|
||
}
|
||
if err := os.Remove(filepath.Join(scriptDir(), name)); err != nil {
|
||
if os.IsNotExist(err) {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||
} else {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"})
|
||
}
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||
}
|