初始提交: ScriptForge 脚本快速转运行链接服务
Some checks failed
Release / build-and-release (push) Failing after 1m31s

- Go 后端 (Gin + GORM + SQLite) 提供 API 和纯文本脚本服务
- Vite + React + TypeScript + Tailwind 前端
- 单二进制部署 (Go embed 前端静态文件)
- Gitea Actions CI/CD: 打标签自动构建多平台 Release
- 支持 bash/zsh/sh/fish/python3/node/ruby/php 8种运行环境

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 23:48:19 +08:00
commit 10a200b96c
37 changed files with 4400 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
package assets
import "embed"
//go:embed dist/*
var FS embed.FS

0
backend/internal/assets/dist/.gitkeep vendored Normal file
View File

View File

@@ -0,0 +1,37 @@
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
}

View File

@@ -0,0 +1,41 @@
package handler
import (
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"gitea.kmux.cn/zhilv/scriptforge/internal/config"
"gitea.kmux.cn/zhilv/scriptforge/internal/service"
)
func GetRawScript(svc *service.ScriptService) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
script, err := svc.GetByID(id)
if errors.Is(err, service.ErrNotFound) {
c.String(http.StatusNotFound, "script not found or expired\n")
return
}
if err != nil {
c.String(http.StatusInternalServerError, "internal error\n")
return
}
rt, _ := config.GetRuntime(script.Runtime)
mime := rt.MIMEType
ext := rt.Extension
if mime == "" {
mime = "text/plain"
ext = ".sh"
}
c.Header("Content-Type", mime+"; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="script%s"`, ext))
c.Header("X-Content-Type-Options", "nosniff")
c.String(http.StatusOK, script.Content)
}
}

View File

@@ -0,0 +1,103 @@
package handler
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gitea.kmux.cn/zhilv/scriptforge/internal/service"
)
type createRequest struct {
Content string `json:"content" binding:"required,max=16384"`
Runtime string `json:"runtime" binding:"required"`
ExpiresIn string `json:"expires_in" binding:"required,oneof=1h 24h 7d 30d"`
}
func CreateScript(svc *service.ScriptService) gin.HandlerFunc {
return func(c *gin.Context) {
var req createRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
scheme := "https"
if strings.HasPrefix(c.Request.Host, "localhost") || strings.HasPrefix(c.Request.Host, "127.0.0.1") {
scheme = "http"
}
result, err := svc.Create(service.CreateInput{
Content: req.Content,
Runtime: req.Runtime,
ExpiresIn: req.ExpiresIn,
}, scheme, c.Request.Host)
if errors.Is(err, service.ErrInvalidRuntime) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid runtime, supported: bash, zsh, sh, fish, python3, node, ruby, php"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
c.JSON(http.StatusCreated, gin.H{
"id": result.Script.ID,
"admin_token": result.AdminToken,
"url": result.RawURL,
"command": result.Command,
"runtime": result.Script.Runtime,
"expires_at": result.Script.ExpiresAt,
})
}
}
func GetScript(svc *service.ScriptService) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
script, err := svc.GetByID(id)
if errors.Is(err, service.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "script not found or expired"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
c.JSON(http.StatusOK, gin.H{
"id": script.ID,
"runtime": script.Runtime,
"content": script.Content,
"content_length": len(script.Content),
"created_at": script.CreatedAt,
"expires_at": script.ExpiresAt,
"expired": false,
})
}
}
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
}
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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
c.Status(http.StatusNoContent)
}
}

View File

@@ -0,0 +1,30 @@
package idgen
import (
"crypto/rand"
"encoding/base64"
"math/big"
)
const idChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
func GenerateID(length int) (string, error) {
result := make([]byte, length)
for i := range result {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(idChars))))
if err != nil {
return "", err
}
result[i] = idChars[n.Int64()]
}
return string(result), nil
}
func GenerateToken() (string, error) {
token := make([]byte, 48)
_, err := rand.Read(token)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(token), nil
}

View File

@@ -0,0 +1,21 @@
package middleware
import (
"log"
"time"
"github.com/gin-gonic/gin"
)
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
log.Printf("[%d] %s %s (%s)", status, c.Request.Method, path, latency)
}
}

View File

@@ -0,0 +1,89 @@
package middleware
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
type visitor struct {
count int
lastSeen time.Time
}
type rateLimiter struct {
visitors map[string]*visitor
mu sync.Mutex
limit int
window time.Duration
}
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
rl := &rateLimiter{
visitors: make(map[string]*visitor),
limit: limit,
window: window,
}
go rl.cleanup()
return rl
}
func (rl *rateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
v, exists := rl.visitors[ip]
if !exists {
rl.visitors[ip] = &visitor{count: 1, lastSeen: time.Now()}
return true
}
if time.Since(v.lastSeen) > rl.window {
v.count = 1
v.lastSeen = time.Now()
return true
}
v.count++
v.lastSeen = time.Now()
return v.count <= rl.limit
}
func (rl *rateLimiter) cleanup() {
for {
time.Sleep(10 * time.Minute)
rl.mu.Lock()
for ip, v := range rl.visitors {
if time.Since(v.lastSeen) > rl.window*2 {
delete(rl.visitors, ip)
}
}
rl.mu.Unlock()
}
}
var (
createLimiter = newRateLimiter(10, 1*time.Minute)
generalLimiter = newRateLimiter(60, 1*time.Minute)
)
func RateLimit(limit int) gin.HandlerFunc {
if limit == 10 {
return func(c *gin.Context) {
if !createLimiter.allow(c.ClientIP()) {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
return
}
c.Next()
}
}
return func(c *gin.Context) {
if !generalLimiter.allow(c.ClientIP()) {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
return
}
c.Next()
}
}

View File

@@ -0,0 +1,12 @@
package model
import "time"
type Script struct {
ID string `gorm:"primaryKey;size:8"`
Content string `gorm:"type:text;not null"`
Runtime string `gorm:"size:16;not null;index"`
AdminToken string `gorm:"size:64;not null"`
ExpiresAt time.Time `gorm:"not null;index"`
CreatedAt time.Time `gorm:"not null;autoCreateTime"`
}

View File

@@ -0,0 +1,113 @@
package service
import (
"errors"
"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")
)
type ScriptService struct {
db *gorm.DB
}
func NewScriptService(db *gorm.DB) *ScriptService {
return &ScriptService{db: db}
}
type CreateInput struct {
Content string
Runtime string
ExpiresIn string
}
type CreateResult struct {
Script model.Script
AdminToken string
Command string
RawURL string
}
func (s *ScriptService) Create(input CreateInput, scheme, host string) (*CreateResult, error) {
if !config.IsValidRuntime(input.Runtime) {
return nil, ErrInvalidRuntime
}
var d time.Duration
switch input.ExpiresIn {
case "1h":
d = 1 * time.Hour
case "24h":
d = 24 * time.Hour
case "7d":
d = 7 * 24 * time.Hour
case "30d":
d = 30 * 24 * time.Hour
default:
d = 24 * time.Hour
}
id, err := idgen.GenerateID(8)
if err != nil {
return nil, err
}
adminToken, err := idgen.GenerateToken()
if err != nil {
return nil, err
}
script := model.Script{
ID: id,
Content: input.Content,
Runtime: input.Runtime,
AdminToken: adminToken,
ExpiresAt: time.Now().Add(d),
CreatedAt: time.Now(),
}
if err := s.db.Create(&script).Error; err != nil {
return nil, err
}
rawURL := scheme + "://" + host + "/raw/" + id
command := "curl " + rawURL + " | " + input.Runtime
return &CreateResult{
Script: script,
AdminToken: adminToken,
Command: command,
RawURL: rawURL,
}, nil
}
func (s *ScriptService) GetByID(id string) (*model.Script, error) {
var script model.Script
err := s.db.Where("id = ? AND expires_at > ?", id, time.Now()).First(&script).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return &script, err
}
func (s *ScriptService) Delete(id, token string) error {
result := s.db.Where("id = ? AND admin_token = ?", id, token).Delete(&model.Script{})
if result.RowsAffected == 0 {
return ErrNotFound
}
return result.Error
}
func (s *ScriptService) CleanupExpired() (int64, error) {
result := s.db.Where("expires_at <= ?", time.Now()).Delete(&model.Script{})
return result.RowsAffected, result.Error
}