gengx
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/zeromicro/go-zero/rest"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
DSN string
|
||||
JWTSecret string
|
||||
ConfigEncryptionKey string
|
||||
MediaDir string
|
||||
Environment string
|
||||
AllowedOrigins []string
|
||||
SeedDemo bool
|
||||
BootstrapAdminUsername string
|
||||
BootstrapAdminPassword string
|
||||
BootstrapAdminRealName string
|
||||
}
|
||||
|
||||
type App struct {
|
||||
config Config
|
||||
db *sql.DB
|
||||
hub *Hub
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
type pageResult struct {
|
||||
Items any `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
func LoadConfig() Config {
|
||||
port, _ := strconv.Atoi(env("IM_PORT", "8888"))
|
||||
return Config{
|
||||
Host: env("IM_HOST", "0.0.0.0"),
|
||||
Port: port,
|
||||
DSN: env("IM_DB_DSN", "root:root@tcp(127.0.0.1:3306)/im?charset=utf8mb4&parseTime=True&loc=Local"),
|
||||
JWTSecret: env("IM_JWT_SECRET", "local-development-secret-change-me"),
|
||||
ConfigEncryptionKey: env("IM_CONFIG_ENCRYPTION_KEY", ""),
|
||||
MediaDir: env("IM_MEDIA_DIR", "./uploads"),
|
||||
Environment: strings.ToLower(env("IM_ENV", "development")),
|
||||
AllowedOrigins: csvEnv("IM_ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:5174,http://localhost:5180,http://localhost:5555,http://localhost:5556,http://localhost:5560,http://127.0.0.1:5173,http://127.0.0.1:5174,http://127.0.0.1:5180,http://127.0.0.1:5555,http://127.0.0.1:5556,http://127.0.0.1:5560"),
|
||||
SeedDemo: boolEnv("IM_SEED_DEMO", false),
|
||||
BootstrapAdminUsername: strings.TrimSpace(os.Getenv("IM_BOOTSTRAP_ADMIN_USERNAME")),
|
||||
BootstrapAdminPassword: os.Getenv("IM_BOOTSTRAP_ADMIN_PASSWORD"),
|
||||
BootstrapAdminRealName: env("IM_BOOTSTRAP_ADMIN_REAL_NAME", "平台管理员"),
|
||||
}
|
||||
}
|
||||
|
||||
func New(config Config) (*App, error) {
|
||||
if err := validateConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("mysql", config.DSN)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open mysql: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(30)
|
||||
db.SetMaxIdleConns(10)
|
||||
// Keep pooled connections below the local MySQL wait_timeout (120s).
|
||||
db.SetConnMaxIdleTime(30 * time.Second)
|
||||
db.SetConnMaxLifetime(90 * time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("connect mysql (run scripts/migrate.ps1 first): %w", err)
|
||||
}
|
||||
return &App{config: config, db: db, hub: NewHub()}, nil
|
||||
}
|
||||
|
||||
func (a *App) Close() { _ = a.db.Close() }
|
||||
|
||||
func (a *App) Run() {
|
||||
server := rest.MustNewServer(rest.RestConf{
|
||||
Host: a.config.Host,
|
||||
Port: a.config.Port,
|
||||
MaxBytes: 16 << 20,
|
||||
MaxConns: 5_000,
|
||||
Timeout: 35_000,
|
||||
}, rest.WithCors(a.config.AllowedOrigins...))
|
||||
defer server.Stop()
|
||||
server.Use(a.requestMetadata)
|
||||
server.AddRoutes(a.routes())
|
||||
log.Printf("星遇 API listening on http://127.0.0.1:%d", a.config.Port)
|
||||
server.Start()
|
||||
}
|
||||
|
||||
type responseRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (r *responseRecorder) WriteHeader(status int) {
|
||||
r.status = status
|
||||
r.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
var safeRequestID = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`)
|
||||
|
||||
func (a *App) requestMetadata(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
started := time.Now()
|
||||
requestID := strings.TrimSpace(r.Header.Get("X-Request-ID"))
|
||||
if !safeRequestID.MatchString(requestID) {
|
||||
requestID = randomToken()[:32]
|
||||
}
|
||||
w.Header().Set("X-Request-ID", requestID)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/admin/") {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
}
|
||||
if r.URL.Path == "/ws" {
|
||||
next(w, r)
|
||||
log.Printf("request_id=%s method=%s path=%s status=%d duration_ms=%d ip=%s", requestID, r.Method, r.URL.Path, http.StatusSwitchingProtocols, time.Since(started).Milliseconds(), clientIP(r))
|
||||
return
|
||||
}
|
||||
recorder := &responseRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next(recorder, r)
|
||||
log.Printf("request_id=%s method=%s path=%s status=%d duration_ms=%d ip=%s", requestID, r.Method, r.URL.Path, recorder.status, time.Since(started).Milliseconds(), clientIP(r))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) routes() []rest.Route {
|
||||
routes := []rest.Route{
|
||||
{Method: http.MethodGet, Path: "/healthz", Handler: a.health},
|
||||
{Method: http.MethodPost, Path: "/api/v1/auth/sms/send", Handler: a.sendSMS},
|
||||
{Method: http.MethodPost, Path: "/api/v1/auth/register", Handler: a.register},
|
||||
{Method: http.MethodPost, Path: "/api/v1/auth/login/password", Handler: a.loginPassword},
|
||||
{Method: http.MethodPost, Path: "/api/v1/auth/login/sms", Handler: a.loginSMS},
|
||||
{Method: http.MethodGet, Path: "/api/v1/auth/oauth/providers", Handler: a.userOAuthProviders},
|
||||
{Method: http.MethodPost, Path: "/api/v1/auth/oauth/start", Handler: a.userOAuthStart},
|
||||
{Method: http.MethodGet, Path: "/api/v1/auth/oauth/callback", Handler: a.oauthCallback},
|
||||
{Method: http.MethodPost, Path: "/api/v1/auth/oauth/exchange", Handler: a.userOAuthExchange},
|
||||
{Method: http.MethodPost, Path: "/api/v1/auth/oauth/link", Handler: a.userOAuthLink},
|
||||
{Method: http.MethodPost, Path: "/api/v1/auth/password/reset", Handler: a.resetPassword},
|
||||
{Method: http.MethodPost, Path: "/api/v1/auth/token/refresh", Handler: a.refreshToken},
|
||||
{Method: http.MethodGet, Path: "/api/v1/membership/plans", Handler: a.membershipPlans},
|
||||
{Method: http.MethodGet, Path: "/api/v1/payment/channels", Handler: a.paymentChannels},
|
||||
{Method: http.MethodPost, Path: "/api/v1/payment/notify", Handler: a.paymentNotify},
|
||||
{Method: http.MethodGet, Path: "/api/v1/app/config", Handler: a.appConfig},
|
||||
{Method: http.MethodGet, Path: "/uploads/:name", Handler: a.serveMedia},
|
||||
{Method: http.MethodHead, Path: "/uploads/:name", Handler: a.serveMedia},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/auth/login", Handler: a.adminLogin},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/auth/refresh", Handler: a.adminRefresh},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/auth/oauth/providers", Handler: a.adminOAuthProviders},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/auth/oauth/start", Handler: a.adminOAuthStart},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/auth/oauth/callback", Handler: a.oauthCallback},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/auth/oauth/exchange", Handler: a.adminOAuthExchange},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/auth/oauth/link", Handler: a.adminOAuthLink},
|
||||
}
|
||||
routes = append(routes, a.userRoutes()...)
|
||||
routes = append(routes, a.adminRoutes()...)
|
||||
return routes
|
||||
}
|
||||
|
||||
func (a *App) userRoutes() []rest.Route {
|
||||
auth := func(next http.HandlerFunc) http.HandlerFunc { return a.requireAuth("user", next) }
|
||||
return []rest.Route{
|
||||
{Method: http.MethodPost, Path: "/api/v1/auth/logout", Handler: auth(a.logout)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/me", Handler: auth(a.me)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/me/profile", Handler: auth(a.me)},
|
||||
{Method: http.MethodPatch, Path: "/api/v1/me/profile", Handler: auth(a.updateProfile)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/users/search", Handler: auth(a.searchUsers)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/users/:id", Handler: auth(a.userProfile)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/me/following", Handler: auth(a.followingList)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/me/followers", Handler: auth(a.followerList)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/me/visitors", Handler: auth(a.visitorList)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/me/privacy", Handler: auth(a.getPrivacy)},
|
||||
{Method: http.MethodPut, Path: "/api/v1/me/privacy", Handler: auth(a.updatePrivacy)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/discover/recommendations", Handler: auth(a.discover)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/nearby/users", Handler: auth(a.nearby)},
|
||||
{Method: http.MethodPut, Path: "/api/v1/location", Handler: auth(a.updateLocation)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/users/:id/follow", Handler: auth(a.follow)},
|
||||
{Method: http.MethodDelete, Path: "/api/v1/users/:id/follow", Handler: auth(a.unfollow)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/users/:id/like", Handler: auth(a.likeUser)},
|
||||
{Method: http.MethodDelete, Path: "/api/v1/users/:id/like", Handler: auth(a.unlikeUser)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/users/:id/block", Handler: auth(a.blockUser)},
|
||||
{Method: http.MethodDelete, Path: "/api/v1/users/:id/block", Handler: auth(a.unblockUser)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/feed", Handler: auth(a.feed)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/posts/:id", Handler: auth(a.postDetail)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/users/:id/posts", Handler: auth(a.userPosts)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/posts", Handler: auth(a.createPost)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/media/upload", Handler: auth(a.uploadMedia)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/posts/:id/like", Handler: auth(a.likePost)},
|
||||
{Method: http.MethodDelete, Path: "/api/v1/posts/:id/like", Handler: auth(a.unlikePost)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/posts/:id/comments", Handler: auth(a.comments)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/posts/:id/comments", Handler: auth(a.createComment)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/im/conversations/direct", Handler: auth(a.directConversation)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/im/conversations", Handler: auth(a.conversations)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/im/conversations/:id/messages", Handler: auth(a.messages)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/im/conversations/:id/messages", Handler: auth(a.sendMessageHTTP)},
|
||||
{Method: http.MethodPatch, Path: "/api/v1/im/conversations/:id/settings", Handler: auth(a.conversationSettings)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/membership/status", Handler: auth(a.membershipStatus)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/orders", Handler: auth(a.createOrder)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/orders/:id/pay", Handler: auth(a.payOrder)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/orders/:id", Handler: auth(a.orderStatus)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/me/orders", Handler: auth(a.myOrders)},
|
||||
{Method: http.MethodGet, Path: "/api/v1/notifications", Handler: auth(a.notifications)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/notifications/read-all", Handler: auth(a.readAllNotifications)},
|
||||
{Method: http.MethodPost, Path: "/api/v1/reports", Handler: auth(a.createReport)},
|
||||
{Method: http.MethodGet, Path: "/ws", Handler: a.websocket},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) adminRoutes() []rest.Route {
|
||||
auth := func(next http.HandlerFunc) http.HandlerFunc { return a.requireAuth("admin", next) }
|
||||
return []rest.Route{
|
||||
{Method: http.MethodPost, Path: "/admin/v1/auth/logout", Handler: auth(a.adminLogout)},
|
||||
{Method: http.MethodPut, Path: "/admin/v1/me/password", Handler: auth(a.adminChangePassword)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/auth/codes", Handler: auth(a.adminCodes)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/user/info", Handler: auth(a.adminInfo)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/dashboard/overview", Handler: auth(a.dashboard)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/users", Handler: auth(a.adminUsers)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/users/:id", Handler: auth(a.adminUserDetail)},
|
||||
{Method: http.MethodPut, Path: "/admin/v1/users/:id/profile", Handler: auth(a.adminUpdateUserProfile)},
|
||||
{Method: http.MethodPut, Path: "/admin/v1/users/:id/verification", Handler: auth(a.adminUpdateVerification)},
|
||||
{Method: http.MethodPut, Path: "/admin/v1/users/:id/membership", Handler: auth(a.adminUpdateMembership)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/users/:id/password-reset", Handler: auth(a.adminResetUserPassword)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/users/:id/force-logout", Handler: auth(a.adminForceLogout)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/users/:id/sanctions", Handler: auth(a.adminUserSanctions)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/users/:id/sanctions", Handler: auth(a.adminUserSanctions)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/sanctions/:id/revoke", Handler: auth(a.adminRevokeSanction)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/users/:id/freeze", Handler: auth(a.adminUserStatus)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/users/:id/unfreeze", Handler: auth(a.adminUserStatus)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/users/:id/ban", Handler: auth(a.adminUserStatus)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/users/:id/unban", Handler: auth(a.adminUserStatus)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/posts", Handler: auth(a.adminPosts)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/posts/:id", Handler: auth(a.adminPostDetail)},
|
||||
{Method: http.MethodDelete, Path: "/admin/v1/posts/:id", Handler: auth(a.adminDeletePost)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/reports", Handler: auth(a.adminReports)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/reports/:id/handle", Handler: auth(a.adminHandleReport)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/risk/users", Handler: auth(a.adminRiskUsers)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/risk/events", Handler: auth(a.adminRiskEvents)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/membership/plans", Handler: auth(a.adminPlans)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/membership/plans", Handler: auth(a.adminCreatePlan)},
|
||||
{Method: http.MethodPatch, Path: "/admin/v1/membership/plans/:id", Handler: auth(a.adminUpdatePlan)},
|
||||
{Method: http.MethodPut, Path: "/admin/v1/membership/plans/:id", Handler: auth(a.adminUpdatePlan)},
|
||||
{Method: http.MethodDelete, Path: "/admin/v1/membership/plans/:id", Handler: auth(a.adminDeletePlan)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/orders", Handler: auth(a.adminOrders)},
|
||||
{Method: http.MethodPut, Path: "/admin/v1/orders/:id", Handler: auth(a.adminUpdateOrder)},
|
||||
{Method: http.MethodDelete, Path: "/admin/v1/orders/:id", Handler: auth(a.adminDeleteOrder)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/pay", Handler: auth(a.adminMarkOrderPaid)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/close", Handler: auth(a.adminCloseOrder)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/refund", Handler: auth(a.adminRefund)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/messages", Handler: auth(a.adminMessages)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/system/configs", Handler: auth(a.adminConfigs)},
|
||||
{Method: http.MethodPatch, Path: "/admin/v1/system/configs/:key", Handler: auth(a.adminUpdateConfig)},
|
||||
{Method: http.MethodPut, Path: "/admin/v1/system/configs/:key", Handler: auth(a.adminUpdateConfig)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/integrations/:group", Handler: auth(a.adminIntegration)},
|
||||
{Method: http.MethodPut, Path: "/admin/v1/integrations/:group", Handler: auth(a.adminUpdateIntegration)},
|
||||
{Method: http.MethodPost, Path: "/admin/v1/integrations/:group/test", Handler: auth(a.adminTestIntegration)},
|
||||
{Method: http.MethodGet, Path: "/admin/v1/audit-logs", Handler: auth(a.adminAuditLogs)},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) health(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
|
||||
defer cancel()
|
||||
if err := a.db.PingContext(ctx); err != nil {
|
||||
fail(w, http.StatusServiceUnavailable, 50001, "database unavailable")
|
||||
return
|
||||
}
|
||||
reply(w, map[string]any{"status": "ok", "time": time.Now()})
|
||||
}
|
||||
|
||||
func reply(w http.ResponseWriter, data any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(apiResponse{Code: 0, Message: "OK", Data: data})
|
||||
}
|
||||
|
||||
func fail(w http.ResponseWriter, status, code int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(apiResponse{Code: code, Message: message, Data: nil})
|
||||
}
|
||||
|
||||
func decode(r *http.Request, out any) error {
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 2<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(out); err != nil {
|
||||
return fmt.Errorf("invalid request: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pagination(r *http.Request) (int, int, int) {
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
size, _ := strconv.Atoi(r.URL.Query().Get("size"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
return page, size, (page - 1) * size
|
||||
}
|
||||
|
||||
func pathID(r *http.Request) (int64, error) {
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
if id, err := strconv.ParseInt(parts[i], 10, 64); err == nil {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
return 0, errors.New("invalid id")
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func boolEnv(key string, fallback bool) bool {
|
||||
value := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value == "1" || value == "true" || value == "yes" || value == "on"
|
||||
}
|
||||
|
||||
func csvEnv(key, fallback string) []string {
|
||||
value := env(key, fallback)
|
||||
items := make([]string, 0)
|
||||
seen := map[string]bool{}
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
item = strings.TrimRight(strings.TrimSpace(item), "/")
|
||||
if item != "" && !seen[item] {
|
||||
seen[item] = true
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func validateConfig(config Config) error {
|
||||
if config.Port < 1 || config.Port > 65535 {
|
||||
return fmt.Errorf("IM_PORT 无效")
|
||||
}
|
||||
if config.Environment != "production" {
|
||||
return nil
|
||||
}
|
||||
if config.SeedDemo {
|
||||
return fmt.Errorf("生产环境禁止启用 IM_SEED_DEMO")
|
||||
}
|
||||
if len(config.JWTSecret) < 32 || config.JWTSecret == "local-development-secret-change-me" {
|
||||
return fmt.Errorf("生产环境必须配置至少 32 字节的 IM_JWT_SECRET")
|
||||
}
|
||||
if len(config.ConfigEncryptionKey) < 32 {
|
||||
return fmt.Errorf("生产环境必须配置至少 32 字节的 IM_CONFIG_ENCRYPTION_KEY")
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(config.DSN)), "root:") {
|
||||
return fmt.Errorf("生产环境禁止使用 root 数据库账号")
|
||||
}
|
||||
if len(config.AllowedOrigins) == 0 {
|
||||
return fmt.Errorf("生产环境必须配置 IM_ALLOWED_ORIGINS")
|
||||
}
|
||||
for _, origin := range config.AllowedOrigins {
|
||||
if origin == "*" {
|
||||
return fmt.Errorf("生产环境禁止使用通配 CORS 来源")
|
||||
}
|
||||
if !strings.HasPrefix(origin, "https://") {
|
||||
return fmt.Errorf("生产环境来源必须使用 HTTPS: %s", origin)
|
||||
}
|
||||
}
|
||||
if config.BootstrapAdminPassword != "" && !strongAdminPassword(config.BootstrapAdminPassword) {
|
||||
return fmt.Errorf("IM_BOOTSTRAP_ADMIN_PASSWORD 至少 12 位,且必须包含大小写字母、数字和特殊字符")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func strongAdminPassword(value string) bool {
|
||||
if len(value) < 12 {
|
||||
return false
|
||||
}
|
||||
var lower, upper, digit, special bool
|
||||
for _, char := range value {
|
||||
switch {
|
||||
case char >= 'a' && char <= 'z':
|
||||
lower = true
|
||||
case char >= 'A' && char <= 'Z':
|
||||
upper = true
|
||||
case char >= '0' && char <= '9':
|
||||
digit = true
|
||||
default:
|
||||
special = true
|
||||
}
|
||||
}
|
||||
return lower && upper && digit && special
|
||||
}
|
||||
|
||||
func (a *App) originAllowed(origin string) bool {
|
||||
origin = strings.TrimRight(strings.TrimSpace(origin), "/")
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
for _, allowed := range a.config.AllowedOrigins {
|
||||
if origin == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user