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 (
- 如果脚本设置了环境变量(如代理),请使用以下命令在当前 shell 中执行: -
-编写脚本,生成可分享的运行命令
+ 没有账号? 注册 +
++ 已有账号? 登录 +
+- 如果脚本设置了环境变量(如代理),请使用以下命令在当前 shell 中执行: -
-继承环境变量(source 方式执行)
+