817 lines
23 KiB
Go
817 lines
23 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"database/sql"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"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"`
|
||
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 / db.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)
|
||
}
|
||
|
||
// ─── 会话令牌(服务启动时随机生成一次)────────────────────────────────
|
||
|
||
var sessionToken string
|
||
|
||
func initSessionToken() {
|
||
b := make([]byte, 32)
|
||
if _, err := rand.Read(b); err != nil {
|
||
log.Fatalf("failed to generate session token: %v", err)
|
||
}
|
||
sessionToken = hex.EncodeToString(b)
|
||
}
|
||
|
||
// authMiddleware 校验请求中携带的令牌。
|
||
// WebSocket 不支持自定义 Header,因此同时接受 ?token= 查询参数。
|
||
func authMiddleware() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
token := ""
|
||
if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
||
token = strings.TrimPrefix(auth, "Bearer ")
|
||
}
|
||
if token == "" {
|
||
token = c.Query("token")
|
||
}
|
||
if token != sessionToken {
|
||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||
return
|
||
}
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// POST /api/auth/login — 用密码换取令牌
|
||
func handleLogin(c *gin.Context) {
|
||
var body struct {
|
||
Password string `json:"password"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||
return
|
||
}
|
||
if body.Password != accessPassword() {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"token": sessionToken}})
|
||
}
|
||
|
||
// GET /api/auth/verify — 校验令牌是否有效(受 authMiddleware 保护)
|
||
func handleVerify(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"valid": true}})
|
||
}
|
||
|
||
// GET / — 服务说明(无需鉴权,便于网关 / 探活)
|
||
func handleAPIRoot(c *gin.Context) {
|
||
loc := time.FixedZone("CST", 8*3600)
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"description": "提供 Web SSH 终端与跳板连接(SSH 由服务端发起);浏览器访问需先 POST /api/auth/login 换取令牌,后续 API 与 WebSocket 携带 Bearer 或 ?token=",
|
||
"endpoints": gin.H{
|
||
"health": "/health",
|
||
"auth": "/api/auth/login (POST,公开), /api/auth/verify (GET,需令牌)",
|
||
"websocket_ssh": "/api/ws/ssh (GET 升级 WebSocket,需令牌;查询参数 token= 或 Header)",
|
||
"ssh_profiles": "/api/ssh, /api/ssh/:name (CRUD,需令牌)",
|
||
"commands": "/api/commands, /api/commands/:index (CRUD,需令牌)",
|
||
"scripts": "/api/scripts, /api/scripts/:name (CRUD,需令牌)",
|
||
},
|
||
"message": "萌芽 SSH MengyaConnect 后端 API 服务运行中",
|
||
"timestamp": time.Now().In(loc).Format(time.RFC3339),
|
||
"version": apiVersion(),
|
||
})
|
||
}
|
||
|
||
func main() {
|
||
initSessionToken()
|
||
if err := initDB(); err != nil {
|
||
log.Fatalf("db init: %v", err)
|
||
}
|
||
migrateFromFiles()
|
||
|
||
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)
|
||
},
|
||
}
|
||
|
||
// ─── 不需要鉴权的路由 ────────────────────────────────────────
|
||
router.GET("/", handleAPIRoot)
|
||
router.GET("/health", func(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"status": "ok",
|
||
"time": time.Now().Format(time.RFC3339),
|
||
})
|
||
})
|
||
router.POST("/api/auth/login", handleLogin)
|
||
|
||
// ─── 需要鉴权的路由 ──────────────────────────────────────────
|
||
auth := router.Group("/api", authMiddleware())
|
||
|
||
auth.GET("/auth/verify", handleVerify)
|
||
|
||
auth.GET("/ws/ssh", func(c *gin.Context) {
|
||
handleSSHWebSocket(c, upgrader)
|
||
})
|
||
|
||
// SSH 配置 CRUD
|
||
auth.GET("/ssh", handleListSSH)
|
||
auth.POST("/ssh", handleCreateSSH)
|
||
auth.PUT("/ssh/:name", handleUpdateSSH)
|
||
auth.DELETE("/ssh/:name", handleDeleteSSH)
|
||
|
||
// 快捷命令 CRUD
|
||
auth.GET("/commands", handleListCommands)
|
||
auth.POST("/commands", handleCreateCommand)
|
||
auth.PUT("/commands/:index", handleUpdateCommand)
|
||
auth.DELETE("/commands/:index", handleDeleteCommand)
|
||
|
||
// 脚本 CRUD
|
||
auth.GET("/scripts", handleListScripts)
|
||
auth.GET("/scripts/:name", handleGetScript)
|
||
auth.POST("/scripts", handleCreateScript)
|
||
auth.PUT("/scripts/:name", handleUpdateScript)
|
||
auth.DELETE("/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)
|
||
log.Printf("Access password configured (use ACCESS_PASSWORD env to override)")
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// SSH 配置 CRUD
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
// GET /api/ssh
|
||
func handleListSSH(c *gin.Context) {
|
||
rows, err := DB.Query(
|
||
`SELECT name,alias,host,port,username,password,private_key,passphrase
|
||
FROM ssh_profiles ORDER BY id`)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db query failed"})
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
profiles := []SSHProfile{}
|
||
for rows.Next() {
|
||
var p SSHProfile
|
||
if err := rows.Scan(&p.Name, &p.Alias, &p.Host, &p.Port,
|
||
&p.Username, &p.Password, &p.PrivateKey, &p.Passphrase); err != nil {
|
||
continue
|
||
}
|
||
profiles = append(profiles, p)
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": profiles})
|
||
}
|
||
|
||
// POST /api/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 := strings.TrimSpace(p.Name)
|
||
if name == "" {
|
||
name = p.Alias
|
||
}
|
||
name = strings.ReplaceAll(name, " ", "-")
|
||
if err := validateName(name); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
|
||
return
|
||
}
|
||
if p.Port == 0 {
|
||
p.Port = 22
|
||
}
|
||
_, err := DB.Exec(
|
||
`INSERT INTO ssh_profiles (name,alias,host,port,username,password,private_key,passphrase)
|
||
VALUES (?,?,?,?,?,?,?,?)`,
|
||
name, p.Alias, p.Host, p.Port, p.Username, p.Password, p.PrivateKey, p.Passphrase,
|
||
)
|
||
if err != nil {
|
||
if strings.Contains(err.Error(), "UNIQUE") {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "name already exists"})
|
||
} else {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db insert failed"})
|
||
}
|
||
return
|
||
}
|
||
p.Name = name
|
||
c.JSON(http.StatusOK, gin.H{"data": p})
|
||
}
|
||
|
||
// PUT /api/ssh/:name
|
||
func handleUpdateSSH(c *gin.Context) {
|
||
name := c.Param("name")
|
||
if err := validateName(name); 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
|
||
}
|
||
if p.Port == 0 {
|
||
p.Port = 22
|
||
}
|
||
res, err := DB.Exec(
|
||
`UPDATE ssh_profiles
|
||
SET alias=?,host=?,port=?,username=?,password=?,private_key=?,passphrase=?,
|
||
updated_at=CURRENT_TIMESTAMP
|
||
WHERE name=?`,
|
||
p.Alias, p.Host, p.Port, p.Username, p.Password, p.PrivateKey, p.Passphrase, name,
|
||
)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db update failed"})
|
||
return
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||
return
|
||
}
|
||
p.Name = name
|
||
c.JSON(http.StatusOK, gin.H{"data": p})
|
||
}
|
||
|
||
// DELETE /api/ssh/:name
|
||
func handleDeleteSSH(c *gin.Context) {
|
||
name := c.Param("name")
|
||
if err := validateName(name); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
|
||
return
|
||
}
|
||
res, err := DB.Exec(`DELETE FROM ssh_profiles WHERE name=?`, name)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db delete failed"})
|
||
return
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// 快捷命令 CRUD
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
func queryAllCommands() ([]Command, error) {
|
||
rows, err := DB.Query(`SELECT alias,command FROM commands ORDER BY sort_order, id`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
cmds := []Command{}
|
||
for rows.Next() {
|
||
var cmd Command
|
||
if err := rows.Scan(&cmd.Alias, &cmd.Command); err != nil {
|
||
return nil, err
|
||
}
|
||
cmds = append(cmds, cmd)
|
||
}
|
||
return cmds, nil
|
||
}
|
||
|
||
// commandIDAtIndex returns the primary-key id of the command at 0-based position idx.
|
||
func commandIDAtIndex(idx int) (int64, error) {
|
||
rows, err := DB.Query(`SELECT id FROM commands ORDER BY sort_order, id`)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer rows.Close()
|
||
pos := 0
|
||
for rows.Next() {
|
||
var id int64
|
||
if err := rows.Scan(&id); err != nil {
|
||
return 0, err
|
||
}
|
||
if pos == idx {
|
||
return id, nil
|
||
}
|
||
pos++
|
||
}
|
||
return 0, sql.ErrNoRows
|
||
}
|
||
|
||
// GET /api/commands
|
||
func handleListCommands(c *gin.Context) {
|
||
cmds, err := queryAllCommands()
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db query failed"})
|
||
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
|
||
}
|
||
var maxOrder int
|
||
_ = DB.QueryRow(`SELECT COALESCE(MAX(sort_order),0) FROM commands`).Scan(&maxOrder)
|
||
if _, err := DB.Exec(
|
||
`INSERT INTO commands (alias,command,sort_order) VALUES (?,?,?)`,
|
||
cmd.Alias, cmd.Command, maxOrder+1,
|
||
); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db insert failed"})
|
||
return
|
||
}
|
||
cmds, _ := queryAllCommands()
|
||
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
|
||
}
|
||
id, err := commandIDAtIndex(idx)
|
||
if err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "index out of range"})
|
||
return
|
||
}
|
||
if _, err := DB.Exec(`UPDATE commands SET alias=?,command=? WHERE id=?`, cmd.Alias, cmd.Command, id); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db update failed"})
|
||
return
|
||
}
|
||
cmds, _ := queryAllCommands()
|
||
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
|
||
}
|
||
id, err := commandIDAtIndex(idx)
|
||
if err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "index out of range"})
|
||
return
|
||
}
|
||
if _, err := DB.Exec(`DELETE FROM commands WHERE id=?`, id); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db delete failed"})
|
||
return
|
||
}
|
||
cmds, _ := queryAllCommands()
|
||
c.JSON(http.StatusOK, gin.H{"data": cmds})
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// 脚本 CRUD
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
// GET /api/scripts
|
||
func handleListScripts(c *gin.Context) {
|
||
rows, err := DB.Query(`SELECT name FROM scripts ORDER BY name`)
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"data": []ScriptInfo{}})
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
scripts := []ScriptInfo{}
|
||
for rows.Next() {
|
||
var s ScriptInfo
|
||
if err := rows.Scan(&s.Name); err != nil {
|
||
continue
|
||
}
|
||
scripts = append(scripts, s)
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": scripts})
|
||
}
|
||
|
||
// GET /api/scripts/:name
|
||
func handleGetScript(c *gin.Context) {
|
||
name := c.Param("name")
|
||
if err := validateName(name); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
|
||
return
|
||
}
|
||
var s ScriptInfo
|
||
err := DB.QueryRow(`SELECT name,content FROM scripts WHERE name=?`, name).
|
||
Scan(&s.Name, &s.Content)
|
||
if err == sql.ErrNoRows {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||
return
|
||
} else if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db query failed"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": s})
|
||
}
|
||
|
||
// 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
|
||
}
|
||
if err := validateName(s.Name); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
|
||
return
|
||
}
|
||
_, err := DB.Exec(`INSERT INTO scripts (name,content) VALUES (?,?)`, s.Name, s.Content)
|
||
if err != nil {
|
||
if strings.Contains(err.Error(), "UNIQUE") {
|
||
c.JSON(http.StatusConflict, gin.H{"error": "name already exists"})
|
||
} else {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db insert failed"})
|
||
}
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": s})
|
||
}
|
||
|
||
// PUT /api/scripts/:name
|
||
func handleUpdateScript(c *gin.Context) {
|
||
name := c.Param("name")
|
||
if err := validateName(name); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
|
||
return
|
||
}
|
||
var s ScriptInfo
|
||
if err := c.ShouldBindJSON(&s); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||
return
|
||
}
|
||
res, err := DB.Exec(
|
||
`UPDATE scripts SET content=?,updated_at=CURRENT_TIMESTAMP WHERE name=?`,
|
||
s.Content, name,
|
||
)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db update failed"})
|
||
return
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": ScriptInfo{Name: name, Content: s.Content}})
|
||
}
|
||
|
||
// DELETE /api/scripts/:name
|
||
func handleDeleteScript(c *gin.Context) {
|
||
name := c.Param("name")
|
||
if err := validateName(name); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
|
||
return
|
||
}
|
||
res, err := DB.Exec(`DELETE FROM scripts WHERE name=?`, name)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db delete failed"})
|
||
return
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||
}
|