Files
mengyaconnect/mengyaconnect-backend/main.go
2026-05-16 19:19:39 +08:00

817 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"})
}