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:
2026-05-29 15:02:20 +08:00
parent 58a80cb196
commit 5414c9c865
24 changed files with 1309 additions and 295 deletions

View 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"
}

View File

@@ -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 ""
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View 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()
}
}

View 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},
}

View File

@@ -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"`
}

View 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)
}
}
}

View File

@@ -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)
}