初始提交: ScriptForge 脚本快速转运行链接服务
Some checks failed
Release / build-and-release (push) Failing after 1m31s
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:
6
backend/internal/assets/assets.go
Normal file
6
backend/internal/assets/assets.go
Normal 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
0
backend/internal/assets/dist/.gitkeep
vendored
Normal file
37
backend/internal/config/runtime.go
Normal file
37
backend/internal/config/runtime.go
Normal 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
|
||||
}
|
||||
41
backend/internal/handler/raw.go
Normal file
41
backend/internal/handler/raw.go
Normal 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)
|
||||
}
|
||||
}
|
||||
103
backend/internal/handler/script.go
Normal file
103
backend/internal/handler/script.go
Normal 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)
|
||||
}
|
||||
}
|
||||
30
backend/internal/idgen/idgen.go
Normal file
30
backend/internal/idgen/idgen.go
Normal 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
|
||||
}
|
||||
21
backend/internal/middleware/logger.go
Normal file
21
backend/internal/middleware/logger.go
Normal 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)
|
||||
}
|
||||
}
|
||||
89
backend/internal/middleware/ratelimit.go
Normal file
89
backend/internal/middleware/ratelimit.go
Normal 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()
|
||||
}
|
||||
}
|
||||
12
backend/internal/model/script.go
Normal file
12
backend/internal/model/script.go
Normal 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"`
|
||||
}
|
||||
113
backend/internal/service/script.go
Normal file
113
backend/internal/service/script.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user