Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 Key(prism_ 开头)
|
||||
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'
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
Reference in New Issue
Block a user