From 5414c9c865121f6f3ea55950f2a4f038e2bc3160 Mon Sep 17 00:00:00 2001 From: zhilv Date: Fri, 29 May 2026 15:02:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=86=E7=B1=BB/=E5=8F=98=E4=BD=93?= =?UTF-8?q?=E4=BD=93=E7=B3=BB=20+=20=E7=94=A8=E6=88=B7=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=20+=20=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 运行时分类体系:Shell/Python/JavaScript/Ruby/PHP 各含变体 - 用户注册/登录(JWT + bcrypt),首个注册用户为管理员 - 管理后台 /admin 动态管理分类和变体 - 脚本市场支持按分类筛选 - CodeMirror 语言模式根据分类名称自动切换 - 结果页展示该分类下所有变体的运行命令 - source 命令变体用于 Shell 类继承环境变量 Co-Authored-By: Claude Opus 4.7 --- backend/cmd/server/main.go | 45 +++- backend/go.mod | 3 +- backend/go.sum | 2 + backend/internal/auth/auth.go | 122 +++++++++ backend/internal/config/runtime.go | 58 ---- backend/internal/handler/raw.go | 16 +- backend/internal/handler/script.go | 323 +++++++++++++++++++---- backend/internal/middleware/auth.go | 54 ++++ backend/internal/model/models.go | 76 ++++++ backend/internal/model/script.go | 16 -- backend/internal/seed/seed.go | 21 ++ backend/internal/service/script.go | 99 +++++-- frontend/src/App.tsx | 19 ++ frontend/src/components/CodeEditor.tsx | 19 +- frontend/src/components/ResultCard.tsx | 49 +--- frontend/src/components/ScriptViewer.tsx | 16 +- frontend/src/lib/api.ts | 106 +++++++- frontend/src/pages/Admin.tsx | 247 +++++++++++++++++ frontend/src/pages/Home.tsx | 39 +-- frontend/src/pages/Login.tsx | 59 +++++ frontend/src/pages/Market.tsx | 32 ++- frontend/src/pages/Register.tsx | 59 +++++ frontend/src/pages/ScriptDetail.tsx | 41 +-- frontend/src/types.ts | 83 +++--- 24 files changed, 1309 insertions(+), 295 deletions(-) create mode 100644 backend/internal/auth/auth.go delete mode 100644 backend/internal/config/runtime.go create mode 100644 backend/internal/middleware/auth.go create mode 100644 backend/internal/model/models.go delete mode 100644 backend/internal/model/script.go create mode 100644 backend/internal/seed/seed.go create mode 100644 frontend/src/pages/Admin.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Register.tsx diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index d0a4af7..c7367ee 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -15,9 +15,11 @@ import ( "gorm.io/gorm" "gitea.kmux.cn/zhilv/scriptforge/internal/assets" + "gitea.kmux.cn/zhilv/scriptforge/internal/auth" "gitea.kmux.cn/zhilv/scriptforge/internal/handler" "gitea.kmux.cn/zhilv/scriptforge/internal/middleware" "gitea.kmux.cn/zhilv/scriptforge/internal/model" + "gitea.kmux.cn/zhilv/scriptforge/internal/seed" "gitea.kmux.cn/zhilv/scriptforge/internal/service" ) @@ -26,13 +28,17 @@ func main() { db := initDB() syncDB(db) + seed.Run(db) + svc := service.NewScriptService(db) + authSvc := auth.NewAuthService(db, getJWTKey()) go cleanupLoop(svc) r := gin.New() r.Use(gin.Recovery(), middleware.Logger()) + // Public API api := r.Group("/api") { api.Use(middleware.RateLimit(60)) @@ -41,6 +47,33 @@ func main() { api.POST("/scripts/:id/publish", handler.PublishScript(svc)) api.DELETE("/scripts/:id", handler.DeleteScript(svc)) api.GET("/market", handler.ListMarket(svc)) + + // Auth + api.POST("/auth/register", handler.Register(authSvc)) + api.POST("/auth/login", handler.Login(authSvc)) + + // Categories (public read) + api.GET("/categories", handler.ListCategories(svc)) + } + + // Authenticated API + authGroup := r.Group("/api") + authGroup.Use(middleware.JWTAuth(authSvc)) + { + authGroup.GET("/auth/me", handler.GetMe(authSvc)) + } + + // Admin API + adminGroup := r.Group("/api/admin") + adminGroup.Use(middleware.JWTAuth(authSvc), middleware.AdminOnly(authSvc)) + { + adminGroup.GET("/categories", handler.ListCategories(svc)) + adminGroup.POST("/categories", handler.CreateCategory(svc)) + adminGroup.PUT("/categories/:id", handler.UpdateCategory(svc)) + adminGroup.DELETE("/categories/:id", handler.DeleteCategory(svc)) + adminGroup.POST("/categories/:id/variants", handler.CreateVariant(svc)) + adminGroup.PUT("/variants/:id", handler.UpdateVariant(svc)) + adminGroup.DELETE("/variants/:id", handler.DeleteVariant(svc)) } r.GET("/raw/:id", handler.GetRawScript(svc)) @@ -77,7 +110,7 @@ func initDB() *gorm.DB { } func syncDB(db *gorm.DB) { - if err := db.AutoMigrate(&model.Script{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.RuntimeCategory{}, &model.RuntimeVariant{}, &model.Script{}); err != nil { log.Fatalf("failed to migrate database: %v", err) } } @@ -93,6 +126,14 @@ func cleanupLoop(svc *service.ScriptService) { } } +func getJWTKey() string { + key := os.Getenv("JWT_KEY") + if key == "" { + key = "scriptforge-dev-key-change-in-production" + } + return key +} + func getPort() string { if p := os.Getenv("PORT"); p != "" { return p @@ -128,4 +169,4 @@ func serveStaticOrSPA(r *gin.Engine) { r.GET("/", func(c *gin.Context) { c.FileFromFS("index.html", http.FS(staticFS)) }) -} +} \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod index a1d2dc7..578f6da 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,6 +4,8 @@ go 1.22 require ( github.com/gin-gonic/gin v1.10.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + golang.org/x/crypto v0.23.0 gorm.io/driver/sqlite v1.5.6 gorm.io/gorm v1.25.11 ) @@ -32,7 +34,6 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.23.0 // indirect golang.org/x/net v0.25.0 // indirect golang.org/x/sys v0.20.0 // indirect golang.org/x/text v0.15.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 0c1d6cc..2200f04 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -25,6 +25,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go new file mode 100644 index 0000000..8aaa0fc --- /dev/null +++ b/backend/internal/auth/auth.go @@ -0,0 +1,122 @@ +package auth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + + "gitea.kmux.cn/zhilv/scriptforge/internal/model" +) + +var ( + ErrUserExists = errors.New("username already exists") + ErrInvalidCredentials = errors.New("invalid username or password") +) + +type AuthService struct { + db *gorm.DB + jwtKey []byte +} + +func NewAuthService(db *gorm.DB, jwtKey string) *AuthService { + return &AuthService{db: db, jwtKey: []byte(jwtKey)} +} + +type Claims struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func (s *AuthService) Register(username, password string) (*model.User, error) { + var existing model.User + if err := s.db.Where("username = ?", username).First(&existing).Error; err == nil { + return nil, ErrUserExists + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + // First user becomes admin + var userCount int64 + s.db.Model(&model.User{}).Count(&userCount) + role := "user" + if userCount == 0 { + role = "admin" + } + + user := model.User{ + Username: username, + PasswordHash: string(hash), + Role: role, + } + + if err := s.db.Create(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (s *AuthService) Login(username, password string) (*model.User, string, error) { + var user model.User + if err := s.db.Where("username = ?", username).First(&user).Error; err != nil { + return nil, "", ErrInvalidCredentials + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return nil, "", ErrInvalidCredentials + } + + token, err := s.GenerateToken(user.ID, user.Username, user.Role) + if err != nil { + return nil, "", err + } + return &user, token, nil +} + +func (s *AuthService) GenerateToken(userID uint, username, role string) (string, error) { + claims := Claims{ + UserID: userID, + Username: username, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(s.jwtKey) +} + +func (s *AuthService) ParseToken(tokenStr string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { + return s.jwtKey, nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*Claims) + if !ok { + return nil, errors.New("invalid claims") + } + return claims, nil +} + +func (s *AuthService) GetUserByID(id uint) (*model.User, error) { + var user model.User + err := s.db.First(&user, id).Error + return &user, err +} + +func (s *AuthService) IsAdmin(userID uint) bool { + var user model.User + if err := s.db.First(&user, userID).Error; err != nil { + return false + } + return user.Role == "admin" +} \ No newline at end of file diff --git a/backend/internal/config/runtime.go b/backend/internal/config/runtime.go deleted file mode 100644 index 1bfbde6..0000000 --- a/backend/internal/config/runtime.go +++ /dev/null @@ -1,58 +0,0 @@ -package config - -type RuntimeConfig struct { - Name string - Extension string - MIMEType string -} - -var Runtimes = []RuntimeConfig{ - {Name: "bash", Extension: ".sh", MIMEType: "text/x-shellscript"}, - {Name: "zsh", Extension: ".zsh", MIMEType: "text/x-shellscript"}, - {Name: "sh", Extension: ".sh", MIMEType: "text/x-shellscript"}, - {Name: "fish", Extension: ".fish", MIMEType: "text/x-shellscript"}, - {Name: "python3", Extension: ".py", MIMEType: "text/x-python"}, - {Name: "node", Extension: ".js", MIMEType: "text/javascript"}, - {Name: "ruby", Extension: ".rb", MIMEType: "text/x-ruby"}, - {Name: "php", Extension: ".php", MIMEType: "text/x-php"}, -} - -var RuntimeMap map[string]RuntimeConfig - -func init() { - RuntimeMap = make(map[string]RuntimeConfig, len(Runtimes)) - for _, rt := range Runtimes { - RuntimeMap[rt.Name] = rt - } -} - -func IsValidRuntime(name string) bool { - _, ok := RuntimeMap[name] - return ok -} - -func GetRuntime(name string) (RuntimeConfig, bool) { - rt, ok := RuntimeMap[name] - return rt, ok -} - -func IsShellRuntime(name string) bool { - switch name { - case "bash", "zsh", "sh", "fish": - return true - } - return false -} - -func GetSourceCommand(url, runtime string) string { - switch runtime { - case "bash", "zsh": - return "source <(curl " + url + ")" - case "sh": - return ". <(curl " + url + ")" - case "fish": - return "curl " + url + " | source" - default: - return "" - } -} diff --git a/backend/internal/handler/raw.go b/backend/internal/handler/raw.go index 5dd2c84..0a2f29e 100644 --- a/backend/internal/handler/raw.go +++ b/backend/internal/handler/raw.go @@ -7,7 +7,6 @@ import ( "github.com/gin-gonic/gin" - "gitea.kmux.cn/zhilv/scriptforge/internal/config" "gitea.kmux.cn/zhilv/scriptforge/internal/service" ) @@ -25,12 +24,13 @@ func GetRawScript(svc *service.ScriptService) gin.HandlerFunc { return } - rt, _ := config.GetRuntime(script.Runtime) - mime := rt.MIMEType - ext := rt.Extension - if mime == "" { - mime = "text/plain" - ext = ".sh" + // Use default variant for MIME/extension + variant, verr := svc.GetDefaultVariant(script.CategoryID) + mime := "text/plain" + ext := ".sh" + if verr == nil { + mime = variant.MIMEType + ext = variant.Extension } c.Header("Content-Type", mime+"; charset=utf-8") @@ -38,4 +38,4 @@ func GetRawScript(svc *service.ScriptService) gin.HandlerFunc { c.Header("X-Content-Type-Options", "nosniff") c.String(http.StatusOK, script.Content) } -} +} \ No newline at end of file diff --git a/backend/internal/handler/script.go b/backend/internal/handler/script.go index 1f8254e..eb9cbda 100644 --- a/backend/internal/handler/script.go +++ b/backend/internal/handler/script.go @@ -8,14 +8,111 @@ import ( "github.com/gin-gonic/gin" + "gitea.kmux.cn/zhilv/scriptforge/internal/auth" + "gitea.kmux.cn/zhilv/scriptforge/internal/model" "gitea.kmux.cn/zhilv/scriptforge/internal/service" ) +func getUserID(c *gin.Context) uint { + val, exists := c.Get("user_id") + if !exists { + return 0 + } + switch v := val.(type) { + case uint: + return v + case float64: + return uint(v) + default: + return 0 + } +} + +// --- Auth handlers --- + +type registerRequest struct { + Username string `json:"username" binding:"required,min=3,max=32"` + Password string `json:"password" binding:"required,min=6"` +} + +func Register(authSvc *auth.AuthService) gin.HandlerFunc { + return func(c *gin.Context) { + var req registerRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + user, err := authSvc.Register(req.Username, req.Password) + if errors.Is(err, auth.ErrUserExists) { + c.JSON(http.StatusConflict, gin.H{"error": "username already exists"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + token, _ := authSvc.GenerateToken(user.ID, user.Username, user.Role) + c.JSON(http.StatusCreated, gin.H{ + "id": user.ID, + "username": user.Username, + "role": user.Role, + "token": token, + }) + } +} + +func Login(authSvc *auth.AuthService) gin.HandlerFunc { + return func(c *gin.Context) { + var req registerRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + user, token, err := authSvc.Login(req.Username, req.Password) + if errors.Is(err, auth.ErrInvalidCredentials) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "id": user.ID, + "username": user.Username, + "role": user.Role, + "token": token, + }) + } +} + +func GetMe(authSvc *auth.AuthService) gin.HandlerFunc { + return func(c *gin.Context) { + userID := getUserID(c) + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) + return + } + user, err := authSvc.GetUserByID(userID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "id": user.ID, + "username": user.Username, + "role": user.Role, + }) + } +} + +// --- Script handlers --- + type createRequest struct { Title string `json:"title" binding:"required,max=128"` Description string `json:"description" binding:"max=512"` Content string `json:"content" binding:"required,max=16384"` - Runtime string `json:"runtime" binding:"required"` + CategoryID uint `json:"category_id" binding:"required"` ExpiresIn string `json:"expires_in" binding:"required,oneof=1h 24h 7d 30d"` Publish bool `json:"publish"` } @@ -33,17 +130,20 @@ func CreateScript(svc *service.ScriptService) gin.HandlerFunc { scheme = "http" } + userID := getUserID(c) + result, err := svc.Create(service.CreateInput{ Title: req.Title, Description: req.Description, Content: req.Content, - Runtime: req.Runtime, + CategoryID: req.CategoryID, ExpiresIn: req.ExpiresIn, Publish: req.Publish, + UserID: userID, }, scheme, c.Request.Host) - if errors.Is(err, service.ErrInvalidRuntime) { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid runtime"}) + if errors.Is(err, service.ErrInvalidCategory) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid category"}) return } if err != nil { @@ -52,16 +152,14 @@ func CreateScript(svc *service.ScriptService) gin.HandlerFunc { } c.JSON(http.StatusCreated, gin.H{ - "id": result.Script.ID, - "title": result.Script.Title, - "description": result.Script.Description, - "admin_token": result.AdminToken, - "url": result.RawURL, - "command": result.Command, - "source_command": result.SourceCmd, - "runtime": result.Script.Runtime, - "status": result.Script.Status, - "expires_at": result.Script.ExpiresAt, + "id": result.Script.ID, + "title": result.Script.Title, + "description": result.Script.Description, + "category_id": result.Script.CategoryID, + "admin_token": result.AdminToken, + "url": result.RawURL, + "status": result.Script.Status, + "expires_at": result.Script.ExpiresAt, }) } } @@ -80,15 +178,50 @@ func GetScript(svc *service.ScriptService) gin.HandlerFunc { return } + cat, _ := svc.GetCategoryByID(script.CategoryID) + variants, _ := svc.GetVariants(script.CategoryID) + + scheme := "https" + if strings.HasPrefix(c.Request.Host, "localhost") || strings.HasPrefix(c.Request.Host, "127.0.0.1") { + scheme = "http" + } + fullURL := scheme + "://" + c.Request.Host + "/raw/" + script.ID + + var commands []gin.H + for _, v := range variants { + cmd := strings.ReplaceAll(v.CommandTemplate, "{url}", fullURL) + entry := gin.H{ + "variant": v.Name, + "label": v.Label, + "command": cmd, + } + if v.SourceTemplate != "" { + entry["source_command"] = strings.ReplaceAll(v.SourceTemplate, "{url}", fullURL) + } + commands = append(commands, entry) + } + + categoryName := "" + categoryLabel := "" + categoryIcon := "" + if cat != nil { + categoryName = cat.Name + categoryLabel = cat.Label + categoryIcon = cat.Icon + } + c.JSON(http.StatusOK, gin.H{ "id": script.ID, "title": script.Title, "description": script.Description, - "runtime": script.Runtime, + "category_id": script.CategoryID, + "category_name": categoryName, + "category_label": categoryLabel, + "category_icon": categoryIcon, "content": script.Content, "content_length": len(script.Content), "status": script.Status, - "created_at": script.CreatedAt, + "commands": commands, "expires_at": script.ExpiresAt, "published_at": script.PublishedAt, "expired": false, @@ -101,17 +234,17 @@ func PublishScript(svc *service.ScriptService) gin.HandlerFunc { id := c.Param("id") token := c.Query("token") if token == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "token query parameter is required"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "token required"}) return } script, err := svc.Publish(id, token) if errors.Is(err, service.ErrNotFound) { - c.JSON(http.StatusNotFound, gin.H{"error": "script not found or invalid token"}) + c.JSON(http.StatusNotFound, gin.H{"error": "not found or invalid token"}) return } if errors.Is(err, service.ErrAlreadyPublished) { - c.JSON(http.StatusBadRequest, gin.H{"error": "script already published"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "already published"}) return } if err != nil { @@ -119,11 +252,27 @@ func PublishScript(svc *service.ScriptService) gin.HandlerFunc { return } - c.JSON(http.StatusOK, gin.H{ - "id": script.ID, - "status": script.Status, - "published_at": script.PublishedAt, - }) + c.JSON(http.StatusOK, gin.H{"id": script.ID, "status": script.Status, "published_at": script.PublishedAt}) + } +} + +func DeleteScript(svc *service.ScriptService) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + token := c.Query("token") + if token == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "token required"}) + return + } + + if err := svc.Delete(id, token); errors.Is(err, service.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } else if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + c.Status(http.StatusNoContent) } } @@ -131,14 +280,14 @@ func ListMarket(svc *service.ScriptService) gin.HandlerFunc { return func(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) - runtime := c.Query("runtime") + catID, _ := strconv.Atoi(c.DefaultQuery("category_id", "0")) search := c.Query("search") result, err := svc.ListMarket(service.MarketQuery{ - Page: page, - PerPage: perPage, - Runtime: runtime, - Search: search, + Page: page, + PerPage: perPage, + CategoryID: uint(catID), + Search: search, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) @@ -147,12 +296,16 @@ func ListMarket(svc *service.ScriptService) gin.HandlerFunc { items := make([]gin.H, len(result.Items)) for i, s := range result.Items { + cat, _ := svc.GetCategoryByID(s.CategoryID) items[i] = gin.H{ - "id": s.ID, - "title": s.Title, - "description": s.Description, - "runtime": s.Runtime, - "published_at": s.PublishedAt, + "id": s.ID, + "title": s.Title, + "description": s.Description, + "category_id": s.CategoryID, + "category_name": cat.Name, + "category_label": cat.Label, + "category_icon": cat.Icon, + "published_at": s.PublishedAt, } } @@ -165,23 +318,101 @@ func ListMarket(svc *service.ScriptService) gin.HandlerFunc { } } -func DeleteScript(svc *service.ScriptService) gin.HandlerFunc { - return func(c *gin.Context) { - id := c.Param("id") - token := c.Query("token") - if token == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "token query parameter is required"}) - return - } +// --- Admin: Runtime management --- - if err := svc.Delete(id, token); errors.Is(err, service.ErrNotFound) { - c.JSON(http.StatusNotFound, gin.H{"error": "script not found or invalid token"}) - return - } else if err != nil { +func ListCategories(svc *service.ScriptService) gin.HandlerFunc { + return func(c *gin.Context) { + cats, err := svc.ListCategories() + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } + c.JSON(http.StatusOK, gin.H{"categories": cats}) + } +} +func CreateCategory(svc *service.ScriptService) gin.HandlerFunc { + return func(c *gin.Context) { + var cat model.RuntimeCategory + if err := c.ShouldBindJSON(&cat); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := svc.CreateCategory(&cat); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + c.JSON(http.StatusCreated, gin.H{"category": cat}) + } +} + +func UpdateCategory(svc *service.ScriptService) gin.HandlerFunc { + return func(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var cat model.RuntimeCategory + cat.ID = uint(id) + if err := c.ShouldBindJSON(&cat); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := svc.UpdateCategory(&cat); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + c.JSON(http.StatusOK, gin.H{"category": cat}) + } +} + +func DeleteCategory(svc *service.ScriptService) gin.HandlerFunc { + return func(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + if err := svc.DeleteCategory(uint(id)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + c.Status(http.StatusNoContent) + } +} + +func CreateVariant(svc *service.ScriptService) gin.HandlerFunc { + return func(c *gin.Context) { + var v model.RuntimeVariant + if err := c.ShouldBindJSON(&v); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := svc.CreateVariant(&v); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + c.JSON(http.StatusCreated, gin.H{"variant": v}) + } +} + +func UpdateVariant(svc *service.ScriptService) gin.HandlerFunc { + return func(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var v model.RuntimeVariant + v.ID = uint(id) + if err := c.ShouldBindJSON(&v); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := svc.UpdateVariant(&v); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + c.JSON(http.StatusOK, gin.H{"variant": v}) + } +} + +func DeleteVariant(svc *service.ScriptService) gin.HandlerFunc { + return func(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + if err := svc.DeleteVariant(uint(id)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } c.Status(http.StatusNoContent) } } \ No newline at end of file diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..5fff4bd --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -0,0 +1,54 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "gitea.kmux.cn/zhilv/scriptforge/internal/auth" +) + +func JWTAuth(authSvc *auth.AuthService) gin.HandlerFunc { + return func(c *gin.Context) { + tokenStr := "" + + // Check Authorization header + authHeader := c.GetHeader("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + tokenStr = strings.TrimPrefix(authHeader, "Bearer ") + } + + // Also check query param for convenience + if tokenStr == "" { + tokenStr = c.Query("token") + } + + if tokenStr == "" { + c.Next() // No token, proceed as anonymous + return + } + + claims, err := authSvc.ParseToken(tokenStr) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + c.Set("user_id", claims.UserID) + c.Set("username", claims.Username) + c.Set("role", claims.Role) + c.Next() + } +} + +func AdminOnly(authSvc *auth.AuthService) gin.HandlerFunc { + return func(c *gin.Context) { + role, exists := c.Get("role") + if !exists || role != "admin" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin only"}) + return + } + c.Next() + } +} \ No newline at end of file diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go new file mode 100644 index 0000000..e631db2 --- /dev/null +++ b/backend/internal/model/models.go @@ -0,0 +1,76 @@ +package model + +import "time" + +type User struct { + ID uint `gorm:"primaryKey;autoIncrement"` + Username string `gorm:"uniqueIndex;size:32;not null"` + PasswordHash string `gorm:"size:128;not null"` + Email string `gorm:"size:128"` + Role string `gorm:"size:16;not null;default:user"` + GiteaID int `gorm:"uniqueIndex"` + CreatedAt time.Time `gorm:"autoCreateTime"` +} + +type RuntimeCategory struct { + ID uint `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"uniqueIndex;size:32;not null"` + Label string `gorm:"size:64;not null"` + Icon string `gorm:"size:8"` + SortOrder int `gorm:"default:0"` + Variants []RuntimeVariant `gorm:"foreignKey:CategoryID"` +} + +type RuntimeVariant struct { + ID uint `gorm:"primaryKey;autoIncrement"` + CategoryID uint `gorm:"not null;index"` + Name string `gorm:"size:32;not null"` + Label string `gorm:"size:64;not null"` + Extension string `gorm:"size:8;not null"` + MIMEType string `gorm:"size:64;not null"` + CommandTemplate string `gorm:"size:128;not null"` + SourceTemplate string `gorm:"size:128"` + IsDefault bool `gorm:"default:false"` + SortOrder int `gorm:"default:0"` +} + +type Script struct { + ID string `gorm:"primaryKey;size:8"` + Title string `gorm:"size:128;not null"` + Description string `gorm:"size:512"` + Content string `gorm:"type:text;not null"` + CategoryID uint `gorm:"not null;index"` + UserID uint `gorm:"index"` + AdminToken string `gorm:"size:64;not null"` + Status string `gorm:"size:16;not null;default:draft;index"` + ExpiresAt time.Time `gorm:"not null;index"` + CreatedAt time.Time `gorm:"not null;autoCreateTime"` + PublishedAt *time.Time `gorm:"index"` +} + +// Default seed data +var DefaultCategories = []RuntimeCategory{ + {Name: "shell", Label: "Shell 脚本", Icon: "🐚", SortOrder: 1}, + {Name: "python", Label: "Python", Icon: "🐍", SortOrder: 2}, + {Name: "javascript", Label: "JavaScript", Icon: "🟨", SortOrder: 3}, + {Name: "ruby", Label: "Ruby", Icon: "💎", SortOrder: 4}, + {Name: "php", Label: "PHP", Icon: "🐘", SortOrder: 5}, +} + +var DefaultVariants = []RuntimeVariant{ + // Shell variants + {CategoryID: 1, Name: "bash", Label: "Bash", Extension: ".sh", MIMEType: "text/x-shellscript", CommandTemplate: "curl {url} | bash", SourceTemplate: "source <(curl {url})", IsDefault: true, SortOrder: 1}, + {CategoryID: 1, Name: "zsh", Label: "Zsh", Extension: ".zsh", MIMEType: "text/x-shellscript", CommandTemplate: "curl {url} | zsh", SourceTemplate: "source <(curl {url})", SortOrder: 2}, + {CategoryID: 1, Name: "sh", Label: "Sh", Extension: ".sh", MIMEType: "text/x-shellscript", CommandTemplate: "curl {url} | sh", SourceTemplate: ". <(curl {url})", SortOrder: 3}, + {CategoryID: 1, Name: "fish", Label: "Fish", Extension: ".fish", MIMEType: "text/x-shellscript", CommandTemplate: "curl {url} | fish", SourceTemplate: "curl {url} | source", SortOrder: 4}, + // Python variants + {CategoryID: 2, Name: "python3", Label: "Python 3", Extension: ".py", MIMEType: "text/x-python", CommandTemplate: "curl {url} | python3", IsDefault: true, SortOrder: 1}, + {CategoryID: 2, Name: "python", Label: "Python", Extension: ".py", MIMEType: "text/x-python", CommandTemplate: "curl {url} | python", SortOrder: 2}, + // JavaScript variants + {CategoryID: 3, Name: "node", Label: "Node.js", Extension: ".js", MIMEType: "text/javascript", CommandTemplate: "curl {url} | node", IsDefault: true, SortOrder: 1}, + {CategoryID: 3, Name: "bun", Label: "Bun", Extension: ".js", MIMEType: "text/javascript", CommandTemplate: "curl {url} | bun", SortOrder: 2}, + // Ruby variants + {CategoryID: 4, Name: "ruby", Label: "Ruby", Extension: ".rb", MIMEType: "text/x-ruby", CommandTemplate: "curl {url} | ruby", IsDefault: true, SortOrder: 1}, + // PHP variants + {CategoryID: 5, Name: "php", Label: "PHP", Extension: ".php", MIMEType: "text/x-php", CommandTemplate: "curl {url} | php", IsDefault: true, SortOrder: 1}, +} \ No newline at end of file diff --git a/backend/internal/model/script.go b/backend/internal/model/script.go deleted file mode 100644 index 227abc7..0000000 --- a/backend/internal/model/script.go +++ /dev/null @@ -1,16 +0,0 @@ -package model - -import "time" - -type Script struct { - ID string `gorm:"primaryKey;size:8"` - Title string `gorm:"size:128;not null"` - Description string `gorm:"size:512"` - Content string `gorm:"type:text;not null"` - Runtime string `gorm:"size:16;not null;index"` - AdminToken string `gorm:"size:64;not null"` - Status string `gorm:"size:16;not null;default:draft;index"` - ExpiresAt time.Time `gorm:"not null;index"` - CreatedAt time.Time `gorm:"not null;autoCreateTime"` - PublishedAt *time.Time `gorm:"index"` -} \ No newline at end of file diff --git a/backend/internal/seed/seed.go b/backend/internal/seed/seed.go new file mode 100644 index 0000000..105cb08 --- /dev/null +++ b/backend/internal/seed/seed.go @@ -0,0 +1,21 @@ +package seed + +import ( + "gorm.io/gorm" + "gitea.kmux.cn/zhilv/scriptforge/internal/model" +) + +func Run(db *gorm.DB) { + // Seed categories only if table is empty + var count int64 + db.Model(&model.RuntimeCategory{}).Count(&count) + if count == 0 { + for i, cat := range model.DefaultCategories { + cat.ID = uint(i + 1) + db.Create(&cat) + } + for _, v := range model.DefaultVariants { + db.Create(&v) + } + } +} \ No newline at end of file diff --git a/backend/internal/service/script.go b/backend/internal/service/script.go index b75baf7..c83093b 100644 --- a/backend/internal/service/script.go +++ b/backend/internal/service/script.go @@ -2,19 +2,19 @@ package service import ( "errors" + "fmt" "time" "gorm.io/gorm" - "gitea.kmux.cn/zhilv/scriptforge/internal/config" "gitea.kmux.cn/zhilv/scriptforge/internal/idgen" "gitea.kmux.cn/zhilv/scriptforge/internal/model" ) var ( - ErrInvalidRuntime = errors.New("invalid runtime") - ErrNotFound = errors.New("script not found or expired") + ErrNotFound = errors.New("script not found or expired") ErrAlreadyPublished = errors.New("script already published") + ErrInvalidCategory = errors.New("invalid category") ) type ScriptService struct { @@ -29,22 +29,22 @@ type CreateInput struct { Title string Description string Content string - Runtime string + CategoryID uint ExpiresIn string Publish bool + UserID uint } type CreateResult struct { Script model.Script AdminToken string - Command string - SourceCmd string RawURL string } func (s *ScriptService) Create(input CreateInput, scheme, host string) (*CreateResult, error) { - if !config.IsValidRuntime(input.Runtime) { - return nil, ErrInvalidRuntime + var cat model.RuntimeCategory + if err := s.db.First(&cat, input.CategoryID).Error; err != nil { + return nil, ErrInvalidCategory } var d time.Duration @@ -84,11 +84,11 @@ func (s *ScriptService) Create(input CreateInput, scheme, host string) (*CreateR Title: input.Title, Description: input.Description, Content: input.Content, - Runtime: input.Runtime, + CategoryID: input.CategoryID, + UserID: input.UserID, AdminToken: adminToken, Status: status, ExpiresAt: time.Now().Add(d), - CreatedAt: time.Now(), PublishedAt: publishedAt, } @@ -97,14 +97,10 @@ func (s *ScriptService) Create(input CreateInput, scheme, host string) (*CreateR } rawURL := scheme + "://" + host + "/raw/" + id - command := "curl " + rawURL + " | " + input.Runtime - sourceCmd := config.GetSourceCommand(rawURL, input.Runtime) return &CreateResult{ Script: script, AdminToken: adminToken, - Command: command, - SourceCmd: sourceCmd, RawURL: rawURL, }, nil } @@ -118,6 +114,27 @@ func (s *ScriptService) GetByID(id string) (*model.Script, error) { return &script, err } +func (s *ScriptService) GetCategoryByID(id uint) (*model.RuntimeCategory, error) { + var cat model.RuntimeCategory + err := s.db.Preload("Variants").First(&cat, id).Error + return &cat, err +} + +func (s *ScriptService) GetVariants(categoryID uint) ([]model.RuntimeVariant, error) { + var variants []model.RuntimeVariant + err := s.db.Where("category_id = ?", categoryID).Order("sort_order ASC").Find(&variants).Error + return variants, err +} + +func (s *ScriptService) GetDefaultVariant(categoryID uint) (*model.RuntimeVariant, error) { + var v model.RuntimeVariant + err := s.db.Where("category_id = ? AND is_default = true", categoryID).First(&v).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + err = s.db.Where("category_id = ?", categoryID).Order("sort_order ASC").First(&v).Error + } + return &v, err +} + func (s *ScriptService) Publish(id, token string) (*model.Script, error) { var script model.Script err := s.db.Where("id = ? AND admin_token = ? AND expires_at > ?", id, token, time.Now()).First(&script).Error @@ -134,7 +151,6 @@ func (s *ScriptService) Publish(id, token string) (*model.Script, error) { now := time.Now() script.Status = "published" script.PublishedAt = &now - if err := s.db.Save(&script).Error; err != nil { return nil, err } @@ -142,16 +158,16 @@ func (s *ScriptService) Publish(id, token string) (*model.Script, error) { } type MarketQuery struct { - Page int - PerPage int - Runtime string - Search string + Page int + PerPage int + CategoryID uint + Search string } type MarketResult struct { - Items []model.Script - Total int64 - Page int + Items []model.Script + Total int64 + Page int PerPage int } @@ -166,8 +182,8 @@ func (s *ScriptService) ListMarket(q MarketQuery) (*MarketResult, error) { query := s.db.Model(&model.Script{}). Where("status = ? AND expires_at > ?", "published", time.Now()) - if q.Runtime != "" && config.IsValidRuntime(q.Runtime) { - query = query.Where("runtime = ?", q.Runtime) + if q.CategoryID > 0 { + query = query.Where("category_id = ?", q.CategoryID) } if q.Search != "" { search := "%" + q.Search + "%" @@ -202,4 +218,39 @@ func (s *ScriptService) Delete(id, token string) error { func (s *ScriptService) CleanupExpired() (int64, error) { result := s.db.Where("expires_at <= ?", time.Now()).Delete(&model.Script{}) return result.RowsAffected, result.Error +} + +func (s *ScriptService) ListCategories() ([]model.RuntimeCategory, error) { + var cats []model.RuntimeCategory + err := s.db.Preload("Variants").Order("sort_order ASC").Find(&cats).Error + return cats, err +} + +func (s *ScriptService) CreateCategory(cat *model.RuntimeCategory) error { + return s.db.Create(cat).Error +} + +func (s *ScriptService) UpdateCategory(cat *model.RuntimeCategory) error { + return s.db.Save(cat).Error +} + +func (s *ScriptService) DeleteCategory(id uint) error { + s.db.Where("category_id = ?", id).Delete(&model.RuntimeVariant{}) + return s.db.Delete(&model.RuntimeCategory{}, id).Error +} + +func (s *ScriptService) CreateVariant(v *model.RuntimeVariant) error { + return s.db.Create(v).Error +} + +func (s *ScriptService) UpdateVariant(v *model.RuntimeVariant) error { + return s.db.Save(v).Error +} + +func (s *ScriptService) DeleteVariant(id uint) error { + return s.db.Delete(&model.RuntimeVariant{}, id).Error +} + +func FormatCommand(template, url string) string { + return fmt.Sprintf(template, url) } \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8999f2a..d5a6639 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,8 +3,14 @@ import Home from './pages/Home' import ScriptDetail from './pages/ScriptDetail' import DeleteScript from './pages/DeleteScript' import Market from './pages/Market' +import Login from './pages/Login' +import Register from './pages/Register' +import Admin from './pages/Admin' +import { isLoggedIn } from './lib/api' export default function App() { + const loggedIn = isLoggedIn() + return (
@@ -15,6 +21,16 @@ export default function App() {
@@ -25,6 +41,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> diff --git a/frontend/src/components/CodeEditor.tsx b/frontend/src/components/CodeEditor.tsx index 13a1d16..c59a98f 100644 --- a/frontend/src/components/CodeEditor.tsx +++ b/frontend/src/components/CodeEditor.tsx @@ -11,19 +11,18 @@ import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete' import { lintKeymap } from '@codemirror/lint' import { searchKeymap, highlightSelectionMatches } from '@codemirror/search' -// Legacy mode imports - use require-style to avoid TS path issues // @ts-ignore import { shell } from '@codemirror/legacy-modes/mode/shell' // @ts-ignore import { ruby } from '@codemirror/legacy-modes/mode/ruby' -function getLanguageExtension(runtime: string) { - switch (runtime) { - case 'bash': case 'zsh': case 'sh': case 'fish': +function getLanguageExtension(categoryName: string) { + switch (categoryName) { + case 'shell': return StreamLanguage.define(shell) - case 'python3': + case 'python': return python() - case 'node': + case 'javascript': return javascript() case 'ruby': return StreamLanguage.define(ruby) @@ -37,10 +36,10 @@ function getLanguageExtension(runtime: string) { interface Props { value: string onChange: (value: string) => void - runtime: string + categoryName: string } -export default function CodeEditor({ value, onChange, runtime }: Props) { +export default function CodeEditor({ value, onChange, categoryName }: Props) { const ref = useRef(null) const viewRef = useRef(null) @@ -67,7 +66,7 @@ export default function CodeEditor({ value, onChange, runtime }: Props) { ...lintKeymap, indentWithTab, ]), - getLanguageExtension(runtime), + getLanguageExtension(categoryName), oneDark, EditorView.updateListener.of((update) => { if (update.docChanged) { @@ -88,7 +87,7 @@ export default function CodeEditor({ value, onChange, runtime }: Props) { view.destroy() viewRef.current = null } - }, [runtime]) + }, [categoryName]) useEffect(() => { const view = viewRef.current diff --git a/frontend/src/components/ResultCard.tsx b/frontend/src/components/ResultCard.tsx index 19719fb..c7edcdb 100644 --- a/frontend/src/components/ResultCard.tsx +++ b/frontend/src/components/ResultCard.tsx @@ -1,5 +1,5 @@ -import { CreateScriptResponse, isShellRuntime, getSourceCommand } from '../types' -import CommandCard from './CommandCard' +import { CreateScriptResponse } from '../types' +import { Link } from 'react-router-dom' interface Props { result: CreateScriptResponse @@ -7,10 +7,6 @@ interface Props { } export default function ResultCard({ result, onReset }: Props) { - const detailUrl = `${window.location.origin}/s/${result.id}` - const showSource = isShellRuntime(result.runtime) - const sourceCommand = showSource ? getSourceCommand(result.url, result.runtime) : null - return (
@@ -20,21 +16,6 @@ export default function ResultCard({ result, onReset }: Props) {
- - - {showSource && sourceCommand && ( -
-

- 如果脚本设置了环境变量(如代理),请使用以下命令在当前 shell 中执行: -

- -
- )} -
标题 @@ -45,8 +26,10 @@ export default function ResultCard({ result, onReset }: Props) { {result.id}
- 运行环境 - {result.runtime} + 运行链接 + + {result.url} +
状态 @@ -73,24 +56,12 @@ export default function ResultCard({ result, onReset }: Props) { > 创建另一个 - - 查看详情 - - {result.status === 'published' && ( - - 查看市场 - - )} + 查看详情和命令 +
) diff --git a/frontend/src/components/ScriptViewer.tsx b/frontend/src/components/ScriptViewer.tsx index 1c80794..45f6bc6 100644 --- a/frontend/src/components/ScriptViewer.tsx +++ b/frontend/src/components/ScriptViewer.tsx @@ -1,13 +1,21 @@ interface Props { content: string - runtime: string + categoryName: string } -export default function ScriptViewer({ content, runtime }: Props) { +const extMap: Record = { + shell: 'sh', + python: 'py', + javascript: 'js', + ruby: 'rb', + php: 'php', +} + +export default function ScriptViewer({ content, categoryName }: Props) { return (
- script.{runtime === 'node' ? 'js' : runtime === 'python3' ? 'py' : runtime} + script.{extMap[categoryName] || categoryName}
) -} +} \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 16b5c25..f2a2584 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,4 +1,4 @@ -import { CreateScriptResponse, ScriptDetail, RuntimeOption, ExpiresIn, MarketResponse } from '../types' +import { CreateScriptResponse, ScriptDetail, ExpiresIn, MarketResponse, RuntimeCategory, RuntimeVariant, AuthResponse } from '../types' async function request(url: string, options?: RequestInit): Promise { const res = await fetch(url, { @@ -12,16 +12,21 @@ async function request(url: string, options?: RequestInit): Promise { return res.json() } +function getToken(): string { + return localStorage.getItem('token') || '' +} + export async function createScript(params: { title: string description?: string content: string - runtime: RuntimeOption + category_id: number expires_in: ExpiresIn publish: boolean }): Promise { return request('/api/scripts', { method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${getToken()}` }, body: JSON.stringify(params), }) } @@ -40,22 +45,109 @@ export async function deleteScript(id: string, token: string): Promise { await fetch(`/api/scripts/${id}?token=${encodeURIComponent(token)}`, { method: 'DELETE', }).then((res) => { - if (!res.ok && res.status !== 204) { - throw new Error('delete failed') - } + if (!res.ok && res.status !== 204) throw new Error('delete failed') }) } export async function listMarket(params: { page?: number per_page?: number - runtime?: string + category_id?: number search?: string }): Promise { const query = new URLSearchParams() if (params.page) query.set('page', String(params.page)) if (params.per_page) query.set('per_page', String(params.per_page)) - if (params.runtime) query.set('runtime', params.runtime) + if (params.category_id) query.set('category_id', String(params.category_id)) if (params.search) query.set('search', params.search) return request(`/api/market?${query.toString()}`) +} + +export async function listCategories(): Promise { + const res = await request<{ categories: RuntimeCategory[] }>('/api/categories') + return res.categories || [] +} + +export async function register(username: string, password: string): Promise { + const res = await request('/api/auth/register', { + method: 'POST', + body: JSON.stringify({ username, password }), + }) + localStorage.setItem('token', res.token) + return res +} + +export async function login(username: string, password: string): Promise { + const res = await request('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }), + }) + localStorage.setItem('token', res.token) + return res +} + +export async function getMe(): Promise<{ id: number; username: string; role: string }> { + return request('/api/auth/me', { + headers: { 'Authorization': `Bearer ${getToken()}` }, + }) +} + +export function isLoggedIn(): boolean { + return !!localStorage.getItem('token') +} + +export function logout(): void { + localStorage.removeItem('token') +} + +// --- Admin API --- + +export async function adminCreateCategory(cat: Partial): Promise { + const res = await request<{ category: RuntimeCategory }>('/api/admin/categories', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${getToken()}` }, + body: JSON.stringify(cat), + }) + return res.category +} + +export async function adminUpdateCategory(id: number, cat: Partial): Promise { + const res = await request<{ category: RuntimeCategory }>(`/api/admin/categories/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${getToken()}` }, + body: JSON.stringify(cat), + }) + return res.category +} + +export async function adminDeleteCategory(id: number): Promise { + await request(`/api/admin/categories/${id}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${getToken()}` }, + }) +} + +export async function adminCreateVariant(categoryId: number, variant: Partial): Promise { + const res = await request<{ variant: RuntimeVariant }>(`/api/admin/categories/${categoryId}/variants`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${getToken()}` }, + body: JSON.stringify(variant), + }) + return res.variant +} + +export async function adminUpdateVariant(id: number, variant: Partial): Promise { + const res = await request<{ variant: RuntimeVariant }>(`/api/admin/variants/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${getToken()}` }, + body: JSON.stringify(variant), + }) + return res.variant +} + +export async function adminDeleteVariant(id: number): Promise { + await request(`/api/admin/variants/${id}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${getToken()}` }, + }) } \ No newline at end of file diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx new file mode 100644 index 0000000..698890d --- /dev/null +++ b/frontend/src/pages/Admin.tsx @@ -0,0 +1,247 @@ +import { useState, useEffect } from 'react' +import { listCategories, getMe, logout, adminCreateCategory, adminUpdateCategory, adminDeleteCategory, adminCreateVariant, adminUpdateVariant, adminDeleteVariant } from '../lib/api' +import { RuntimeCategory, RuntimeVariant } from '../types' + +export default function Admin() { + const [categories, setCategories] = useState([]) + const [user, setUser] = useState<{ id: number; username: string; role: string } | null>(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Category form + const [editingCat, setEditingCat] = useState(null) + const [catName, setCatName] = useState('') + const [catLabel, setCatLabel] = useState('') + const [catIcon, setCatIcon] = useState('') + const [catOrder, setCatOrder] = useState(0) + + // Variant form + const [selectedCatId, setSelectedCatId] = useState(0) + const [editingVariant, setEditingVariant] = useState(null) + const [vName, setVName] = useState('') + const [vLabel, setVLabel] = useState('') + const [vExt, setVExt] = useState('') + const [vMime, setVMime] = useState('') + const [vCmd, setVCmd] = useState('') + const [vSrcCmd, setVSrcCmd] = useState('') + const [vDefault, setVDefault] = useState(false) + const [vOrder, setVOrder] = useState(0) + + useEffect(() => { + Promise.all([ + getMe().catch(() => null), + listCategories().catch(() => [] as RuntimeCategory[]), + ]).then(([u, cats]) => { + setUser(u) + setCategories(cats) + setLoading(false) + if (!u || u.role !== 'admin') setError('无权访问管理后台') + }) + }, []) + + const refresh = () => listCategories().then(setCategories) + + const resetCatForm = () => { + setEditingCat(null) + setCatName('') + setCatLabel('') + setCatIcon('') + setCatOrder(0) + } + + const editCat = (cat: RuntimeCategory) => { + setEditingCat(cat) + setCatName(cat.Name) + setCatLabel(cat.Label) + setCatIcon(cat.Icon) + setCatOrder(cat.SortOrder) + } + + const saveCategory = async () => { + try { + if (editingCat) { + await adminUpdateCategory(editingCat.ID, { Name: catName, Label: catLabel, Icon: catIcon, SortOrder: catOrder }) + } else { + await adminCreateCategory({ Name: catName, Label: catLabel, Icon: catIcon, SortOrder: catOrder }) + } + resetCatForm() + await refresh() + } catch (e) { + alert('操作失败') + } + } + + const deleteCategory = async (id: number) => { + if (!confirm('确定删除此分类?相关变体也会被删除。')) return + try { + await adminDeleteCategory(id) + await refresh() + } catch { alert('删除失败') } + } + + const resetVForm = () => { + setEditingVariant(null) + setVName('') + setVLabel('') + setVExt('') + setVMime('') + setVCmd('') + setVSrcCmd('') + setVDefault(false) + setVOrder(0) + } + + const editVariant = (v: RuntimeVariant) => { + setEditingVariant(v) + setSelectedCatId(v.CategoryID) + setVName(v.Name) + setVLabel(v.Label) + setVExt(v.Extension) + setVMime(v.MIMEType) + setVCmd(v.CommandTemplate) + setVSrcCmd(v.SourceTemplate) + setVDefault(v.IsDefault) + setVOrder(v.SortOrder) + } + + const saveVariant = async () => { + try { + const payload = { + Name: vName, + Label: vLabel, + Extension: vExt, + MIMEType: vMime, + CommandTemplate: vCmd, + SourceTemplate: vSrcCmd, + IsDefault: vDefault, + SortOrder: vOrder, + } + if (editingVariant) { + await adminUpdateVariant(editingVariant.ID, payload) + } else { + await adminCreateVariant(selectedCatId, payload) + } + resetVForm() + await refresh() + } catch { alert('操作失败') } + } + + const deleteVariant = async (id: number) => { + if (!confirm('确定删除此变体?')) return + try { + await adminDeleteVariant(id) + await refresh() + } catch { alert('删除失败') } + } + + if (loading) return
加载中...
+ + if (error || !user || user.role !== 'admin') { + return ( +
+
🔒
+

无权访问

+

{error || '请使用管理员账号登录'}

+ 登录 +
+ ) + } + + const selectedCat = categories.find(c => c.ID === selectedCatId) + + return ( +
+
+

管理后台

+
+ {user.username} + +
+
+ + {/* Category Form */} +
+

{editingCat ? '编辑分类' : '新增分类'}

+
+ setCatName(e.target.value)} placeholder="名称 (shell)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" /> + setCatLabel(e.target.value)} placeholder="标签 (Shell)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" /> + setCatIcon(e.target.value)} placeholder="图标 (🐚)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" /> + setCatOrder(Number(e.target.value))} placeholder="排序" type="number" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" /> +
+
+ + {editingCat && } +
+
+ + {/* Categories List */} + {categories.map(cat => ( +
+
+
+ {cat.Icon} + {cat.Label} + ({cat.Name}) + 排序: {cat.SortOrder} +
+
+ + +
+
+ + {/* Variants */} +
+ {cat.Variants?.map(v => ( +
+
+ {v.Name} + {v.Label} + .{v.Extension} + {v.IsDefault && 默认} +
+
+ + +
+
+ ))} + +
+
+ ))} + + {/* Variant Form */} + {(selectedCatId > 0 || editingVariant) && ( +
+

+ {editingVariant ? '编辑变体' : `为 ${selectedCat?.Label || selectedCatId} 添加变体`} +

+
+ setVName(e.target.value)} placeholder="名称 (bash)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" /> + setVLabel(e.target.value)} placeholder="标签 (Bash)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" /> + setVExt(e.target.value)} placeholder="扩展名 (.sh)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" /> + setVMime(e.target.value)} placeholder="MIME" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" /> +
+
+ setVCmd(e.target.value)} placeholder="命令模板 (curl {url} | bash)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" /> + setVSrcCmd(e.target.value)} placeholder="source模板 (可选)" className="px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" /> +
+
+
+ setVOrder(Number(e.target.value))} placeholder="排序" className="w-20 px-3 py-2 bg-gray-800 border border-gray-700 rounded text-xs" /> +
+ +
+
+ + +
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index b8ebdbe..2041b13 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,11 +1,12 @@ -import { useState, useCallback } from 'react' +import { useState, useEffect, useCallback } from 'react' import CodeEditor from '../components/CodeEditor' import ResultCard from '../components/ResultCard' -import { createScript } from '../lib/api' -import { CreateScriptResponse, RuntimeOption, ExpiresIn, RUNTIME_OPTIONS, EXPIRES_OPTIONS } from '../types' +import { createScript, listCategories } from '../lib/api' +import { CreateScriptResponse, ExpiresIn, EXPIRES_OPTIONS, RuntimeCategory } from '../types' export default function Home() { - const [runtime, setRuntime] = useState('bash') + const [categories, setCategories] = useState([]) + const [categoryId, setCategoryId] = useState(0) const [title, setTitle] = useState('') const [description, setDescription] = useState('') const [content, setContent] = useState('') @@ -15,8 +16,15 @@ export default function Home() { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + useEffect(() => { + listCategories().then(setCategories).catch(() => {}) + }, []) + + const selectedCategory = categories.find(c => c.ID === categoryId) + const categoryName = selectedCategory?.Name || 'shell' + const handleSubmit = useCallback(async () => { - if (!title.trim() || !content.trim()) return + if (!title.trim() || !content.trim() || !categoryId) return setLoading(true) setError(null) try { @@ -24,7 +32,7 @@ export default function Home() { title: title.trim(), description: description.trim(), content, - runtime, + category_id: categoryId, expires_in: expiresIn, publish, }) @@ -34,7 +42,7 @@ export default function Home() { } finally { setLoading(false) } - }, [title, description, content, runtime, expiresIn, publish]) + }, [title, description, content, categoryId, expiresIn, publish]) const handleReset = useCallback(() => { setResult(null) @@ -48,7 +56,7 @@ export default function Home() { return } - const canSubmit = title.trim().length > 0 && content.trim().length > 0 && content.length <= 16384 + const canSubmit = title.trim().length > 0 && content.trim().length > 0 && content.length <= 16384 && categoryId > 0 return (
@@ -57,17 +65,18 @@ export default function Home() {

编写脚本,生成可分享的运行命令

- {/* Runtime + Expires */} + {/* Category + Expires */}
- +
@@ -119,7 +128,7 @@ export default function Home() { {content.length > 16384 && 超出限制!}
- +
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..6a81a38 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { login } from '../lib/api' + +export default function Login() { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const navigate = useNavigate() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!username.trim() || !password) return + setLoading(true) + setError(null) + try { + await login(username.trim(), password) + navigate('/') + } catch (e) { + setError(e instanceof Error ? e.message : '登录失败') + } finally { + setLoading(false) + } + } + + return ( +
+

登录

+
+ setUsername(e.target.value)} + placeholder="用户名" + className="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500" + /> + setPassword(e.target.value)} + placeholder="密码" + className="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500" + /> + {error &&

{error}

} + +
+

+ 没有账号? 注册 +

+
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/Market.tsx b/frontend/src/pages/Market.tsx index edcbccc..b3c37e9 100644 --- a/frontend/src/pages/Market.tsx +++ b/frontend/src/pages/Market.tsx @@ -1,20 +1,30 @@ import { useState, useEffect, useCallback } from 'react' import { Link } from 'react-router-dom' -import { listMarket } from '../lib/api' -import { MarketItem, RUNTIME_OPTIONS } from '../types' +import { listMarket, listCategories } from '../lib/api' +import { MarketItem, RuntimeCategory } from '../types' export default function Market() { const [items, setItems] = useState([]) + const [categories, setCategories] = useState([]) const [total, setTotal] = useState(0) const [page, setPage] = useState(1) - const [runtime, setRuntime] = useState('') + const [categoryId, setCategoryId] = useState(0) const [search, setSearch] = useState('') const [loading, setLoading] = useState(true) + useEffect(() => { + listCategories().then(setCategories).catch(() => {}) + }, []) + const fetchMarket = useCallback(async () => { setLoading(true) try { - const res = await listMarket({ page, per_page: 20, runtime, search }) + const res = await listMarket({ + page, + per_page: 20, + category_id: categoryId || undefined, + search: search || undefined, + }) setItems(res.items) setTotal(res.total) } catch { @@ -22,7 +32,7 @@ export default function Market() { } finally { setLoading(false) } - }, [page, runtime, search]) + }, [page, categoryId, search]) useEffect(() => { fetchMarket() }, [fetchMarket]) @@ -45,13 +55,13 @@ export default function Market() { className="flex-1 px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500" />
@@ -77,7 +87,7 @@ export default function Market() { {item.title} - {item.runtime} + {item.category_icon} {item.category_label}
{item.description && ( diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx new file mode 100644 index 0000000..e0d06ba --- /dev/null +++ b/frontend/src/pages/Register.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { register } from '../lib/api' + +export default function Register() { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const navigate = useNavigate() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!username.trim() || password.length < 6) return + setLoading(true) + setError(null) + try { + await register(username.trim(), password) + navigate('/') + } catch (e) { + setError(e instanceof Error ? e.message : '注册失败') + } finally { + setLoading(false) + } + } + + return ( +
+

注册

+
+ setUsername(e.target.value)} + placeholder="用户名(3-32位)" + className="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500" + /> + setPassword(e.target.value)} + placeholder="密码(至少6位)" + className="w-full px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-sm focus:outline-none focus:border-blue-500" + /> + {error &&

{error}

} + +
+

+ 已有账号? 登录 +

+
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/ScriptDetail.tsx b/frontend/src/pages/ScriptDetail.tsx index dbd8c21..d4e6620 100644 --- a/frontend/src/pages/ScriptDetail.tsx +++ b/frontend/src/pages/ScriptDetail.tsx @@ -3,7 +3,7 @@ import { useParams, Link } from 'react-router-dom' import ScriptViewer from '../components/ScriptViewer' import CommandCard from '../components/CommandCard' import { getScript, publishScript } from '../lib/api' -import { ScriptDetail as ScriptDetailType, isShellRuntime, getSourceCommand } from '../types' +import { ScriptDetail as ScriptDetailType } from '../types' export default function ScriptDetail() { const { id } = useParams<{ id: string }>() @@ -36,11 +36,6 @@ export default function ScriptDetail() { ) } - const rawUrl = `${window.location.origin}/raw/${script.id}` - const command = `curl ${rawUrl} | ${script.runtime}` - const showSource = isShellRuntime(script.runtime) - const sourceCommand = showSource ? getSourceCommand(rawUrl, script.runtime) : null - const handlePublish = async () => { if (!adminToken.trim()) return setPublishing(true) @@ -59,8 +54,8 @@ export default function ScriptDetail() { return (
- ← 创建 - 市场 + ← 市场 + 创建
@@ -71,7 +66,7 @@ export default function ScriptDetail() {
{script.id} - {script.runtime} + {script.category_icon} {script.category_label}
- + -
- - {showSource && sourceCommand && ( -
-

- 如果脚本设置了环境变量(如代理),请使用以下命令在当前 shell 中执行: -

- + {/* Commands */} +
+

运行命令

+ {script.commands.map((cmd, i) => ( +
+ + {cmd.source_command && ( +
+

继承环境变量(source 方式执行)

+ +
+ )}
- )} + ))}
{/* Publish section for drafts */} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 1be89fa..721c164 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2,55 +2,69 @@ export interface CreateScriptResponse { id: string title: string description: string + category_id: number admin_token: string url: string - command: string - source_command: string - runtime: string status: string expires_at: string } +export interface CommandVariant { + variant: string + label: string + command: string + source_command?: string +} + export interface ScriptDetail { id: string title: string description: string - runtime: string + category_id: number + category_name: string + category_label: string + category_icon: string content: string content_length: number status: string - created_at: string + commands: CommandVariant[] expires_at: string published_at: string | null expired: boolean } -export type RuntimeOption = 'bash' | 'zsh' | 'sh' | 'fish' | 'python3' | 'node' | 'ruby' | 'php' export type ExpiresIn = '1h' | '24h' | '7d' | '30d' -export const RUNTIME_OPTIONS: { value: RuntimeOption; label: string }[] = [ - { value: 'bash', label: 'Bash' }, - { value: 'zsh', label: 'Zsh' }, - { value: 'sh', label: 'Sh' }, - { value: 'fish', label: 'Fish' }, - { value: 'python3', label: 'Python 3' }, - { value: 'node', label: 'Node.js' }, - { value: 'ruby', label: 'Ruby' }, - { value: 'php', label: 'PHP' }, -] +export interface RuntimeCategory { + ID: number + Name: string + Label: string + Icon: string + SortOrder: number + Variants: RuntimeVariant[] +} -export const EXPIRES_OPTIONS: { value: ExpiresIn; label: string }[] = [ - { value: '1h', label: '1 小时' }, - { value: '24h', label: '24 小时' }, - { value: '7d', label: '7 天' }, - { value: '30d', label: '30 天' }, -] +export interface RuntimeVariant { + ID: number + CategoryID: number + Name: string + Label: string + Extension: string + MIMEType: string + CommandTemplate: string + SourceTemplate: string + IsDefault: boolean + SortOrder: number +} export interface MarketItem { id: string title: string description: string - runtime: string + category_id: number + category_name: string + category_label: string + category_icon: string published_at: string | null } @@ -61,17 +75,16 @@ export interface MarketResponse { per_page: number } -const SOURCE_TEMPLATES: Record = { - bash: 'source <(curl {url})', - zsh: 'source <(curl {url})', - sh: '. <(curl {url})', - fish: 'curl {url} | source', +export interface AuthResponse { + id: number + username: string + role: string + token: string } -export function isShellRuntime(runtime: string): boolean { - return runtime in SOURCE_TEMPLATES -} - -export function getSourceCommand(url: string, runtime: string): string { - return (SOURCE_TEMPLATES[runtime] ?? 'source <(curl {url})').replace('{url}', url) -} \ No newline at end of file +export const EXPIRES_OPTIONS: { value: ExpiresIn; label: string }[] = [ + { value: '1h', label: '1 小时' }, + { value: '24h', label: '24 小时' }, + { value: '7d', label: '7 天' }, + { value: '30d', label: '30 天' }, +] \ No newline at end of file