feat: 分类/变体体系 + 用户认证 + 管理后台
- 运行时分类体系:Shell/Python/JavaScript/Ruby/PHP 各含变体 - 用户注册/登录(JWT + bcrypt),首个注册用户为管理员 - 管理后台 /admin 动态管理分类和变体 - 脚本市场支持按分类筛选 - CodeMirror 语言模式根据分类名称自动切换 - 结果页展示该分类下所有变体的运行命令 - source 命令变体用于 Shell 类继承环境变量 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
122
backend/internal/auth/auth.go
Normal file
122
backend/internal/auth/auth.go
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
54
backend/internal/middleware/auth.go
Normal file
54
backend/internal/middleware/auth.go
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
76
backend/internal/model/models.go
Normal file
76
backend/internal/model/models.go
Normal file
@@ -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},
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
21
backend/internal/seed/seed.go
Normal file
21
backend/internal/seed/seed.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user