feat: Prism HTTP/SOCKS5 代理网关及管理台
CI / docker (push) Successful in 2m54s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
renjue
2026-06-23 15:58:28 +08:00
commit fe8ea784d0
170 changed files with 20056 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
package api
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
func issueToken(secret string, ttl time.Duration) string {
exp := time.Now().Add(ttl).Unix()
return fmt.Sprintf("%d.%s", exp, signToken(secret, exp))
}
func verifyToken(secret, token string) bool {
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
return false
}
exp, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || time.Now().Unix() > exp {
return false
}
expected := signToken(secret, exp)
return hmac.Equal([]byte(parts[1]), []byte(expected))
}
func signToken(secret string, exp int64) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(strconv.FormatInt(exp, 10)))
return hex.EncodeToString(mac.Sum(nil))
}
+35
View File
@@ -0,0 +1,35 @@
package api
import (
"fmt"
"testing"
"time"
)
func TestIssueAndVerifyToken(t *testing.T) {
secret := "test-secret"
token := issueToken(secret, time.Hour)
if !verifyToken(secret, token) {
t.Fatal("expected valid token")
}
}
func TestVerifyTokenWrongSecret(t *testing.T) {
token := issueToken("a", time.Hour)
if verifyToken("b", token) {
t.Fatal("expected invalid token for wrong secret")
}
}
func TestVerifyTokenExpired(t *testing.T) {
secret := "test-secret"
exp := time.Now().Add(-time.Hour).Unix()
token := fmtToken(secret, exp)
if verifyToken(secret, token) {
t.Fatal("expected expired token to be rejected")
}
}
func fmtToken(secret string, exp int64) string {
return fmt.Sprintf("%d.%s", exp, signToken(secret, exp))
}
+23
View File
@@ -0,0 +1,23 @@
package api
import (
"net"
"strings"
"github.com/gin-gonic/gin"
)
func clientIP(c *gin.Context) string {
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[0])
}
if xri := strings.TrimSpace(c.GetHeader("X-Real-IP")); xri != "" {
return xri
}
host, _, err := net.SplitHostPort(c.Request.RemoteAddr)
if err != nil {
return c.Request.RemoteAddr
}
return host
}
+498
View File
@@ -0,0 +1,498 @@
package api
import (
"context"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/prism/proxy/internal/auth"
"github.com/prism/proxy/internal/country"
"github.com/prism/proxy/internal/health"
"github.com/prism/proxy/internal/metrics"
applog "github.com/prism/proxy/internal/log"
"github.com/prism/proxy/internal/mihomoapi"
"github.com/prism/proxy/internal/service"
"github.com/prism/proxy/internal/store"
"github.com/prism/proxy/internal/subscription"
"github.com/prism/proxy/internal/trafficlog"
)
type Server struct {
app *service.App
health *health.Checker
metrics *metrics.Collector
subscription *subscription.Service
logs *applog.Collector
traffic *trafficlog.Collector
loginLimiter *auth.LoginLimiter
}
func NewServer(app *service.App, hc *health.Checker, mc *metrics.Collector, sub *subscription.Service, logs *applog.Collector, traffic *trafficlog.Collector) *Server {
return &Server{
app: app,
health: hc,
metrics: mc,
subscription: sub,
logs: logs,
traffic: traffic,
loginLimiter: auth.NewLoginLimiter(app.Store),
}
}
func (s *Server) syncMihomoClient(ctx context.Context) {
base, secret, err := s.app.MihomoAPIBase(ctx)
if err != nil {
return
}
s.health.UpdateAPIBase(base, secret)
s.metrics.UpdateAPIBase(base, secret)
if s.traffic != nil {
s.traffic.UpdateClient(mihomoapi.New(base, secret))
}
}
func (s *Server) Router() *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery(), s.corsMiddleware())
r.GET("/api/v1/openapi.json", s.serveOpenAPIJSON)
r.GET("/api/v1/openapi.yaml", s.serveOpenAPIYAML)
r.GET("/api/docs", s.serveOpenAPIDocs)
api := r.Group("/api/v1")
api.POST("/auth/login", s.login)
api.GET("/auth/status", s.authStatus)
protected := api.Group("")
protected.Use(s.authMiddleware())
{
protected.GET("/dashboard", s.dashboard)
protected.GET("/connections", s.connections)
protected.GET("/subscriptions", s.listSubscriptions)
protected.POST("/subscriptions", s.createSubscription)
protected.PUT("/subscriptions/:id", s.updateSubscription)
protected.DELETE("/subscriptions/:id", s.deleteSubscription)
protected.POST("/subscriptions/:id/refresh", s.refreshSubscription)
protected.GET("/proxies", s.listProxies)
protected.POST("/proxies", s.createProxy)
protected.PUT("/proxies/:id", s.updateProxy)
protected.DELETE("/proxies/:id", s.deleteProxy)
protected.POST("/proxies/:id/test", s.testProxy)
protected.POST("/proxies/test-all", s.testAllProxies)
protected.POST("/proxies/rebuild-countries", s.rebuildCountries)
protected.GET("/proxies/export", s.exportProxies)
protected.POST("/proxies/import", s.importProxies)
protected.GET("/countries", s.listCountries)
protected.GET("/outbound", s.outboundOverview)
protected.PUT("/outbound/groups/:name", s.selectOutboundGroup)
protected.PUT("/outbound/default-policy", s.setDefaultPolicy)
protected.GET("/traffic-logs", s.listTrafficLogs)
protected.GET("/rules", s.listRules)
protected.POST("/rules", s.createRule)
protected.PUT("/rules/:id", s.updateRule)
protected.DELETE("/rules/:id", s.deleteRule)
protected.GET("/rules/merged", s.mergedRules)
protected.POST("/rules/test", s.testRule)
protected.GET("/rules/export", s.exportRules)
protected.POST("/rules/import", s.importRules)
protected.GET("/settings", s.getSettings)
protected.PUT("/settings", s.putSettings)
protected.GET("/api-keys", s.listAPIKeys)
protected.POST("/api-keys", s.createAPIKey)
protected.DELETE("/api-keys/:id", s.deleteAPIKey)
protected.GET("/logs", s.listLogs)
protected.GET("/audit-logs", s.listAuditLogs)
protected.GET("/system/status", s.systemStatus)
protected.POST("/system/reload", s.systemReload)
protected.GET("/system/geo", s.geoStatus)
protected.POST("/system/geo/update", s.geoUpdate)
}
r.NoRoute(s.serveSPA)
return r
}
func (s *Server) corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key")
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
func (s *Server) dashboard(c *gin.Context) {
ctx := c.Request.Context()
s.metrics.RefreshAsync(10 * time.Second)
s.app.RefreshEngineStatusAsync()
history, _ := s.app.Store.ListTrafficSnapshots(ctx, 60)
conns := s.metrics.CachedConnections()
policyGroups, _ := s.app.PolicyGroupRuntimes(ctx)
names, _ := s.app.ProxyDisplayNames(ctx)
liveDomainsAll, liveNodesAll := metrics.AggregateConnections(conns, policyGroups)
liveDomainsAll = metrics.ApplyDomainLabels(liveDomainsAll, names)
liveNodesAll = metrics.ApplyTrafficLabels(liveNodesAll, names)
liveDomainLimit, liveDomainOffset := parseLimitOffset(c.DefaultQuery("live_domain_limit", "50"), c.DefaultQuery("live_domain_offset", "0"))
liveNodeLimit, liveNodeOffset := parseLimitOffset(c.DefaultQuery("live_node_limit", "50"), c.DefaultQuery("live_node_offset", "0"))
periodDomainLimit, periodDomainOffset := parseLimitOffset(c.DefaultQuery("period_domain_limit", "50"), c.DefaultQuery("period_domain_offset", "0"))
periodNodeLimit, periodNodeOffset := parseLimitOffset(c.DefaultQuery("period_node_limit", "50"), c.DefaultQuery("period_node_offset", "0"))
liveDomains, liveDomainTotal := paginateSlice(liveDomainsAll, liveDomainLimit, liveDomainOffset)
liveNodes, liveNodeTotal := paginateSlice(liveNodesAll, liveNodeLimit, liveNodeOffset)
hostNodeTotals, _ := s.app.Store.AggregateTrafficByHostNode(ctx, 24, periodDomainLimit, periodDomainOffset)
nodeTotals, _ := s.app.Store.AggregateTrafficByNode(ctx, 24, periodNodeLimit, periodNodeOffset)
periodDomainTotal, _ := s.app.Store.CountAggregateTrafficByHostNode(ctx, 24)
periodNodeTotal, _ := s.app.Store.CountAggregateTrafficByNode(ctx, 24)
periodDomains := metrics.TrafficHostNodeAggToItems(hostNodeTotals, names)
periodNodes := metrics.TrafficAggToItems(nodeTotals, names)
c.JSON(http.StatusOK, gin.H{
"snapshot": s.metrics.Snapshot(ctx),
"history": history,
"connections": conns,
"engine_running": s.app.EngineStatusCached(),
"live_domains": liveDomains,
"live_nodes": liveNodes,
"live_domain_total": liveDomainTotal,
"live_node_total": liveNodeTotal,
"period_domains": periodDomains,
"period_nodes": periodNodes,
"period_domain_total": periodDomainTotal,
"period_node_total": periodNodeTotal,
"period_hours": 24,
})
}
func (s *Server) connections(c *gin.Context) {
conns, err := s.metrics.GetConnectionsCached(8 * time.Second)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"connections": conns})
}
func (s *Server) listSubscriptions(c *gin.Context) {
items, err := s.app.Store.ListSubscriptions(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *Server) createSubscription(c *gin.Context) {
var sub store.Subscription
if err := c.ShouldBindJSON(&sub); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if sub.Type == "" {
sub.Type = "auto"
}
if sub.IntervalSec <= 0 {
sub.IntervalSec = 3600
}
sub.Enabled = true
if err := s.app.Store.CreateSubscription(c.Request.Context(), &sub); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.Store.AddAuditLog(c.Request.Context(), "create", "subscription", sub.Name)
go func(id int64) {
ctx := context.Background()
_ = s.subscription.FetchAndStore(ctx, id)
_ = s.app.ReloadEngine(ctx)
}(sub.ID)
c.JSON(http.StatusCreated, sub)
}
func (s *Server) updateSubscription(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
sub, err := s.app.Store.GetSubscription(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err := c.ShouldBindJSON(sub); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
sub.ID = id
if err := s.app.Store.UpdateSubscription(c.Request.Context(), sub); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, sub)
}
func (s *Server) deleteSubscription(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if err := s.app.Store.DeleteSubscription(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.ReloadEngine(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) refreshSubscription(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if err := s.subscription.FetchAndStore(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := s.app.ReloadEngine(c.Request.Context()); err != nil {
c.JSON(http.StatusOK, gin.H{"ok": true, "warning": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) listProxies(c *gin.Context) {
source := c.Query("source")
country := c.Query("country")
var subID *int64
if v := c.Query("subscription_id"); v != "" {
id, _ := strconv.ParseInt(v, 10, 64)
subID = &id
}
limit, offset := parseLimitOffset(c.DefaultQuery("limit", "50"), c.DefaultQuery("offset", "0"))
items, err := s.app.Store.ListProxyNodesPage(c.Request.Context(), source, subID, country, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
total, err := s.app.Store.CountProxyNodes(c.Request.Context(), source, subID, country)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
}
type proxyReq struct {
Name string `json:"name"`
Type string `json:"type"`
RawConfig string `json:"raw_config"`
Enabled *bool `json:"enabled"`
}
func (s *Server) createProxy(c *gin.Context) {
var req proxyReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
n := store.ProxyNode{
Name: req.Name,
RuntimeName: store.RuntimeName("manual", 0, req.Name),
Type: req.Type,
RawConfig: req.RawConfig,
Source: "manual",
Country: country.Detect(req.Name),
Enabled: true,
}
if req.Enabled != nil {
n.Enabled = *req.Enabled
}
if err := s.app.Store.CreateProxyNode(c.Request.Context(), &n); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.ReloadEngine(c.Request.Context())
c.JSON(http.StatusCreated, n)
}
func (s *Server) updateProxy(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
n, err := s.app.Store.GetProxyNode(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var req proxyReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != "" {
n.Name = req.Name
n.Country = country.Detect(req.Name)
}
if req.Type != "" {
n.Type = req.Type
}
if req.RawConfig != "" {
n.RawConfig = req.RawConfig
}
if req.Enabled != nil {
n.Enabled = *req.Enabled
}
if err := s.app.Store.UpdateProxyNode(c.Request.Context(), n); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.ReloadEngine(c.Request.Context())
c.JSON(http.StatusOK, n)
}
func (s *Server) deleteProxy(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
n, err := s.app.Store.GetProxyNode(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if n.Source != "manual" {
c.JSON(http.StatusBadRequest, gin.H{"error": "only manual proxies can be deleted"})
return
}
if err := s.app.Store.DeleteProxyNode(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.ReloadEngine(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) testProxy(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
latency, err := s.health.TestNode(c.Request.Context(), id, s.app.ReloadEngine)
if err != nil {
c.JSON(http.StatusOK, gin.H{"latency_ms": -1, "alive": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"latency_ms": latency, "alive": latency >= 0})
}
func (s *Server) testAllProxies(c *gin.Context) {
if err := s.health.TestAll(c.Request.Context(), s.app.ReloadEngine); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) listRules(c *gin.Context) {
source := c.DefaultQuery("source", "manual")
items, err := s.app.Store.ListRules(c.Request.Context(), source)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *Server) createRule(c *gin.Context) {
var r store.Rule
if err := c.ShouldBindJSON(&r); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
r.Source = "manual"
r.Enabled = true
if r.Priority <= 0 {
p, _ := s.app.Store.NextManualRulePriority(c.Request.Context())
r.Priority = p
}
if err := s.app.Store.CreateRule(c.Request.Context(), &r); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.ReloadEngine(c.Request.Context())
c.JSON(http.StatusCreated, r)
}
func (s *Server) updateRule(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
r, err := s.app.Store.GetRule(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err := c.ShouldBindJSON(r); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
r.ID = id
if err := s.app.Store.UpdateRule(c.Request.Context(), r); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.ReloadEngine(c.Request.Context())
c.JSON(http.StatusOK, r)
}
func (s *Server) deleteRule(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if err := s.app.Store.DeleteRule(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.ReloadEngine(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) listLogs(c *gin.Context) {
level := c.Query("level")
keyword := c.Query("keyword")
limit, offset := parseLimitOffset(c.DefaultQuery("limit", "50"), c.DefaultQuery("offset", "0"))
items, total, err := s.app.Store.ListRequestLogs(c.Request.Context(), level, keyword, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
}
func (s *Server) listAuditLogs(c *gin.Context) {
limit, offset := parseLimitOffset(c.DefaultQuery("limit", "50"), c.DefaultQuery("offset", "0"))
items, err := s.app.Store.ListAuditLogs(c.Request.Context(), limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
total, err := s.app.Store.CountAuditLogs(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
}
func (s *Server) systemStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"engine_running": s.app.EngineReachable(c.Request.Context()),
"has_config": len(s.app.Cache.GetLastYAML()) > 0,
"last_reload_error": s.app.LastReloadError,
})
}
func (s *Server) systemReload(c *gin.Context) {
if err := s.app.ReloadEngine(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+96
View File
@@ -0,0 +1,96 @@
package api
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/prism/proxy/internal/auth"
)
type createAPIKeyReq struct {
Name string `json:"name"`
}
func (s *Server) listAPIKeys(c *gin.Context) {
items, err := s.app.Store.ListAPIKeys(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *Server) createAPIKey(c *gin.Context) {
var req createAPIKeyReq
_ = c.ShouldBindJSON(&req)
name := strings.TrimSpace(req.Name)
if name == "" {
name = "default"
}
raw, err := auth.GenerateAPIKey()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
item, err := s.app.Store.CreateAPIKey(
c.Request.Context(),
name,
auth.APIKeyDisplayPrefix(raw),
auth.HashAPIKey(raw),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.Store.AddAuditLog(c.Request.Context(), "create", "api_key", name)
c.JSON(http.StatusCreated, gin.H{
"id": item.ID,
"name": item.Name,
"key": raw,
"key_prefix": item.KeyPrefix,
"created_at": item.CreatedAt,
})
}
func (s *Server) deleteAPIKey(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if err := s.app.Store.DeleteAPIKey(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.Store.AddAuditLog(c.Request.Context(), "delete", "api_key", strconv.FormatInt(id, 10))
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) authorizeRequest(c *gin.Context, settings map[string]string) bool {
if settings["auth_enabled"] != "true" {
return true
}
ctx := c.Request.Context()
if key := strings.TrimSpace(c.GetHeader("X-API-Key")); key != "" {
if ok, _ := s.app.Store.VerifyAPIKey(ctx, auth.HashAPIKey(key)); ok {
return true
}
}
header := c.GetHeader("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
return false
}
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
if token == "" {
return false
}
if auth.IsAPIKeyFormat(token) {
ok, _ := s.app.Store.VerifyAPIKey(ctx, auth.HashAPIKey(token))
return ok
}
return verifyToken(settings["admin_password"], token)
}
+66
View File
@@ -0,0 +1,66 @@
package api
import (
"context"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/prism/proxy/internal/auth"
"github.com/prism/proxy/internal/service"
"github.com/prism/proxy/internal/store"
)
func TestAuthorizeRequestWithAPIKey(t *testing.T) {
gin.SetMode(gin.TestMode)
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
ctx := context.Background()
if err := st.SetSettings(ctx, map[string]string{
"auth_enabled": "true",
"admin_password": "secret",
}); err != nil {
t.Fatal(err)
}
raw, err := auth.GenerateAPIKey()
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateAPIKey(ctx, "test", auth.APIKeyDisplayPrefix(raw), auth.HashAPIKey(raw)); err != nil {
t.Fatal(err)
}
s := &Server{app: &service.App{Store: st}}
settings, _ := st.GetSettings(ctx)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/dashboard", nil)
c.Request.Header.Set("X-API-Key", raw)
if !s.authorizeRequest(c, settings) {
t.Fatal("expected api key to authorize")
}
w2 := httptest.NewRecorder()
c2, _ := gin.CreateTestContext(w2)
c2.Request = httptest.NewRequest("GET", "/api/v1/dashboard", nil)
c2.Request.Header.Set("Authorization", "Bearer "+raw)
if !s.authorizeRequest(c2, settings) {
t.Fatal("expected bearer api key to authorize")
}
w3 := httptest.NewRecorder()
c3, _ := gin.CreateTestContext(w3)
c3.Request = httptest.NewRequest("GET", "/api/v1/dashboard", nil)
c3.Request.Header.Set("Authorization", "Bearer prism_invalid")
if s.authorizeRequest(c3, settings) {
t.Fatal("expected invalid key to fail")
}
}
+116
View File
@@ -0,0 +1,116 @@
package api
import (
"math/rand"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"github.com/prism/proxy/internal/auth"
)
type loginReq struct {
Password string `json:"password"`
}
func (s *Server) authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
settings, err := s.app.Store.GetSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
c.Abort()
return
}
if !s.authorizeRequest(c, settings) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
c.Abort()
return
}
c.Next()
}
}
func (s *Server) authStatus(c *gin.Context) {
settings, err := s.app.Store.GetSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"enabled": settings["auth_enabled"] == "true"})
}
func (s *Server) login(c *gin.Context) {
var req loginReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
settings, err := s.app.Store.GetSettings(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ip := clientIP(c)
cfg := auth.ParseLoginConfig(settings)
check, err := s.loginLimiter.Check(ctx, ip, cfg)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !check.Allowed {
writeLoginLocked(c, check.RetryAfter)
return
}
hash := settings["admin_password"]
if !checkPassword(req.Password, hash) {
time.Sleep(loginFailureDelay())
result, err := s.loginLimiter.RecordFailure(ctx, ip, cfg)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if result.Locked {
writeLoginLocked(c, result.RetryAfter)
return
}
c.JSON(http.StatusUnauthorized, gin.H{
"error": "invalid password",
"remaining": result.Remaining,
})
return
}
if err := s.loginLimiter.RecordSuccess(ctx, ip); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
token := issueToken(hash, 24*time.Hour)
c.JSON(http.StatusOK, gin.H{"token": token})
}
func writeLoginLocked(c *gin.Context, retryAfter time.Duration) {
secs := int(retryAfter.Seconds())
if secs < 1 {
secs = 1
}
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "too many login attempts",
"retry_after": secs,
})
}
func loginFailureDelay() time.Duration {
return 300*time.Millisecond + time.Duration(rand.Intn(200))*time.Millisecond
}
func checkPassword(password, stored string) bool {
if strings.HasPrefix(stored, "$2a$") {
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(password)) == nil
}
return password == stored
}
+26
View File
@@ -0,0 +1,26 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (s *Server) listCountries(c *gin.Context) {
items, err := s.app.Store.ListCountryStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *Server) rebuildCountries(c *gin.Context) {
n, err := s.app.Store.RebuildAllNodeCountries(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.ReloadEngine(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"updated": n})
}
+79
View File
@@ -0,0 +1,79 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/prism/proxy/internal/geodata"
)
func (s *Server) geoStatus(c *gin.Context) {
ctx := c.Request.Context()
settings, err := s.app.Store.GetSettings(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
cfg := geodata.ConfigFromSettings(settings)
c.JSON(http.StatusOK, gin.H{
"ready": geodata.Ready(s.app.DataDir),
"auto_download": settings["geo_auto_download"] != "false",
"files": geodata.Status(s.app.DataDir, cfg),
"default_urls": geodata.DefaultConfig(),
})
}
type geoUpdateReq struct {
Force bool `json:"force"`
}
func (s *Server) geoUpdate(c *gin.Context) {
var req geoUpdateReq
_ = c.ShouldBindJSON(&req)
ctx := c.Request.Context()
settings, err := s.app.Store.GetSettings(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
cfg := geodata.ConfigFromSettings(settings)
geodata.ApplyMihomoURLs(cfg)
updated, err := geodata.Ensure(s.app.DataDir, cfg, req.Force)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
_ = s.app.Store.AddAuditLog(ctx, "update", "geo", "updated: "+joinNames(updated))
s.logs.Add("info", "prism", "geo data updated")
_ = s.app.ReloadEngine(ctx)
c.JSON(http.StatusOK, gin.H{
"ok": true,
"updated": updated,
"ready": geodata.Ready(s.app.DataDir),
"files": geodata.Status(s.app.DataDir, cfg),
})
}
func joinNames(names []string) string {
if len(names) == 0 {
return "none (already present)"
}
out := names[0]
for i := 1; i < len(names); i++ {
out += ", " + names[i]
}
return out
}
func geoDataReady(dataDir string) bool {
return geodata.Ready(dataDir)
}
func applyGeoSettings(dataDir string, settings map[string]string) {
geodata.InitHomeDir(dataDir)
geodata.ApplyMihomoURLs(geodata.ConfigFromSettings(settings))
}
+52
View File
@@ -0,0 +1,52 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (s *Server) outboundOverview(c *gin.Context) {
data, err := s.app.OutboundOverview(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, data)
}
type selectOutboundReq struct {
Proxy string `json:"proxy"`
}
func (s *Server) selectOutboundGroup(c *gin.Context) {
name := c.Param("name")
var req selectOutboundReq
if err := c.ShouldBindJSON(&req); err != nil || req.Proxy == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "proxy required"})
return
}
if err := s.app.SelectOutboundGroup(c.Request.Context(), name, req.Proxy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
type defaultPolicyReq struct {
Policy string `json:"policy"`
}
func (s *Server) setDefaultPolicy(c *gin.Context) {
var req defaultPolicyReq
if err := c.ShouldBindJSON(&req); err != nil || req.Policy == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "policy required"})
return
}
if err := s.app.SetDefaultPolicy(c.Request.Context(), req.Policy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s.syncMihomoClient(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+124
View File
@@ -0,0 +1,124 @@
package api
import (
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/prism/proxy/internal/proxyio"
"github.com/prism/proxy/internal/store"
)
func (s *Server) exportProxies(c *gin.Context) {
ctx := c.Request.Context()
items, err := s.app.Store.ListProxyNodes(ctx, "manual", nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
format := strings.ToLower(c.DefaultQuery("format", "json"))
filename := "prism-proxies"
switch format {
case "text", "txt", "links":
c.Header("Content-Type", "text/plain; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="`+filename+`.txt"`)
c.String(http.StatusOK, proxyio.ExportLinks(items))
default:
data, err := proxyio.ExportJSON(items)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Header("Content-Type", "application/json; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="`+filename+`.json"`)
c.Data(http.StatusOK, "application/json; charset=utf-8", data)
}
}
type importProxiesReq struct {
Mode string `json:"mode"`
Text string `json:"text"`
Nodes []proxyio.ExportNode `json:"nodes"`
}
func (s *Server) importProxies(c *gin.Context) {
ctx := c.Request.Context()
mode := strings.ToLower(strings.TrimSpace(c.DefaultQuery("mode", "")))
var nodes []store.ProxyNode
var parseErrors []string
contentType := c.GetHeader("Content-Type")
if strings.Contains(contentType, "application/json") {
var req importProxiesReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if mode == "" {
mode = strings.ToLower(strings.TrimSpace(req.Mode))
}
if len(req.Nodes) > 0 {
var valErrors []string
nodes, valErrors = proxyio.ToStoreNodes(req.Nodes)
parseErrors = append(parseErrors, valErrors...)
} else if strings.TrimSpace(req.Text) != "" {
nodes, parseErrors = proxyio.ParseText(req.Text)
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "nodes or text required"})
return
}
} else {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if mode == "" {
mode = "append"
}
nodes, parseErrors = proxyio.ParseText(string(body))
}
if mode == "" {
mode = "append"
}
if mode != "append" && mode != "replace" {
c.JSON(http.StatusBadRequest, gin.H{"error": "mode must be append or replace"})
return
}
if len(nodes) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no valid proxies", "errors": parseErrors})
return
}
if mode == "append" {
existing, _ := s.app.Store.ListProxyNodes(ctx, "manual", nil)
proxyio.AssignUniqueNames(nodes, proxyio.ReservedNames(existing))
if err := s.app.Store.AppendManualProxies(ctx, nodes); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
} else {
proxyio.AssignUniqueNames(nodes, nil)
if err := s.app.Store.ReplaceManualProxies(ctx, nodes); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
_ = s.app.Store.AddAuditLog(ctx, "import", "proxies", mode+": "+strconv.Itoa(len(nodes)))
_ = s.app.ReloadEngine(ctx)
c.JSON(http.StatusOK, gin.H{
"ok": true,
"imported": len(nodes),
"skipped": len(parseErrors),
"errors": parseErrors,
"mode": mode,
})
}
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/prism/proxy/internal/rulematch"
)
type testRuleReq struct {
URL string `json:"url"`
}
func (s *Server) mergedRules(c *gin.Context) {
limit, offset := parseLimitOffset(c.DefaultQuery("limit", "50"), c.DefaultQuery("offset", "0"))
items, err := s.app.Store.ListMergedRulesPage(c.Request.Context(), limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
total, err := s.app.Store.CountMergedRules(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
settings, _ := s.app.Store.GetSettings(c.Request.Context())
policy := settings["default_policy"]
if policy == "" {
policy = "DIRECT"
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"limit": limit,
"offset": offset,
"default_policy": policy,
})
}
func (s *Server) testRule(c *gin.Context) {
var req testRuleReq
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.URL) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "url required"})
return
}
ctx := c.Request.Context()
rules, err := s.app.Store.ListRules(ctx, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
settings, _ := s.app.Store.GetSettings(ctx)
policy := settings["default_policy"]
geoReady := geoDataReady(s.app.DataDir)
result := rulematch.Match(rules, policy, req.URL, geoReady)
if result.SkippedGeo > 0 {
result.Note = appendNote(result.Note, "已跳过 "+strconv.Itoa(result.SkippedGeo)+" 条 Geo 规则(本地测试不支持 GEOIP/GEOSITE")
}
c.JSON(http.StatusOK, result)
}
func appendNote(existing, extra string) string {
if existing == "" {
return extra
}
return existing + "" + extra
}
+142
View File
@@ -0,0 +1,142 @@
package api
import (
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/prism/proxy/internal/ruleio"
)
func (s *Server) exportRules(c *gin.Context) {
ctx := c.Request.Context()
items, err := s.app.Store.ListRules(ctx, "manual")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
format := strings.ToLower(c.DefaultQuery("format", "json"))
filename := "prism-rules"
switch format {
case "text", "txt", "clash":
c.Header("Content-Type", "text/plain; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="`+filename+`.txt"`)
c.String(http.StatusOK, ruleio.ExportText(items))
default:
data, err := ruleio.ExportJSON(items)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Header("Content-Type", "application/json; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="`+filename+`.json"`)
c.Data(http.StatusOK, "application/json; charset=utf-8", data)
}
}
type importRulesReq struct {
Mode string `json:"mode"`
Text string `json:"text"`
Rules []ruleio.ExportRule `json:"rules"`
}
func (s *Server) importRules(c *gin.Context) {
ctx := c.Request.Context()
mode := strings.ToLower(strings.TrimSpace(c.DefaultQuery("mode", "")))
var parsed []ruleio.ExportRule
var parseErrors []string
contentType := c.GetHeader("Content-Type")
if strings.Contains(contentType, "application/json") {
var req importRulesReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if mode == "" {
mode = strings.ToLower(strings.TrimSpace(req.Mode))
}
if len(req.Rules) > 0 {
parsed = req.Rules
} else if strings.TrimSpace(req.Text) != "" {
parsed, parseErrors = parseImportText(req.Text)
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "rules or text required"})
return
}
} else {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if mode == "" {
mode = "append"
}
parsed, parseErrors = parseImportText(string(body))
}
if mode == "" {
mode = "append"
}
if mode != "append" && mode != "replace" {
c.JSON(http.StatusBadRequest, gin.H{"error": "mode must be append or replace"})
return
}
if len(parsed) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no valid rules", "errors": parseErrors})
return
}
rules, valErrors := ruleio.ToStoreRules(parsed)
allErrors := append(parseErrors, valErrors...)
if len(rules) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no valid rules", "errors": allErrors})
return
}
if mode == "append" {
start, _ := s.app.Store.NextManualRulePriority(ctx)
ruleio.AssignPriorities(rules, start)
if err := s.app.Store.AppendManualRules(ctx, rules); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
} else {
ruleio.AssignPriorities(rules, 100)
if err := s.app.Store.ReplaceManualRules(ctx, rules); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
_ = s.app.Store.AddAuditLog(ctx, "import", "rules", mode+": "+strconv.Itoa(len(rules)))
_ = s.app.ReloadEngine(ctx)
c.JSON(http.StatusOK, gin.H{
"ok": true,
"imported": len(rules),
"skipped": len(allErrors),
"errors": allErrors,
"mode": mode,
})
}
func parseImportText(text string) ([]ruleio.ExportRule, []string) {
text = strings.TrimSpace(text)
if text == "" {
return nil, nil
}
if strings.HasPrefix(text, "{") || strings.HasPrefix(text, "[") {
rules, err := ruleio.ParseJSON([]byte(text))
if err == nil && len(rules) > 0 {
return rules, nil
}
}
return ruleio.ParseText(text)
}
+97
View File
@@ -0,0 +1,97 @@
package api
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
appsettings "github.com/prism/proxy/internal/settings"
"github.com/prism/proxy/internal/store"
)
func (s *Server) getSettings(c *gin.Context) {
settings, err := s.app.Store.GetSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
safe := make(map[string]string)
for k, v := range settings {
if k == appsettings.KeyAdminPassword || k == appsettings.KeyProxyAuthPass {
safe[k] = ""
continue
}
safe[k] = v
}
c.JSON(http.StatusOK, appsettings.Display(safe))
}
func (s *Server) putSettings(c *gin.Context) {
var req map[string]string
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if pw, ok := req[appsettings.KeyAdminPassword]; ok && pw != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
req[appsettings.KeyAdminPassword] = string(hash)
} else {
delete(req, appsettings.KeyAdminPassword)
}
if err := normalizeProxyAuthSettings(c.Request.Context(), s.app.Store, req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
appsettings.NormalizeSubmit(req)
if err := s.app.Store.SetSettings(c.Request.Context(), req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
allSettings, _ := s.app.Store.GetSettings(c.Request.Context())
applyGeoSettings(s.app.DataDir, allSettings)
base, secret, _ := s.app.MihomoAPIBase(c.Request.Context())
s.health.UpdateAPIBase(base, secret)
s.metrics.UpdateAPIBase(base, secret)
s.syncMihomoClient(c.Request.Context())
_ = s.app.ReloadEngine(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func normalizeProxyAuthSettings(ctx context.Context, st *store.Store, req map[string]string) error {
user, hasUser := req["proxy_auth_user"]
pass, hasPass := req["proxy_auth_pass"]
if !hasUser && !hasPass {
return nil
}
user = strings.TrimSpace(user)
if user == "" {
req["proxy_auth_user"] = ""
req["proxy_auth_pass"] = ""
return nil
}
if strings.Contains(user, ":") {
return fmt.Errorf("代理用户名不能包含冒号")
}
req["proxy_auth_user"] = user
if !hasPass || pass == "" {
current, err := st.GetSettings(ctx)
if err != nil {
return err
}
if existing := current["proxy_auth_pass"]; existing != "" {
delete(req, "proxy_auth_pass")
return nil
}
return fmt.Errorf("请填写代理密码")
}
req["proxy_auth_pass"] = pass
return nil
}
+18
View File
@@ -0,0 +1,18 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (s *Server) listTrafficLogs(c *gin.Context) {
keyword := c.Query("keyword")
limit, offset := parseLimitOffset(c.DefaultQuery("limit", "50"), c.DefaultQuery("offset", "0"))
items, total, err := s.app.Store.ListProxyRequestLogs(c.Request.Context(), keyword, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
}
+51
View File
@@ -0,0 +1,51 @@
package api
import (
_ "embed"
"encoding/json"
"net/http"
"sync"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
)
//go:embed openapi/spec.yaml
var openAPISpecYAML []byte
//go:embed openapi/docs.html
var openAPIDocsHTML []byte
var (
openAPIJSONOnce sync.Once
openAPIJSON []byte
openAPIJSONErr error
)
func loadOpenAPIJSON() {
var doc any
if err := yaml.Unmarshal(openAPISpecYAML, &doc); err != nil {
openAPIJSONErr = err
return
}
openAPIJSON, openAPIJSONErr = json.Marshal(doc)
}
func (s *Server) serveOpenAPIJSON(c *gin.Context) {
openAPIJSONOnce.Do(loadOpenAPIJSON)
if openAPIJSONErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": openAPIJSONErr.Error()})
return
}
c.Header("Cache-Control", "public, max-age=300")
c.Data(http.StatusOK, "application/json; charset=utf-8", openAPIJSON)
}
func (s *Server) serveOpenAPIYAML(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=300")
c.Data(http.StatusOK, "application/yaml; charset=utf-8", openAPISpecYAML)
}
func (s *Server) serveOpenAPIDocs(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", openAPIDocsHTML)
}
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Prism API 文档</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
html { box-sizing: border-box; overflow-y: scroll; }
*, *:before, *:after { box-sizing: inherit; }
body { margin: 0; background: #fafafa; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
<script>
window.onload = function () {
SwaggerUIBundle({
url: '/api/v1/openapi.json',
dom_id: '#swagger-ui',
deepLinking: true,
persistAuthorization: true,
displayRequestDuration: true,
tryItOutEnabled: true,
})
}
</script>
</body>
</html>
+753
View File
@@ -0,0 +1,753 @@
openapi: 3.0.3
info:
title: Prism Management API
description: |
Prism 代理网关管理接口(默认端口 8080)。
除 `auth/login`、`auth/status` 与 OpenAPI 文档外,启用登录后需在请求头携带以下任一凭证:
- `Authorization: Bearer <token>`(登录接口返回的短期 token)
- `Authorization: Bearer <api_key>` 或 `X-API-Key: <api_key>`(在系统设置中申请的 API Key)
version: 1.0.0
contact:
name: Prism
servers:
- url: /api/v1
description: 管理 API v1
tags:
- name: Auth
- name: Dashboard
- name: Subscriptions
- name: Proxies
- name: Countries
- name: Outbound
- name: Rules
- name: TrafficLogs
- name: Settings
- name: System
- name: Logs
paths:
/auth/login:
post:
tags: [Auth]
summary: 登录
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [password]
properties:
password:
type: string
responses:
'200':
description: 登录成功
content:
application/json:
schema:
type: object
properties:
token:
type: string
'401':
$ref: '#/components/responses/Unauthorized'
'429':
description: 登录尝试过多
/auth/status:
get:
tags: [Auth]
summary: 是否启用登录认证
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
enabled:
type: boolean
/api-keys:
get:
tags: [Auth]
summary: API Key 列表
security:
- bearerAuth: []
responses:
'200':
description: OK
post:
tags: [Auth]
summary: 申请 API Key
description: 完整 Key 仅在创建响应中返回一次,请妥善保存
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
properties:
name:
type: string
responses:
'201':
description: Created
/api-keys/{id}:
parameters:
- $ref: '#/components/parameters/IdPath'
delete:
tags: [Auth]
summary: 吊销 API Key
security:
- bearerAuth: []
responses:
'200':
description: OK
/dashboard:
get:
tags: [Dashboard]
summary: 监控大盘
security:
- bearerAuth: []
parameters:
- name: live_domain_limit
in: query
schema:
type: integer
default: 50
maximum: 500
- name: live_domain_offset
in: query
schema:
type: integer
default: 0
- name: live_node_limit
in: query
schema:
type: integer
default: 50
maximum: 500
- name: live_node_offset
in: query
schema:
type: integer
default: 0
- name: period_domain_limit
in: query
schema:
type: integer
default: 50
maximum: 500
- name: period_domain_offset
in: query
schema:
type: integer
default: 0
- name: period_node_limit
in: query
schema:
type: integer
default: 50
maximum: 500
- name: period_node_offset
in: query
schema:
type: integer
default: 0
responses:
'200':
description: OK
/connections:
get:
tags: [Dashboard]
summary: 当前活跃连接
security:
- bearerAuth: []
responses:
'200':
description: OK
/subscriptions:
get:
tags: [Subscriptions]
summary: 订阅列表
security:
- bearerAuth: []
responses:
'200':
description: OK
post:
tags: [Subscriptions]
summary: 创建订阅
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Subscription'
responses:
'201':
description: Created
/subscriptions/{id}:
parameters:
- $ref: '#/components/parameters/IdPath'
put:
tags: [Subscriptions]
summary: 更新订阅
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Subscription'
responses:
'200':
description: OK
delete:
tags: [Subscriptions]
summary: 删除订阅
security:
- bearerAuth: []
responses:
'200':
description: OK
/subscriptions/{id}/refresh:
parameters:
- $ref: '#/components/parameters/IdPath'
post:
tags: [Subscriptions]
summary: 刷新订阅
security:
- bearerAuth: []
responses:
'200':
description: OK
/proxies:
get:
tags: [Proxies]
summary: 节点列表
security:
- bearerAuth: []
parameters:
- name: source
in: query
schema:
type: string
enum: [manual, subscription]
- name: subscription_id
in: query
schema:
type: integer
- name: country
in: query
schema:
type: string
- name: limit
in: query
schema:
type: integer
default: 50
maximum: 500
- name: offset
in: query
schema:
type: integer
default: 0
responses:
'200':
description: OK
post:
tags: [Proxies]
summary: 添加手动节点
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
properties:
name:
type: string
type:
type: string
raw_config:
type: string
responses:
'201':
description: Created
/proxies/export:
get:
tags: [Proxies]
summary: 导出手动节点
security:
- bearerAuth: []
parameters:
- name: format
in: query
schema:
type: string
enum: [json, links, text, txt]
default: json
responses:
'200':
description: 文件下载
/proxies/import:
post:
tags: [Proxies]
summary: 导入手动节点
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
properties:
mode:
type: string
enum: [append, replace]
text:
type: string
nodes:
type: array
items:
type: object
responses:
'200':
description: OK
/proxies/{id}:
parameters:
- $ref: '#/components/parameters/IdPath'
put:
tags: [Proxies]
summary: 更新节点
security:
- bearerAuth: []
responses:
'200':
description: OK
delete:
tags: [Proxies]
summary: 删除手动节点
security:
- bearerAuth: []
responses:
'200':
description: OK
/proxies/{id}/test:
parameters:
- $ref: '#/components/parameters/IdPath'
post:
tags: [Proxies]
summary: 单节点测速
security:
- bearerAuth: []
responses:
'200':
description: OK
/proxies/test-all:
post:
tags: [Proxies]
summary: 全部节点测速
security:
- bearerAuth: []
responses:
'200':
description: OK
/proxies/rebuild-countries:
post:
tags: [Proxies]
summary: 重新识别节点国家
security:
- bearerAuth: []
responses:
'200':
description: OK
/countries:
get:
tags: [Countries]
summary: 国家统计
security:
- bearerAuth: []
responses:
'200':
description: OK
/outbound:
get:
tags: [Outbound]
summary: 出站概览
security:
- bearerAuth: []
responses:
'200':
description: OK
/outbound/groups/{name}:
parameters:
- name: name
in: path
required: true
schema:
type: string
put:
tags: [Outbound]
summary: 切换策略组节点
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
required: [proxy]
properties:
proxy:
type: string
responses:
'200':
description: OK
/outbound/default-policy:
put:
tags: [Outbound]
summary: 设置默认出站
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
required: [policy]
properties:
policy:
type: string
responses:
'200':
description: OK
/traffic-logs:
get:
tags: [TrafficLogs]
summary: 请求日志
security:
- bearerAuth: []
parameters:
- name: keyword
in: query
schema:
type: string
- name: limit
in: query
schema:
type: integer
default: 50
- name: offset
in: query
schema:
type: integer
default: 0
responses:
'200':
description: OK
/rules:
get:
tags: [Rules]
summary: 规则列表
security:
- bearerAuth: []
parameters:
- name: source
in: query
schema:
type: string
responses:
'200':
description: OK
post:
tags: [Rules]
summary: 添加手动规则
security:
- bearerAuth: []
responses:
'201':
description: Created
/rules/export:
get:
tags: [Rules]
summary: 导出手动规则
security:
- bearerAuth: []
parameters:
- name: format
in: query
schema:
type: string
enum: [json, text, txt, clash]
default: json
responses:
'200':
description: 文件下载
/rules/import:
post:
tags: [Rules]
summary: 导入手动规则
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
properties:
mode:
type: string
enum: [append, replace]
text:
type: string
rules:
type: array
items:
type: object
responses:
'200':
description: OK
/rules/merged:
get:
tags: [Rules]
summary: 合成规则预览
security:
- bearerAuth: []
parameters:
- name: limit
in: query
schema:
type: integer
- name: offset
in: query
schema:
type: integer
responses:
'200':
description: OK
/rules/test:
post:
tags: [Rules]
summary: 规则命中测试
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
required: [url]
properties:
url:
type: string
responses:
'200':
description: OK
/rules/{id}:
parameters:
- $ref: '#/components/parameters/IdPath'
put:
tags: [Rules]
summary: 更新规则
security:
- bearerAuth: []
responses:
'200':
description: OK
delete:
tags: [Rules]
summary: 删除手动规则
security:
- bearerAuth: []
responses:
'200':
description: OK
/settings:
get:
tags: [Settings]
summary: 获取系统设置
security:
- bearerAuth: []
responses:
'200':
description: OK
put:
tags: [Settings]
summary: 更新系统设置
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
additionalProperties:
type: string
responses:
'200':
description: OK
/logs:
get:
tags: [Logs]
summary: 运行日志
security:
- bearerAuth: []
parameters:
- name: level
in: query
schema:
type: string
- name: limit
in: query
schema:
type: integer
responses:
'200':
description: OK
/audit-logs:
get:
tags: [Logs]
summary: 审计日志
security:
- bearerAuth: []
responses:
'200':
description: OK
/system/status:
get:
tags: [System]
summary: 系统状态
security:
- bearerAuth: []
responses:
'200':
description: OK
/system/reload:
post:
tags: [System]
summary: 重载内核
security:
- bearerAuth: []
responses:
'200':
description: OK
/system/geo:
get:
tags: [System]
summary: Geo 数据状态
security:
- bearerAuth: []
responses:
'200':
description: OK
/system/geo/update:
post:
tags: [System]
summary: 更新 Geo 数据
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
properties:
force:
type: boolean
responses:
'200':
description: OK
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: 登录 token 或 API Keyprism_ 开头)
apiKeyAuth:
type: apiKey
in: header
name: X-API-Key
description: 系统设置中申请的 API Key
parameters:
IdPath:
name: id
in: path
required: true
schema:
type: integer
format: int64
schemas:
Subscription:
type: object
properties:
name:
type: string
url:
type: string
type:
type: string
description: auto / clash / links
interval_sec:
type: integer
enabled:
type: boolean
Error:
type: object
properties:
error:
type: string
responses:
Unauthorized:
description: 未授权
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
+48
View File
@@ -0,0 +1,48 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestServeOpenAPIJSON(t *testing.T) {
s := &Server{}
r := s.Router()
req := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.json", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Fatalf("content-type: %s", ct)
}
var doc map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &doc); err != nil {
t.Fatal(err)
}
if doc["openapi"] != "3.0.3" {
t.Fatalf("unexpected openapi version: %v", doc["openapi"])
}
}
func TestServeOpenAPIDocs(t *testing.T) {
s := &Server{}
r := s.Router()
req := httptest.NewRequest(http.MethodGet, "/api/docs", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d", w.Code)
}
if !strings.Contains(w.Body.String(), "swagger-ui") {
t.Fatal("expected swagger ui html")
}
}
+35
View File
@@ -0,0 +1,35 @@
package api
import "strconv"
const (
defaultPageLimit = 50
maxPageLimit = 500
)
func parseLimitOffset(limitStr, offsetStr string) (limit, offset int) {
limit, _ = strconv.Atoi(limitStr)
offset, _ = strconv.Atoi(offsetStr)
if limit <= 0 {
limit = defaultPageLimit
}
if limit > maxPageLimit {
limit = maxPageLimit
}
if offset < 0 {
offset = 0
}
return limit, offset
}
func paginateSlice[T any](items []T, limit, offset int) ([]T, int) {
total := len(items)
if offset >= total {
return nil, total
}
end := offset + limit
if end > total {
end = total
}
return items[offset:end], total
}
+47
View File
@@ -0,0 +1,47 @@
package api
import (
"embed"
"io/fs"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
//go:embed static/*
var staticFS embed.FS
func (s *Server) serveSPA(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
sub, err := fs.Sub(staticFS, "static")
if err != nil {
c.String(http.StatusInternalServerError, "static fs error")
return
}
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if path == "" {
path = "index.html"
}
data, err := fs.ReadFile(sub, path)
if err != nil {
data, err = fs.ReadFile(sub, "index.html")
if err != nil {
c.String(http.StatusNotFound, "not found")
return
}
}
contentType := "text/html; charset=utf-8"
switch {
case strings.HasSuffix(path, ".js"):
contentType = "application/javascript"
case strings.HasSuffix(path, ".css"):
contentType = "text/css"
case strings.HasSuffix(path, ".svg"):
contentType = "image/svg+xml"
}
c.Data(http.StatusOK, contentType, data)
}
@@ -0,0 +1 @@
import{Kt as e,R as t,Ut as n,V as r,b as i,d as a,m as o,p as s}from"./client-CiT_nDm8.js";var c={class:`page-header`},l={class:`page-title`},u={key:0,class:`page-desc`},d={key:0,class:`page-actions`},f=i({__name:`PageHeader`,props:{title:{},description:{}},setup(n){return(i,f)=>(t(),o(`div`,c,[a(`div`,null,[a(`h1`,l,e(n.title),1),n.description?(t(),o(`p`,u,e(n.description),1)):s(``,!0)]),i.$slots.actions?(t(),o(`div`,d,[r(i.$slots,`actions`)])):s(``,!0)]))}}),p={class:`app-card`},m={key:0,class:`app-card__header`},h={class:`app-card__title`},g=i({__name:`AppCard`,props:{title:{},padded:{type:Boolean}},setup(i){return(c,l)=>(t(),o(`div`,p,[i.title||c.$slots.header?(t(),o(`div`,m,[r(c.$slots,`header`,{},()=>[a(`h3`,h,e(i.title),1)])])):s(``,!0),a(`div`,{class:n(i.padded?`app-card__body--padded`:``)},[r(c.$slots,`default`)],2)]))}});export{f as n,g as t};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{H as e,R as t,_ as n,b as r,d as i,et as a,m as o,ut as s,v as c,y as l}from"./client-CiT_nDm8.js";import{i as u,n as d,o as f,t as p}from"./index-B4K9RppJ.js";var m={class:`login-page`},h={class:`login-wrap`},g={class:`login-card`},_=r({__name:`LoginView`,setup(r){let _=d(),v=p(),y=s(``),b=s(!1);async function x(){b.value=!0;try{await v.login(y.value),_.push(`/`)}catch{u.error(`密码错误`)}finally{b.value=!1}}return(r,s)=>{let u=e(`el-input`),d=e(`el-form-item`),p=e(`el-button`),_=e(`el-form`);return t(),o(`div`,m,[i(`div`,h,[s[4]||=n(`<div class="login-brand-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"></path><path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linejoin="round"></path><path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linejoin="round"></path></svg></div><h1 class="page-title">Prism</h1><p class="page-desc">HTTP / SOCKS5 代理网关管理台</p>`,3),i(`div`,g,[s[2]||=i(`h3`,{class:`app-card__title`,style:{"margin-bottom":`24px`}},`登录`,-1),l(_,{size:`large`,onSubmit:f(x,[`prevent`])},{default:a(()=>[l(d,{label:`密码`},{default:a(()=>[l(u,{modelValue:y.value,"onUpdate:modelValue":s[0]||=e=>y.value=e,type:`password`,"show-password":``,placeholder:`默认 admin`},null,8,[`modelValue`])]),_:1}),l(p,{type:`primary`,"native-type":`submit`,loading:b.value,style:{width:`100%`}},{default:a(()=>[...s[1]||=[c(` 登录 `,-1)]]),_:1},8,[`loading`])]),_:1}),s[3]||=i(`p`,{class:`form-hint`,style:{"margin-top":`16px`,"text-align":`center`}},` 首次使用默认密码为 admin,请在设置中修改 `,-1)])])])}}});export{_ as default};
@@ -0,0 +1 @@
import{F as e,H as t,R as n,U as r,b as i,d as a,et as o,f as s,m as c,p as l,t as u,tt as d,ut as f,v as p,y as m}from"./client-CiT_nDm8.js";import{a as h}from"./index-B4K9RppJ.js";import{n as g,t as _}from"./AppCard-ClnBgZ-s.js";var v={class:`page`},y={class:`tabs-bar`},b={key:0,class:`card-toolbar`},x={key:2,class:`card-pager`},S=50,C=i({__name:`LogsView`,setup(i){let C=f([]),w=f([]),T=f(0),E=f(!1),D=f(``),O=f(``),k=f(1),A=f(`runtime`);async function j(){E.value=!0;try{if(A.value===`audit`){let{data:e}=await u.get(`/audit-logs`);w.value=e.items||[];return}let{data:e}=await u.get(`/logs`,{params:{level:D.value,keyword:O.value,limit:S,offset:(k.value-1)*S}});C.value=e.items||[],T.value=e.total||0}finally{E.value=!1}}return e(j),(e,i)=>{let u=t(`el-tab-pane`),f=t(`el-tabs`),M=t(`el-option`),N=t(`el-select`),P=t(`el-input`),F=t(`el-button`),I=t(`el-table-column`),L=t(`el-table`),R=t(`el-pagination`),z=r(`loading`);return n(),c(`div`,v,[m(g,{title:`运行日志`,description:`Prism 与代理内核运行日志`}),m(_,null,{default:o(()=>[a(`div`,y,[m(f,{modelValue:A.value,"onUpdate:modelValue":i[0]||=e=>A.value=e,onTabChange:j},{default:o(()=>[m(u,{label:`运行日志`,name:`runtime`}),m(u,{label:`审计日志`,name:`audit`})]),_:1},8,[`modelValue`])]),A.value===`runtime`?(n(),c(`div`,b,[m(N,{modelValue:D.value,"onUpdate:modelValue":i[1]||=e=>D.value=e,placeholder:`级别`,clearable:``,style:{width:`120px`},onChange:j},{default:o(()=>[m(M,{label:`info`,value:`info`}),m(M,{label:`warn`,value:`warn`}),m(M,{label:`error`,value:`error`})]),_:1},8,[`modelValue`]),m(P,{modelValue:O.value,"onUpdate:modelValue":i[2]||=e=>O.value=e,placeholder:`搜索关键词`,clearable:``,style:{width:`200px`},onKeyup:h(j,[`enter`])},null,8,[`modelValue`]),m(F,{onClick:j},{default:o(()=>[...i[4]||=[p(`刷新`,-1)]]),_:1})])):l(``,!0),A.value===`runtime`?d((n(),s(L,{key:1,data:C.value,size:`small`},{default:o(()=>[m(I,{prop:`created_at`,label:`时间`,width:`170`}),m(I,{prop:`level`,label:`级别`,width:`80`}),m(I,{prop:`source`,label:`来源`,width:`100`}),m(I,{prop:`message`,label:`消息`,"min-width":`300`,"show-overflow-tooltip":``})]),_:1},8,[`data`])),[[z,E.value]]):l(``,!0),A.value===`runtime`?(n(),c(`div`,x,[m(R,{"current-page":k.value,"onUpdate:currentPage":i[3]||=e=>k.value=e,"page-size":S,total:T.value,layout:`total, prev, pager, next`,onCurrentChange:j},null,8,[`current-page`,`total`])])):d((n(),s(L,{key:3,data:w.value,size:`small`},{default:o(()=>[m(I,{prop:`created_at`,label:`时间`,width:`170`}),m(I,{prop:`action`,label:`操作`,width:`100`}),m(I,{prop:`resource`,label:`资源`,width:`120`}),m(I,{prop:`detail`,label:`详情`,"min-width":`200`,"show-overflow-tooltip":``})]),_:1},8,[`data`])),[[z,E.value]])]),_:1})])}}});export{C as default};
@@ -0,0 +1 @@
import{F as e,H as t,Kt as n,R as r,U as i,Ut as a,b as o,d as s,et as c,f as l,m as u,t as d,tt as f,u as p,ut as m,v as h,y as g}from"./client-CiT_nDm8.js";import{i as _}from"./index-B4K9RppJ.js";import{n as v,t as y}from"./AppCard-ClnBgZ-s.js";var b={class:`page`},x={class:`tabs-bar`},S=o({__name:`ProxiesView`,setup(o){let S=m([]),C=m(!1),w=m(`all`),T=m(!1),E=m(!1),D=m({name:``,type:`socks5`,server:``,port:1080,username:``,password:``}),O=p(()=>w.value===`manual`?S.value.filter(e=>e.source===`manual`):w.value===`subscription`?S.value.filter(e=>e.source===`subscription`):S.value);function k(e){return e<0?`latency-dead`:e<200?`latency-good`:e<500?`latency-warn`:`latency-bad`}async function A(){C.value=!0;try{let{data:e}=await d.get(`/proxies`);S.value=e.items||[]}finally{C.value=!1}}async function j(e){let{data:t}=await d.post(`/proxies/${e.id}/test`);e.latency_ms=t.latency_ms,e.alive=t.alive}async function M(){T.value=!0;try{await d.post(`/proxies/test-all`),_.success(`测速完成`),A()}finally{T.value=!1}}function N(){D.value={name:``,type:`socks5`,server:``,port:1080,username:``,password:``},E.value=!0}async function P(){let e={type:D.value.type,server:D.value.server,port:D.value.port,...D.value.username?{username:D.value.username,password:D.value.password}:{}};await d.post(`/proxies`,{name:D.value.name,type:D.value.type,raw_config:JSON.stringify(e)}),_.success(`节点已添加`),E.value=!1,A()}return e(A),(e,o)=>{let d=t(`el-button`),p=t(`el-tab-pane`),m=t(`el-tabs`),_=t(`el-table-column`),S=t(`el-tag`),A=t(`el-table`),F=t(`el-input`),I=t(`el-form-item`),L=t(`el-option`),R=t(`el-select`),z=t(`el-input-number`),B=t(`el-form`),V=t(`el-dialog`),H=i(`loading`);return r(),u(`div`,b,[g(v,{title:`代理节点`,description:`查看节点状态与延迟,支持手动添加节点`},{actions:c(()=>[g(d,{onClick:N},{default:c(()=>[...o[9]||=[h(`添加节点`,-1)]]),_:1}),g(d,{loading:T.value,onClick:M},{default:c(()=>[...o[10]||=[h(`全部测速`,-1)]]),_:1},8,[`loading`])]),_:1}),g(y,null,{default:c(()=>[s(`div`,x,[g(m,{modelValue:w.value,"onUpdate:modelValue":o[0]||=e=>w.value=e},{default:c(()=>[g(p,{label:`全部`,name:`all`}),g(p,{label:`订阅`,name:`subscription`}),g(p,{label:`手动`,name:`manual`})]),_:1},8,[`modelValue`])]),f((r(),l(A,{data:O.value,size:`small`},{default:c(()=>[g(_,{prop:`name`,label:`名称`,"min-width":`140`}),g(_,{prop:`type`,label:`类型`,width:`90`}),g(_,{prop:`source`,label:`来源`,width:`90`}),g(_,{label:`延迟`,width:`100`},{default:c(({row:e})=>[s(`span`,{class:a(k(e.latency_ms))},n(e.latency_ms>=0?`${e.latency_ms} ms`:`未测`),3)]),_:1}),g(_,{label:`状态`,width:`80`},{default:c(({row:e})=>[g(S,{type:e.alive?`success`:`info`,size:`small`},{default:c(()=>[h(n(e.alive?`可用`:`未知`),1)]),_:2},1032,[`type`])]),_:1}),g(_,{label:`操作`,width:`80`,fixed:`right`},{default:c(({row:e})=>[g(d,{size:`small`,link:``,onClick:t=>j(e)},{default:c(()=>[...o[11]||=[h(`测速`,-1)]]),_:1},8,[`onClick`])]),_:1})]),_:1},8,[`data`])),[[H,C.value]])]),_:1}),g(V,{modelValue:E.value,"onUpdate:modelValue":o[8]||=e=>E.value=e,title:`添加手动节点`,width:`520px`,"destroy-on-close":``},{footer:c(()=>[g(d,{onClick:o[7]||=e=>E.value=!1},{default:c(()=>[...o[12]||=[h(`取消`,-1)]]),_:1}),g(d,{type:`primary`,onClick:P},{default:c(()=>[...o[13]||=[h(`确认`,-1)]]),_:1})]),default:c(()=>[g(B,{"label-width":`100px`},{default:c(()=>[g(I,{label:`名称`},{default:c(()=>[g(F,{modelValue:D.value.name,"onUpdate:modelValue":o[1]||=e=>D.value.name=e},null,8,[`modelValue`])]),_:1}),g(I,{label:`类型`},{default:c(()=>[g(R,{modelValue:D.value.type,"onUpdate:modelValue":o[2]||=e=>D.value.type=e,style:{width:`100%`}},{default:c(()=>[g(L,{label:`SOCKS5`,value:`socks5`}),g(L,{label:`HTTP`,value:`http`})]),_:1},8,[`modelValue`])]),_:1}),g(I,{label:`服务器`},{default:c(()=>[g(F,{modelValue:D.value.server,"onUpdate:modelValue":o[3]||=e=>D.value.server=e},null,8,[`modelValue`])]),_:1}),g(I,{label:`端口`},{default:c(()=>[g(z,{modelValue:D.value.port,"onUpdate:modelValue":o[4]||=e=>D.value.port=e,min:1,max:65535},null,8,[`modelValue`])]),_:1}),g(I,{label:`用户名`},{default:c(()=>[g(F,{modelValue:D.value.username,"onUpdate:modelValue":o[5]||=e=>D.value.username=e},null,8,[`modelValue`])]),_:1}),g(I,{label:`密码`},{default:c(()=>[g(F,{modelValue:D.value.password,"onUpdate:modelValue":o[6]||=e=>D.value.password=e,type:`password`,"show-password":``},null,8,[`modelValue`])]),_:1})]),_:1})]),_:1},8,[`modelValue`])])}}});export{S as default};
@@ -0,0 +1 @@
import{F as e,H as t,R as n,U as r,b as i,d as a,et as o,f as s,m as c,t as l,tt as u,ut as d,v as f,y as p}from"./client-CiT_nDm8.js";import{i as m,r as h}from"./index-B4K9RppJ.js";import{n as g,t as _}from"./AppCard-ClnBgZ-s.js";var v={class:`page`},y={class:`content-grid content-grid--2`},b=i({__name:`RulesView`,setup(i){let b=d([]),x=d([]),S=d(!1),C=d(!1),w=d({rule_type:`DOMAIN-SUFFIX`,payload:``,target:`PROXY`,priority:100});async function T(){S.value=!0;try{let[e,t]=await Promise.all([l.get(`/rules?source=manual`),l.get(`/rules/merged`)]);b.value=e.data.items||[],x.value=t.data.items||[]}finally{S.value=!1}}function E(){w.value={rule_type:`DOMAIN-SUFFIX`,payload:``,target:`PROXY`,priority:100},C.value=!0}async function D(){await l.post(`/rules`,w.value),m.success(`规则已添加`),C.value=!1,T()}async function O(e){await h.confirm(`确定删除该规则?`,`确认`),await l.delete(`/rules/${e.id}`),T()}return e(T),(e,i)=>{let l=t(`el-button`),d=t(`el-table-column`),m=t(`el-table`),h=t(`el-option`),T=t(`el-select`),k=t(`el-form-item`),A=t(`el-input`),j=t(`el-input-number`),M=t(`el-form`),N=t(`el-dialog`),P=r(`loading`);return n(),c(`div`,v,[p(g,{title:`路由规则`,description:`手动规则优先级高于订阅规则,自上而下首条命中`},{actions:o(()=>[p(l,{type:`primary`,onClick:E},{default:o(()=>[...i[6]||=[f(`添加规则`,-1)]]),_:1})]),_:1}),a(`div`,y,[p(_,{title:`手动规则(高优先级)`},{default:o(()=>[u((n(),s(m,{data:b.value,size:`small`},{default:o(()=>[p(d,{prop:`priority`,label:`优先级`,width:`80`}),p(d,{prop:`rule_type`,label:`类型`,width:`120`}),p(d,{prop:`payload`,label:`匹配`,"min-width":`120`}),p(d,{prop:`target`,label:`目标`,width:`100`}),p(d,{label:`操作`,width:`80`,fixed:`right`},{default:o(({row:e})=>[p(l,{size:`small`,type:`danger`,link:``,onClick:t=>O(e)},{default:o(()=>[...i[7]||=[f(`删除`,-1)]]),_:1},8,[`onClick`])]),_:1})]),_:1},8,[`data`])),[[P,S.value]])]),_:1}),p(_,{title:`合成后规则预览`},{default:o(()=>[p(m,{data:x.value,size:`small`,"max-height":`400`},{default:o(()=>[p(d,{prop:`priority`,label:``,width:`70`}),p(d,{prop:`rule`,label:`规则`,"min-width":`200`}),p(d,{prop:`source`,label:`来源`,width:`90`})]),_:1},8,[`data`])]),_:1})]),p(N,{modelValue:C.value,"onUpdate:modelValue":i[5]||=e=>C.value=e,title:`添加规则`,width:`520px`,"destroy-on-close":``},{footer:o(()=>[p(l,{onClick:i[4]||=e=>C.value=!1},{default:o(()=>[...i[9]||=[f(`取消`,-1)]]),_:1}),p(l,{type:`primary`,onClick:D},{default:o(()=>[...i[10]||=[f(`确认`,-1)]]),_:1})]),default:o(()=>[p(M,{"label-width":`100px`},{default:o(()=>[p(k,{label:`类型`},{default:o(()=>[p(T,{modelValue:w.value.rule_type,"onUpdate:modelValue":i[0]||=e=>w.value.rule_type=e,style:{width:`100%`}},{default:o(()=>[p(h,{label:`DOMAIN`,value:`DOMAIN`}),p(h,{label:`DOMAIN-SUFFIX`,value:`DOMAIN-SUFFIX`}),p(h,{label:`DOMAIN-KEYWORD`,value:`DOMAIN-KEYWORD`}),p(h,{label:`IP-CIDR`,value:`IP-CIDR`}),p(h,{label:`GEOIP`,value:`GEOIP`})]),_:1},8,[`modelValue`])]),_:1}),p(k,{label:`匹配值`},{default:o(()=>[p(A,{modelValue:w.value.payload,"onUpdate:modelValue":i[1]||=e=>w.value.payload=e},null,8,[`modelValue`])]),_:1}),p(k,{label:`目标`},{default:o(()=>[p(A,{modelValue:w.value.target,"onUpdate:modelValue":i[2]||=e=>w.value.target=e,placeholder:`PROXY / DIRECT / REJECT`},null,8,[`modelValue`])]),_:1}),p(k,{label:`优先级`},{default:o(()=>[p(j,{modelValue:w.value.priority,"onUpdate:modelValue":i[3]||=e=>w.value.priority=e,min:1,max:9999},null,8,[`modelValue`]),i[8]||=a(`p`,{class:`form-hint`},`数值越小优先级越高`,-1)]),_:1})]),_:1})]),_:1},8,[`modelValue`])])}}});export{b as default};
@@ -0,0 +1 @@
import{F as e,H as t,R as n,U as r,b as i,d as a,et as o,f as s,m as c,t as l,tt as u,ut as d,v as f,y as p}from"./client-CiT_nDm8.js";import{i as m}from"./index-B4K9RppJ.js";import{n as h,t as g}from"./AppCard-ClnBgZ-s.js";var _={class:`page`},v=i({__name:`SettingsView`,setup(i){let v=d({}),y=d(!1),b=d(!1);async function x(){y.value=!0;try{let{data:e}=await l.get(`/settings`);v.value=e}finally{y.value=!1}}async function S(){b.value=!0;try{await l.put(`/settings`,v.value),m.success(`设置已保存`)}finally{b.value=!1}}async function C(){await l.post(`/system/reload`),m.success(`内核已重载`)}return e(x),(e,i)=>{let l=t(`el-button`),d=t(`el-input`),m=t(`el-form-item`),x=t(`el-option`),w=t(`el-select`),T=t(`el-switch`),E=t(`el-form`),D=r(`loading`);return n(),c(`div`,_,[p(h,{title:`系统设置`,description:`代理端口、调度间隔与安全配置`},{actions:o(()=>[p(l,{onClick:C},{default:o(()=>[...i[10]||=[f(`重载内核`,-1)]]),_:1}),p(l,{type:`primary`,loading:b.value,onClick:S},{default:o(()=>[...i[11]||=[f(`保存`,-1)]]),_:1},8,[`loading`])]),_:1}),u((n(),s(g,{title:`入站与 API`,padded:``},{default:o(()=>[p(E,{"label-width":`120px`},{default:o(()=>[i[14]||=a(`div`,{class:`section-title`},`代理入站`,-1),p(m,{label:`HTTP 端口`},{default:o(()=>[p(d,{modelValue:v.value.http_port,"onUpdate:modelValue":i[0]||=e=>v.value.http_port=e},null,8,[`modelValue`])]),_:1}),p(m,{label:`SOCKS5 端口`},{default:o(()=>[p(d,{modelValue:v.value.socks_port,"onUpdate:modelValue":i[1]||=e=>v.value.socks_port=e},null,8,[`modelValue`])]),_:1}),i[15]||=a(`div`,{class:`section-title`},`管理 API`,-1),p(m,{label:`API 端口`},{default:o(()=>[p(d,{modelValue:v.value.api_port,"onUpdate:modelValue":i[2]||=e=>v.value.api_port=e},null,8,[`modelValue`]),i[12]||=a(`p`,{class:`form-hint`},`修改后需重启进程生效`,-1)]),_:1}),p(m,{label:`Mihomo API`},{default:o(()=>[p(d,{modelValue:v.value.mihomo_api_port,"onUpdate:modelValue":i[3]||=e=>v.value.mihomo_api_port=e},null,8,[`modelValue`])]),_:1}),p(m,{label:`API Secret`},{default:o(()=>[p(d,{modelValue:v.value.api_secret,"onUpdate:modelValue":i[4]||=e=>v.value.api_secret=e,placeholder:`Clash API 密钥`},null,8,[`modelValue`])]),_:1}),i[16]||=a(`div`,{class:`section-title`},`调度`,-1),p(m,{label:`健康检查间隔`},{default:o(()=>[p(d,{modelValue:v.value.health_interval_sec,"onUpdate:modelValue":i[5]||=e=>v.value.health_interval_sec=e},null,8,[`modelValue`]),i[13]||=a(`p`,{class:`form-hint`},``,-1)]),_:1}),p(m,{label:`默认策略`},{default:o(()=>[p(w,{modelValue:v.value.default_policy,"onUpdate:modelValue":i[6]||=e=>v.value.default_policy=e,style:{width:`100%`}},{default:o(()=>[p(x,{label:`DIRECT`,value:`DIRECT`}),p(x,{label:`PROXY`,value:`PROXY`}),p(x,{label:`REJECT`,value:`REJECT`})]),_:1},8,[`modelValue`])]),_:1}),p(m,{label:`日志级别`},{default:o(()=>[p(w,{modelValue:v.value.log_level,"onUpdate:modelValue":i[7]||=e=>v.value.log_level=e,style:{width:`100%`}},{default:o(()=>[p(x,{label:`info`,value:`info`}),p(x,{label:`warning`,value:`warning`}),p(x,{label:`error`,value:`error`}),p(x,{label:`debug`,value:`debug`})]),_:1},8,[`modelValue`])]),_:1}),i[17]||=a(`div`,{class:`section-title`},`安全`,-1),p(m,{label:`启用登录`},{default:o(()=>[p(T,{modelValue:v.value.auth_enabled,"onUpdate:modelValue":i[8]||=e=>v.value.auth_enabled=e,"active-value":`true`,"inactive-value":`false`},null,8,[`modelValue`])]),_:1}),p(m,{label:`新密码`},{default:o(()=>[p(d,{modelValue:v.value.admin_password,"onUpdate:modelValue":i[9]||=e=>v.value.admin_password=e,type:`password`,"show-password":``,placeholder:`留空则不修改`},null,8,[`modelValue`])]),_:1})]),_:1})]),_:1})),[[D,y.value]])])}}});export{v as default};
@@ -0,0 +1 @@
import{F as e,H as t,Kt as n,R as r,U as i,b as a,d as o,et as s,f as c,m as l,t as u,tt as d,ut as f,v as p,y as m}from"./client-CiT_nDm8.js";import{i as h,r as g}from"./index-B4K9RppJ.js";import{n as _,t as v}from"./AppCard-ClnBgZ-s.js";var y={class:`page`},b=a({__name:`SubscriptionsView`,setup(a){let b=f([]),x=f(!1),S=f(!1),C=f({name:``,url:``,type:`clash`,interval_sec:3600});async function w(){x.value=!0;try{let{data:e}=await u.get(`/subscriptions`);b.value=e.items||[]}finally{x.value=!1}}function T(){C.value={name:``,url:``,type:`clash`,interval_sec:3600},S.value=!0}async function E(){await u.post(`/subscriptions`,C.value),h.success(`已添加订阅`),S.value=!1,w()}async function D(e){await u.post(`/subscriptions/${e.id}/refresh`),h.success(`刷新成功`),w()}async function O(e){await g.confirm(`确定删除订阅「${e.name}」?`,`确认`),await u.delete(`/subscriptions/${e.id}`),h.success(`已删除`),w()}return e(w),(e,a)=>{let u=t(`el-button`),f=t(`el-table-column`),h=t(`el-tag`),g=t(`el-table`),w=t(`el-input`),k=t(`el-form-item`),A=t(`el-option`),j=t(`el-select`),M=t(`el-input-number`),N=t(`el-form`),P=t(`el-dialog`),F=i(`loading`);return r(),l(`div`,y,[m(_,{title:`订阅管理`,description:`添加并定时更新 Clash 或节点链接订阅`},{actions:s(()=>[m(u,{type:`primary`,onClick:T},{default:s(()=>[...a[6]||=[p(`添加订阅`,-1)]]),_:1})]),_:1}),m(v,null,{default:s(()=>[d((r(),c(g,{data:b.value,size:`small`},{default:s(()=>[m(f,{prop:`name`,label:`名称`,"min-width":`120`}),m(f,{prop:`type`,label:`类型`,width:`80`}),m(f,{prop:`url`,label:`URL`,"min-width":`200`,"show-overflow-tooltip":``}),m(f,{prop:`node_count`,label:`节点数`,width:`80`}),m(f,{label:`状态`,width:`100`},{default:s(({row:e})=>[m(h,{type:e.last_error?`danger`:`success`,size:`small`},{default:s(()=>[p(n(e.last_error?`异常`:`正常`),1)]),_:2},1032,[`type`])]),_:1}),m(f,{prop:`last_fetch_at`,label:`上次更新`,width:`160`}),m(f,{label:`操作`,width:`160`,fixed:`right`},{default:s(({row:e})=>[m(u,{size:`small`,link:``,onClick:t=>D(e)},{default:s(()=>[...a[7]||=[p(`刷新`,-1)]]),_:1},8,[`onClick`]),m(u,{size:`small`,type:`danger`,link:``,onClick:t=>O(e)},{default:s(()=>[...a[8]||=[p(`删除`,-1)]]),_:1},8,[`onClick`])]),_:1})]),_:1},8,[`data`])),[[F,x.value]])]),_:1}),m(P,{modelValue:S.value,"onUpdate:modelValue":a[5]||=e=>S.value=e,title:`添加订阅`,width:`520px`,"destroy-on-close":``},{footer:s(()=>[m(u,{onClick:a[4]||=e=>S.value=!1},{default:s(()=>[...a[10]||=[p(`取消`,-1)]]),_:1}),m(u,{type:`primary`,onClick:E},{default:s(()=>[...a[11]||=[p(`确认`,-1)]]),_:1})]),default:s(()=>[m(N,{"label-width":`100px`},{default:s(()=>[m(k,{label:`名称`},{default:s(()=>[m(w,{modelValue:C.value.name,"onUpdate:modelValue":a[0]||=e=>C.value.name=e},null,8,[`modelValue`])]),_:1}),m(k,{label:`URL`},{default:s(()=>[m(w,{modelValue:C.value.url,"onUpdate:modelValue":a[1]||=e=>C.value.url=e},null,8,[`modelValue`])]),_:1}),m(k,{label:`类型`},{default:s(()=>[m(j,{modelValue:C.value.type,"onUpdate:modelValue":a[2]||=e=>C.value.type=e,style:{width:`100%`}},{default:s(()=>[m(A,{label:`Clash YAML`,value:`clash`}),m(A,{label:`节点链接`,value:`links`})]),_:1},8,[`modelValue`])]),_:1}),m(k,{label:`更新间隔`},{default:s(()=>[m(M,{modelValue:C.value.interval_sec,"onUpdate:modelValue":a[3]||=e=>C.value.interval_sec=e,min:300,step:300},null,8,[`modelValue`]),a[9]||=o(`p`,{class:`form-hint`},`秒,最小 300`,-1)]),_:1})]),_:1})]),_:1},8,[`modelValue`])])}}});export{b as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<script type="module" crossorigin src="/assets/index-B4K9RppJ.js"></script>
<link rel="modulepreload" crossorigin href="/assets/client-CiT_nDm8.js">
<link rel="stylesheet" crossorigin href="/assets/index-CxbpOrcM.css">
</head>
<body>
<div id="app"></div>
</body>
</html>