├── .gitattributes ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── README_ZH.md ├── build.bat ├── build.sh ├── cmd ├── cmd.go ├── reset.go ├── server.go └── version.go ├── controller ├── account.go ├── config.go ├── hysteria2.go ├── log.go ├── monitor.go └── validator.go ├── dao ├── account.go ├── config.go └── sqlite.go ├── docker-build.sh ├── docker-compose.yml ├── docs ├── FAQ.md ├── FAQ_ZH.md ├── images │ ├── cover.png │ └── head-cover.png └── sql │ └── h_ui_db.sql ├── frontend ├── .editorconfig ├── .env.development ├── .env.production ├── .eslintignore ├── .eslintrc-auto-import.json ├── .eslintrc.cjs ├── .gitignore ├── .prettierignore ├── .prettierrc.cjs ├── .stylelintignore ├── .stylelintrc.cjs ├── README.md ├── embed.go ├── index.html ├── package.json ├── pnpm-lock.yaml ├── public │ └── favicon.ico ├── src │ ├── App.vue │ ├── api │ │ ├── account │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── config │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── hysteria2 │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── log │ │ │ ├── index.ts │ │ │ └── types.ts │ │ └── monitor │ │ │ ├── index.ts │ │ │ └── types.ts │ ├── assets │ │ ├── 401_images │ │ │ └── 401.gif │ │ ├── 404_images │ │ │ ├── 404.png │ │ │ └── 404_cloud.png │ │ ├── icons │ │ │ ├── close.svg │ │ │ ├── close_all.svg │ │ │ ├── close_left.svg │ │ │ ├── close_other.svg │ │ │ ├── close_right.svg │ │ │ ├── download.svg │ │ │ ├── error.svg │ │ │ ├── exit-fullscreen.svg │ │ │ ├── expire-time.svg │ │ │ ├── eye-open.svg │ │ │ ├── eye.svg │ │ │ ├── fullscreen.svg │ │ │ ├── homepage.svg │ │ │ ├── hysteria.svg │ │ │ ├── info-account.svg │ │ │ ├── language.svg │ │ │ ├── log-hysteria.svg │ │ │ ├── log-system.svg │ │ │ ├── password.svg │ │ │ ├── quota.svg │ │ │ ├── refresh.svg │ │ │ ├── report.svg │ │ │ ├── setting.svg │ │ │ ├── share.svg │ │ │ ├── size.svg │ │ │ ├── telegram.svg │ │ │ ├── upload.svg │ │ │ ├── user.svg │ │ │ └── users.svg │ │ └── logo.png │ ├── components │ │ ├── GithubCorner │ │ │ └── index.vue │ │ ├── Hamburger │ │ │ └── index.vue │ │ ├── ImputMultiple │ │ │ └── index.vue │ │ ├── LangSelect │ │ │ └── index.vue │ │ ├── MapAdd │ │ │ └── index.vue │ │ ├── Pagination │ │ │ └── index.vue │ │ ├── RightPanel │ │ │ └── index.vue │ │ ├── SizeSelect │ │ │ └── index.vue │ │ ├── SvgIcon │ │ │ └── index.vue │ │ └── UnitSelect │ │ │ └── index.vue │ ├── directive │ │ ├── index.ts │ │ └── permission │ │ │ └── index.ts │ ├── lang │ │ ├── index.ts │ │ └── package │ │ │ ├── en.ts │ │ │ └── zh-cn.ts │ ├── layout │ │ ├── components │ │ │ ├── AppMain.vue │ │ │ ├── Navbar.vue │ │ │ ├── Settings │ │ │ │ └── index.vue │ │ │ ├── Sidebar │ │ │ │ ├── Link.vue │ │ │ │ ├── Logo.vue │ │ │ │ ├── SidebarItem.vue │ │ │ │ └── index.vue │ │ │ ├── TagsView │ │ │ │ ├── ScrollPane.vue │ │ │ │ └── index.vue │ │ │ └── index.ts │ │ └── index.vue │ ├── main.ts │ ├── permission.ts │ ├── router │ │ └── index.ts │ ├── settings.ts │ ├── store │ │ ├── index.ts │ │ └── modules │ │ │ ├── account.ts │ │ │ ├── app.ts │ │ │ ├── permission.ts │ │ │ ├── settings.ts │ │ │ └── tagsView.ts │ ├── styles │ │ ├── dark.scss │ │ ├── element-plus.scss │ │ ├── index.scss │ │ ├── reset.scss │ │ ├── sidebar.scss │ │ ├── variables.module.scss │ │ └── variables.scss │ ├── types │ │ ├── auto-imports.d.ts │ │ ├── components.d.ts │ │ ├── env.d.ts │ │ └── global.d.ts │ ├── utils │ │ ├── byte.ts │ │ ├── copy.ts │ │ ├── i18n.ts │ │ ├── index.ts │ │ ├── request.ts │ │ ├── scroll-to.ts │ │ └── time.ts │ └── views │ │ ├── account │ │ └── list │ │ │ └── index.vue │ │ ├── config │ │ └── list │ │ │ └── index.vue │ │ ├── error-page │ │ ├── 401.vue │ │ └── 404.vue │ │ ├── hysteria │ │ └── list │ │ │ ├── components │ │ │ └── Outbounds │ │ │ │ └── index.vue │ │ │ └── index.vue │ │ ├── info │ │ └── account │ │ │ └── index.vue │ │ ├── log │ │ ├── hysteria │ │ │ └── index.vue │ │ └── system │ │ │ └── index.vue │ │ ├── login │ │ └── index.vue │ │ ├── monitor │ │ └── system │ │ │ └── index.vue │ │ ├── redirect │ │ └── index.vue │ │ └── telegram │ │ └── list │ │ └── index.vue ├── tsconfig.json ├── tsconfig.node.json ├── types │ └── index.d.ts └── vite.config.ts ├── go.mod ├── go.sum ├── h-ui.service ├── install.sh ├── local ├── en.json └── zh_cn.json ├── main.go ├── middleware ├── admin.go ├── cron.go ├── filter.go ├── jwt.go ├── log.go └── rate_limiter.go ├── model ├── bo │ ├── account.go │ ├── hysteria2.go │ ├── hysteria2_api.go │ └── subscribe.go ├── constant │ ├── client.go │ ├── code.go │ ├── config.go │ ├── error.go │ └── system.go ├── dto │ ├── account.go │ ├── config.go │ ├── dto.go │ ├── hysteria2.go │ ├── log.go │ └── server.go ├── entity │ ├── account.go │ ├── config.go │ └── entity.go └── vo │ ├── account.go │ ├── config.go │ ├── hysteria2.go │ ├── jwt.go │ ├── log.go │ ├── monitor.go │ ├── result.go │ └── vo.go ├── proxy ├── hysteria2.go ├── hysteria2_api.go └── process.go ├── router ├── account.go ├── auth.go ├── config.go ├── hysteria2.go ├── log.go ├── monitor.go └── router.go ├── service ├── account.go ├── config.go ├── cron.go ├── forward.go ├── hysteria2.go ├── hysteria2_api.go ├── jwt.go ├── monitor.go ├── server.go └── telegram.go └── util ├── arr.go ├── encrypt.go ├── encrypt_test.go ├── export.go ├── file.go ├── github.go ├── hysteria2.go ├── linux.go ├── map.go ├── rand.go └── string.go /.gitattributes: -------------------------------------------------------------------------------- 1 | *.ts linguist-language=Go 2 | *.js linguist-language=Go 3 | *.css linguist-language=Go 4 | *.scss linguist-language=Go 5 | *.html linguist-language=Go -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /bin 3 | /build 4 | /data 5 | /export 6 | /logs -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.15 2 | 3 | LABEL maintainer="jonsosnyan " 4 | 5 | WORKDIR /h-ui 6 | 7 | ENV TZ=Asia/Shanghai 8 | ENV GIN_MODE=release 9 | 10 | ARG TARGETOS 11 | ARG TARGETARCH 12 | ARG TARGETVARIANT 13 | 14 | COPY build/h-ui-${TARGETOS}-${TARGETARCH}${TARGETVARIANT} h-ui 15 | 16 | RUN apk update && apk add --no-cache bash tzdata ca-certificates nftables \ 17 | && rm -rf /var/cache/apk/* \ 18 | && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \ 19 | && echo $TZ > /etc/timezone \ 20 | && chmod +x /h-ui/h-ui 21 | 22 | CMD ["./h-ui"] -------------------------------------------------------------------------------- /build.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal enabledelayedexpansion 3 | 4 | REM target platform array 5 | set platforms=windows/amd64 darwin/amd64 linux/386 linux/amd64 linux/arm/6 linux/arm/7 linux/arm64 linux/ppc64le linux/s390x 6 | 7 | REM output directory 8 | set output_dir=build 9 | 10 | REM make sure the output directory exists 11 | if not exist %output_dir% mkdir %output_dir% 12 | 13 | REM compile for each target platform 14 | for %%p in (%platforms%) do ( 15 | set "platform=%%p" 16 | 17 | for /F "tokens=1,2,3 delims=/" %%a in ("!platform!") do ( 18 | set "GOOS=%%a" 19 | set "GOARCH=%%b" 20 | set "GOARM=%%c" 21 | 22 | set "output_name=h-ui-!GOOS!-!GOARCH!" 23 | if "!GOARCH!" == "arm" ( 24 | set "output_name=!output_name!v!GOARM!" 25 | ) 26 | 27 | if "!GOOS!" == "windows" ( 28 | set "output_name=!output_name!.exe" 29 | ) 30 | 31 | echo Building for !GOOS!/!GOARCH! !GOARM!... 32 | set CGO_ENABLED=0 33 | set GOOS=!GOOS! 34 | set GOARCH=!GOARCH! 35 | if defined GOARM ( 36 | set GOARM=!GOARM! 37 | ) 38 | 39 | go build -o %output_dir%/!output_name! -trimpath -ldflags "-s -w" 40 | 41 | if !errorlevel! == 0 ( 42 | echo Build succeeded for !GOOS!/!GOARCH! !GOARM! 43 | ) else ( 44 | echo Error occurred during building for !GOOS!/!GOARCH! !GOARM! 45 | echo CGO_ENABLED=!CGO_ENABLED! GOOS=!GOOS! GOARCH=!GOARCH! GOARM=!GOARM! 46 | exit /b 1 47 | ) 48 | ) 49 | ) 50 | 51 | echo All builds completed successfully! 52 | endlocal 53 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin:~/bin 3 | export PATH 4 | 5 | # target platform array 6 | platforms=( 7 | "windows/amd64" 8 | "darwin/amd64" 9 | "linux/386" 10 | "linux/amd64" 11 | "linux/arm/6" 12 | "linux/arm/7" 13 | "linux/arm64" 14 | "linux/ppc64le" 15 | "linux/s390x" 16 | ) 17 | 18 | # output directory 19 | output_dir="build" 20 | 21 | # make sure the output directory exists 22 | mkdir -p "$output_dir" 23 | 24 | # compile for each target platform 25 | for platform in "${platforms[@]}"; do 26 | IFS='/' read -r -a platform_split <<<"$platform" 27 | GOOS=${platform_split[0]} 28 | GOARCH=${platform_split[1]} 29 | GOARM=${platform_split[2]} 30 | 31 | output_name="h-ui-${GOOS}-${GOARCH}" 32 | if [ "$GOARCH" == "arm" ]; then 33 | output_name+="v${GOARM}" 34 | fi 35 | 36 | # add the appropriate extension 37 | if [ "$GOOS" == "windows" ]; then 38 | output_name+=".exe" 39 | fi 40 | 41 | echo "Building for $GOOS/$GOARCH ${GOARM:-}" 42 | build_env="CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH" 43 | [ "$GOARCH" == "arm" ] && build_env+=" GOARM=$GOARM" 44 | 45 | env $build_env go build -o "$output_dir/$output_name" -trimpath -ldflags "-s -w" 46 | if [ $? -ne 0 ]; then 47 | echo "Error occurred during building for $GOOS/$GOARCH ${GOARM:-}" 48 | exit 1 49 | fi 50 | done 51 | 52 | echo "All builds completed successfully!" 53 | -------------------------------------------------------------------------------- /cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/spf13/cobra" 6 | "h-ui/model/constant" 7 | "h-ui/util" 8 | "os" 9 | ) 10 | 11 | var rootCmd = &cobra.Command{ 12 | Use: "h-ui", 13 | Short: "just the panel for Hysteria2", 14 | Long: "just the panel for Hysteria2.", 15 | Run: run, 16 | } 17 | 18 | var ( 19 | version bool 20 | port string 21 | ) 22 | 23 | func init() { 24 | rootCmd.Flags().BoolVarP(&version, "version", "v", false, "Show version") 25 | rootCmd.Flags().StringVarP(&port, "port", "p", "", "The port of the web server") 26 | } 27 | 28 | func run(cmd *cobra.Command, args []string) { 29 | if version { 30 | fmt.Println("h-ui version", constant.Version) 31 | return 32 | } 33 | 34 | if err := util.VerifyPort(port); err != nil { 35 | fmt.Println(err.Error()) 36 | os.Exit(1) 37 | } 38 | for { 39 | if err := runServer(port); err != nil { 40 | fmt.Println(err.Error()) 41 | os.Exit(1) 42 | } 43 | } 44 | } 45 | 46 | func Execute() { 47 | if err := rootCmd.Execute(); err != nil { 48 | os.Exit(1) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /cmd/reset.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/spf13/cobra" 6 | "h-ui/dao" 7 | "h-ui/util" 8 | "os" 9 | ) 10 | 11 | var resetCmd = &cobra.Command{ 12 | Use: "reset", 13 | Short: "Reset username and password", 14 | Long: "Reset username and password.", 15 | Run: runReset, 16 | } 17 | 18 | func init() { 19 | rootCmd.AddCommand(resetCmd) 20 | } 21 | 22 | func runReset(cmd *cobra.Command, args []string) { 23 | username, err := util.RandomString(6) 24 | if err != nil { 25 | fmt.Println(err.Error()) 26 | os.Exit(1) 27 | } 28 | password, err := util.RandomString(6) 29 | if err != nil { 30 | fmt.Println(err.Error()) 31 | os.Exit(1) 32 | } 33 | if err = dao.InitSqliteDB(); err != nil { 34 | fmt.Println(err.Error()) 35 | os.Exit(1) 36 | } 37 | if err = dao.UpdateAccount([]int64{1}, map[string]interface{}{ 38 | "username": username, 39 | "pass": util.SHA224String(password), 40 | "con_pass": fmt.Sprintf("%s.%s", username, password)}); err != nil { 41 | fmt.Println(err.Error()) 42 | os.Exit(1) 43 | } 44 | if err = dao.CloseSqliteDB(); err != nil { 45 | fmt.Println(err.Error()) 46 | os.Exit(1) 47 | } 48 | fmt.Println(fmt.Sprintf("h-ui Login Username: %s", username)) 49 | fmt.Println(fmt.Sprintf("h-ui Login Password: %s", password)) 50 | fmt.Println(fmt.Sprintf("h-ui Connection Password: %s", fmt.Sprintf("%s.%s", username, password))) 51 | } 52 | -------------------------------------------------------------------------------- /cmd/server.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/gin-gonic/gin" 7 | "github.com/sirupsen/logrus" 8 | "h-ui/dao" 9 | "h-ui/middleware" 10 | "h-ui/model/constant" 11 | "h-ui/router" 12 | "h-ui/service" 13 | "h-ui/util" 14 | "net/http" 15 | "os" 16 | ) 17 | 18 | func runServer(port string) error { 19 | defer releaseResource() 20 | 21 | middleware.InitLog() 22 | service.InitForward() 23 | if err := initFile(); err != nil { 24 | return err 25 | } 26 | if err := dao.InitSql(port); err != nil { 27 | return err 28 | } 29 | if err := middleware.InitCron(); err != nil { 30 | return err 31 | } 32 | if err := service.InitHysteria2(); err != nil { 33 | return err 34 | } 35 | if err := service.InitTableAndChain(); err != nil { 36 | logrus.Errorf(err.Error()) 37 | } 38 | if err := service.InitPortHopping(); err != nil { 39 | logrus.Errorf(err.Error()) 40 | } 41 | if err := service.InitTelegramBot(); err != nil { 42 | logrus.Errorf(err.Error()) 43 | } 44 | 45 | config, err := dao.GetConfig("key = ?", constant.HUIWebContext) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | r := gin.Default() 51 | router.Router(r, config.Value) 52 | 53 | serverPort, crtPath, keyPath, err := service.GetServerPortAndCert() 54 | if err != nil { 55 | return err 56 | } 57 | 58 | service.InitServer(fmt.Sprintf(":%d", serverPort), r) 59 | if err := service.StartServer(crtPath, keyPath); err != nil && err != http.ErrServerClosed { 60 | logrus.Errorf("start server err: %v", err) 61 | return errors.New("start server err") 62 | } 63 | return nil 64 | } 65 | 66 | func releaseResource() { 67 | if err := dao.CloseSqliteDB(); err != nil { 68 | logrus.Errorf(err.Error()) 69 | } 70 | if err := service.ReleaseHysteria2(); err != nil { 71 | logrus.Errorf(err.Error()) 72 | } 73 | if err := service.RemoveByComment(); err != nil { 74 | logrus.Errorf(err.Error()) 75 | } 76 | } 77 | 78 | func initFile() error { 79 | var dirs = []string{constant.LogDir, constant.SqliteDBDir, constant.BinDir, constant.ExportPathDir} 80 | for _, item := range dirs { 81 | if !util.Exists(item) { 82 | if err := os.Mkdir(item, os.ModePerm); err != nil { 83 | logrus.Errorf("%s create err: %v", item, err) 84 | return errors.New(fmt.Sprintf("%s create err", item)) 85 | } 86 | } 87 | } 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/spf13/cobra" 6 | "h-ui/model/constant" 7 | ) 8 | 9 | var versionCmd = &cobra.Command{ 10 | Use: "version", 11 | Short: "Show version", 12 | Long: "Show version.", 13 | Run: runVersion, 14 | } 15 | 16 | func init() { 17 | rootCmd.AddCommand(versionCmd) 18 | } 19 | 20 | func runVersion(cmd *cobra.Command, args []string) { 21 | fmt.Println("h-ui version", constant.Version) 22 | } 23 | -------------------------------------------------------------------------------- /controller/log.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/gin-gonic/gin" 7 | "h-ui/model/constant" 8 | "h-ui/model/dto" 9 | "h-ui/model/vo" 10 | "h-ui/util" 11 | "time" 12 | ) 13 | 14 | func LogSystem(c *gin.Context) { 15 | logSystemDto, err := validateField(c, dto.LogDto{}) 16 | if err != nil { 17 | return 18 | } 19 | exists := util.Exists(constant.SystemLogPath) 20 | logSystemVos := make([]vo.LogSystemVo, 0) 21 | if !exists { 22 | vo.Success(logSystemVos, c) 23 | return 24 | } 25 | numLine := 0 26 | if logSystemDto.NumLine != nil || *logSystemDto.NumLine > 0 { 27 | numLine = *logSystemDto.NumLine 28 | } 29 | logLines, total, err := util.ReadLinesFromBottom(constant.SystemLogPath, numLine) 30 | if err != nil { 31 | vo.Fail("Unable to read log file", c) 32 | return 33 | } 34 | 35 | for _, line := range logLines { 36 | if line == "" { 37 | continue 38 | } 39 | logSystemVo := vo.LogSystemVo{} 40 | err := json.Unmarshal([]byte(line), &logSystemVo) 41 | if err != nil { 42 | vo.Fail("Unable to unmarshal log data", c) 43 | continue 44 | } 45 | logSystemVos = append(logSystemVos, logSystemVo) 46 | } 47 | vo.Success(vo.LogSystemPage[vo.LogSystemVo]{ 48 | LogSystemVos: logSystemVos, 49 | Total: int64(total), 50 | }, c) 51 | } 52 | 53 | func LogHysteria2(c *gin.Context) { 54 | logSystemDto, err := validateField(c, dto.LogDto{}) 55 | if err != nil { 56 | return 57 | } 58 | exists := util.Exists(constant.Hysteria2LogPath) 59 | logHysteria2Vos := make([]vo.LogHysteria2Vo, 0) 60 | if !exists { 61 | vo.Success(logHysteria2Vos, c) 62 | return 63 | } 64 | numLine := 0 65 | if logSystemDto.NumLine != nil || *logSystemDto.NumLine > 0 { 66 | numLine = *logSystemDto.NumLine 67 | } 68 | logLines, total, err := util.ReadLinesFromBottom(constant.Hysteria2LogPath, numLine) 69 | if err != nil { 70 | vo.Fail("Unable to read log file", c) 71 | return 72 | } 73 | 74 | for _, line := range logLines { 75 | if line == "" { 76 | continue 77 | } 78 | logHysteria2Vo := vo.LogHysteria2Vo{} 79 | err := json.Unmarshal([]byte(line), &logHysteria2Vo) 80 | if err != nil { 81 | vo.Fail("Unable to unmarshal log data", c) 82 | continue 83 | } 84 | logHysteria2Vos = append(logHysteria2Vos, logHysteria2Vo) 85 | } 86 | vo.Success(vo.LogSystemPage[vo.LogHysteria2Vo]{ 87 | LogSystemVos: logHysteria2Vos, 88 | Total: int64(total), 89 | }, c) 90 | } 91 | 92 | func ExportLog(c *gin.Context) { 93 | logExportDto, err := validateField(c, dto.LogExportDto{}) 94 | if err != nil { 95 | return 96 | } 97 | 98 | var fileName string 99 | var filePath string 100 | if *logExportDto.Option == 0 { 101 | fileName = fmt.Sprintf("h-ui-%s.log", time.Now().Format("20060102150405")) 102 | filePath = constant.SystemLogPath 103 | } else if *logExportDto.Option == 1 { 104 | fileName = fmt.Sprintf("hysteria2-%s.log", time.Now().Format("20060102150405")) 105 | filePath = constant.Hysteria2LogPath 106 | } 107 | 108 | if !util.Exists(filePath) { 109 | vo.Fail("log file not exist", c) 110 | return 111 | } 112 | c.Header("Content-Type", "application/octet-stream") 113 | c.Header("Content-Transfer-Encoding", "binary") 114 | c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) 115 | c.File(filePath) 116 | } 117 | -------------------------------------------------------------------------------- /controller/monitor.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "h-ui/model/vo" 6 | "h-ui/service" 7 | ) 8 | 9 | func MonitorSystem(c *gin.Context) { 10 | systemMonitorVo, err := service.MonitorSystem() 11 | if err != nil { 12 | vo.Fail(err.Error(), c) 13 | return 14 | } 15 | vo.Success(systemMonitorVo, c) 16 | } 17 | 18 | func MonitorHysteria2(c *gin.Context) { 19 | hysteria2MonitorVo, err := service.MonitorHysteria2() 20 | if err != nil { 21 | vo.Fail(err.Error(), c) 22 | return 23 | } 24 | vo.Success(hysteria2MonitorVo, c) 25 | return 26 | } 27 | -------------------------------------------------------------------------------- /controller/validator.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gin-gonic/gin" 6 | "github.com/go-playground/validator/v10" 7 | "h-ui/model/constant" 8 | "h-ui/model/vo" 9 | "net/http" 10 | "regexp" 11 | ) 12 | 13 | var validate *validator.Validate 14 | 15 | func init() { 16 | validate = validator.New() 17 | _ = validate.RegisterValidation("validateStr", validateStr) 18 | } 19 | 20 | func validateStr(f validator.FieldLevel) bool { 21 | field := f.Field().String() 22 | // 字符串必须6-32位是字母或者数字或部分特殊字符的组合 23 | reg := "^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$" 24 | compile := regexp.MustCompile(reg) 25 | return field == "" || compile.MatchString(field) 26 | } 27 | 28 | func validateField[T interface{}](c *gin.Context, field T) (T, error) { 29 | if c.Request.Method == http.MethodGet { 30 | _ = c.ShouldBindQuery(&field) 31 | } else if c.Request.Method == http.MethodPost || 32 | c.Request.Method == http.MethodPut || 33 | c.Request.Method == http.MethodDelete { 34 | _ = c.ShouldBindJSON(&field) 35 | } 36 | if err := validate.Struct(&field); err != nil { 37 | vo.Fail(constant.InvalidError, c) 38 | return field, fmt.Errorf(constant.InvalidError) 39 | } 40 | return field, nil 41 | } 42 | -------------------------------------------------------------------------------- /dao/config.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import ( 4 | "errors" 5 | "github.com/sirupsen/logrus" 6 | "gorm.io/gorm" 7 | "gorm.io/gorm/clause" 8 | "h-ui/model/constant" 9 | "h-ui/model/entity" 10 | "time" 11 | ) 12 | 13 | func SaveConfig(config entity.Config) (int64, error) { 14 | if tx := sqliteDB.Save(&config); tx.Error != nil { 15 | logrus.Errorf("%v", tx.Error) 16 | return 0, errors.New(constant.SysError) 17 | } 18 | return *config.Id, nil 19 | } 20 | 21 | func UpdateConfig(keys []string, updates map[string]interface{}) error { 22 | if len(updates) > 0 { 23 | updates["update_time"] = time.Now().Format("2006-01-02 15:04:05") 24 | if tx := sqliteDB.Model(&entity.Config{}). 25 | Where("key in ?", keys). 26 | Updates(updates); tx.Error != nil { 27 | logrus.Errorf("%v", tx.Error) 28 | return errors.New(constant.SysError) 29 | } 30 | } 31 | return nil 32 | } 33 | 34 | func GetConfig(query interface{}, args ...interface{}) (entity.Config, error) { 35 | var config entity.Config 36 | if tx := sqliteDB.Model(&entity.Config{}). 37 | Where(query, args...).First(&config); tx.Error != nil { 38 | if tx.Error == gorm.ErrRecordNotFound { 39 | return config, errors.New(constant.ConfigNotExist) 40 | } 41 | logrus.Errorf("%v", tx.Error) 42 | return config, errors.New(constant.SysError) 43 | } 44 | return config, nil 45 | } 46 | 47 | func ListConfig(query interface{}, args ...interface{}) ([]entity.Config, error) { 48 | var configs []entity.Config 49 | if tx := sqliteDB.Model(&entity.Config{}). 50 | Where(query, args...).Order("create_time desc").Find(&configs); tx.Error != nil { 51 | logrus.Errorf("%v", tx.Error) 52 | return configs, errors.New(constant.SysError) 53 | } 54 | return configs, nil 55 | } 56 | 57 | func UpsertConfig(configs []entity.Config) error { 58 | if tx := sqliteDB.Model(&entity.Config{}).Clauses(clause.OnConflict{ 59 | Columns: []clause.Column{{Name: "key"}}, 60 | DoUpdates: clause.AssignmentColumns([]string{"value", "remark", "create_time", "update_time"}), 61 | }).Create(configs); tx.Error != nil { 62 | logrus.Errorf("%v", tx.Error) 63 | return errors.New(constant.SysError) 64 | } 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /docker-build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin:~/bin 3 | export PATH 4 | 5 | init_var() { 6 | ECHO_TYPE="echo -e" 7 | 8 | version=0.0.20 9 | 10 | arch_arr="linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x" 11 | } 12 | 13 | echo_content() { 14 | case $1 in 15 | "red") 16 | ${ECHO_TYPE} "\033[31m$2\033[0m" 17 | ;; 18 | "green") 19 | ${ECHO_TYPE} "\033[32m$2\033[0m" 20 | ;; 21 | "yellow") 22 | ${ECHO_TYPE} "\033[33m$2\033[0m" 23 | ;; 24 | "blue") 25 | ${ECHO_TYPE} "\033[34m$2\033[0m" 26 | ;; 27 | "purple") 28 | ${ECHO_TYPE} "\033[35m$2\033[0m" 29 | ;; 30 | "skyBlue") 31 | ${ECHO_TYPE} "\033[36m$2\033[0m" 32 | ;; 33 | "white") 34 | ${ECHO_TYPE} "\033[37m$2\033[0m" 35 | ;; 36 | esac 37 | } 38 | 39 | main() { 40 | echo_content skyBlue "start build h-ui CPU:${arch_arr}" 41 | 42 | if [[ ${version} != "latest" ]]; then 43 | docker buildx build -t jonssonyan/h-ui:${version} --platform ${arch_arr} --push . 44 | if [[ "$?" == "0" ]]; then 45 | echo_content green "h-ui-linux Version:${version} CPU:${arch_arr} build success" 46 | else 47 | echo_content red "h-ui-linux Version:${version} CPU:${arch_arr} build failed" 48 | fi 49 | fi 50 | 51 | docker buildx build -t jonssonyan/h-ui:latest --platform ${arch_arr} --push . 52 | if [[ "$?" == "0" ]]; then 53 | echo_content green "h-ui Version:latest CPU:${arch_arr} build success" 54 | else 55 | echo_content red "h-ui-linux Version:latest CPU:${arch_arr} build failed" 56 | fi 57 | 58 | echo_content skyBlue "h-ui CPU:${arch_arr} build finished" 59 | } 60 | 61 | init_var 62 | main 63 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | h-ui: 5 | image: jonssonyan/h-ui 6 | cap_add: 7 | - NET_ADMIN 8 | container_name: h-ui 9 | restart: always 10 | network_mode: host 11 | volumes: 12 | - /h-ui/bin:/h-ui/bin 13 | - /h-ui/data:/h-ui/data 14 | - /h-ui/export:/h-ui/export 15 | - /h-ui/logs:/h-ui/logs 16 | environment: 17 | TZ: Asia/Shanghai -------------------------------------------------------------------------------- /docs/FAQ.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## Command Line 4 | 5 | Give executable permissions 6 | 7 | ```bash 8 | chmod +x ./h-ui 9 | ``` 10 | 11 | - Check the system version 12 | 13 | ```bash 14 | ./h-ui -v 15 | ``` 16 | 17 | - Start with a custom web port 18 | 19 | ```bash 20 | ./h-ui -p [port] 21 | ``` 22 | 23 | - Reset sysadmin username and password 24 | 25 | ```bash 26 | ./h-ui reset 27 | ``` 28 | 29 | ## Meaning of folders in project 30 | 31 | - bin: Hysteria2 executable and configuration files 32 | - data: database files 33 | - export: all exported files 34 | - logs: system logs and Hysteria2 operation logs 35 | 36 | # Deployment Issues 37 | 38 | ## Unable to access the panel 39 | 40 | - Check if h-ui is running normally 41 | - Check if the firewall allows ports 42 | - Check if the protocol is correct, http:// or https:// 43 | 44 | ## h-ui startup failed 45 | 46 | View h-ui logs through the log menu to eliminate the cause of the error 47 | 48 | ## Hysteria2 startup failed 49 | 50 | - Hysteria2 configuration error, view Hysteria2 logs through the log menu to eliminate the cause of the error 51 | - It takes some time to apply for a certificate using ACME for the first time. Please wait patiently for Hysteria2 to start and refresh the page 52 | 53 | # Usage Issues 54 | 55 | ## How to manage certificates? 56 | 57 | ACME method (recommended) The system automatically manages. The self-owned certificate method requires manual maintenance and manual replacement after expiration 58 | 59 | ## How to set the Hysteria2 certificate path for your own certificate? 60 | 61 | Upload the certificate file to the server. When configuring Hysteria2, fill in the absolute path of the certificate. When deploying through Docker, you need to upload the certificate file to the volume mapping folder. Recommended: /h-ui/bin 62 | 63 | ## Why only Hysteria2 version >= v2.4.4 is supported? 64 | 65 | Hysteria2 with lower versions does not support the latest API 66 | 67 | ## Log export failed 68 | 69 | No log file -------------------------------------------------------------------------------- /docs/FAQ_ZH.md: -------------------------------------------------------------------------------- 1 | # 常见问题 2 | 3 | ## 命令行 4 | 5 | 赋予可执行权限 6 | 7 | ```bash 8 | chmod +x ./h-ui 9 | ``` 10 | 11 | - 查看系统版本 12 | 13 | ```bash 14 | ./h-ui -v 15 | ``` 16 | 17 | - 自定义 Web 端口启动 18 | 19 | ```bash 20 | ./h-ui -p [端口] 21 | ``` 22 | 23 | 24 | - 重设系统管理员用户名和密码 25 | 26 | ```bash 27 | ./h-ui reset 28 | ``` 29 | 30 | ## 项目工程中文件夹的含义 31 | 32 | - bin: Hysteria2 的可执行文件和配置文件 33 | - data: 数据库文件 34 | - export: 所有导出的文件 35 | - logs: 系统日志和 Hysteria2 运行日志 36 | 37 | # 部署问题 38 | 39 | ## 无法访问面板 40 | 41 | - 检查 h-ui 运行是否正常 42 | - 检查防火墙是否放行端口 43 | - 检查协议是否正确,http:// 或者 https:// 44 | 45 | ## h-ui 启动失败 46 | 47 | 通过日志菜单查看 h-ui 日志排除错误原因 48 | 49 | ## Hysteria2 启动失败 50 | 51 | - Hysteria2 配置错误,通过日志菜单查看 Hysteria2 日志排除错误原因 52 | - 第一次使用 ACME 申请证书需要一段时间,请耐心等待 Hysteria2 启动并刷新页面 53 | 54 | # 使用问题 55 | 56 | ## 证书如何管理? 57 | 58 | ACME 方式(推荐)系统自动管理。自有证书方式要自己手动维护,到期之后手动替换 59 | 60 | ## 自有证书,如何设置 Hysteria2 证书路径? 61 | 62 | 将证书文件上传至服务器,在配置 Hysteria2 时,填入证书的绝对路径,通过 Docker 部署,需要将证书文件上传卷映射文件夹,推荐:/h-ui/bin 63 | 64 | ## 为什么只支持 Hysteria2 版本 >= v2.4.4? 65 | 66 | 低版本 Hysteria2 不支持最新 API 67 | 68 | ## 日志导出失败 69 | 70 | 没有日志文件 -------------------------------------------------------------------------------- /docs/images/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonssonyan/h-ui/cd2935adddd043eebc4af889aa996273c166a63b/docs/images/cover.png -------------------------------------------------------------------------------- /docs/images/head-cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonssonyan/h-ui/cd2935adddd043eebc4af889aa996273c166a63b/docs/images/head-cover.png -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | # 表示所有文件适用 5 | [*] 6 | charset = utf-8 # 设置文件字符集为 utf-8 7 | end_of_line = lf # 控制换行类型(lf | cr | crlf) 8 | indent_style = tab # 缩进风格(tab | space) 9 | insert_final_newline = true # 始终在文件末尾插入一个新行 10 | 11 | # 表示仅 md 文件适用以下规则 12 | [*.md] 13 | max_line_length = off # 关闭最大行长度限制 14 | trim_trailing_whitespace = false # 关闭末尾空格修剪 15 | -------------------------------------------------------------------------------- /frontend/.env.development: -------------------------------------------------------------------------------- 1 | ## 开发环境 2 | 3 | # 变量必须以 VITE_ 为前缀才能暴露给外部读取 4 | NODE_ENV='development' 5 | 6 | VITE_APP_TITLE = 'h-ui' 7 | VITE_APP_PORT = 3000 8 | VITE_APP_BASE_API = '/hui' 9 | -------------------------------------------------------------------------------- /frontend/.env.production: -------------------------------------------------------------------------------- 1 | ## 生产环境 2 | 3 | VITE_APP_TITLE = 'h-ui' 4 | VITE_APP_PORT = 80 5 | VITE_APP_BASE_API = '/hui' 6 | -------------------------------------------------------------------------------- /frontend/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | public 4 | .vscode 5 | .idea 6 | *.sh 7 | *.md 8 | 9 | src/assets 10 | 11 | .eslintrc.cjs 12 | .prettierrc.cjs 13 | .stylelintrc.cjs 14 | -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | parser: "vue-eslint-parser", // https://eslint.vuejs.org/user-guide/#bundle-configurations 8 | extends: [ 9 | "eslint:recommended", 10 | "plugin:vue/vue3-essential", 11 | "plugin:@typescript-eslint/recommended", 12 | "./.eslintrc-auto-import.json", 13 | ], 14 | parserOptions: { 15 | ecmaVersion: "latest", 16 | sourceType: "module", 17 | parser: "@typescript-eslint/parser", 18 | }, 19 | plugins: ["vue", "@typescript-eslint"], 20 | rules: { 21 | "vue/multi-word-component-names": "off", // 关闭组件名必须多字: https://eslint.vuejs.org/rules/multi-word-component-names.html 22 | "@typescript-eslint/no-empty-function": "off", // 关闭空方法检查 23 | "@typescript-eslint/no-explicit-any": "off", // 关闭any类型的警告 24 | "vue/no-v-model-argument": "off", 25 | "@typescript-eslint/no-non-null-assertion": "off", 26 | }, // https://eslint.org/docs/latest/use/configure/language-options#specifying-globals 27 | globals: { 28 | DialogType: "readonly", 29 | OptionType: "readonly", 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | 7 | # Editor directories and files 8 | .idea 9 | .vscode 10 | *.suo 11 | *.ntvs* 12 | *.njsproj 13 | *.sln 14 | *.local 15 | -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | public 4 | .vscode 5 | .idea 6 | *.sh 7 | *.md 8 | 9 | src/assets 10 | -------------------------------------------------------------------------------- /frontend/.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // (x)=>{},单个参数箭头函数是否显示小括号。(always:始终显示;avoid:省略括号。默认:always) 3 | arrowParens: "always", 4 | // 开始标签的右尖括号是否跟随在最后一行属性末尾,默认false 5 | bracketSameLine: false, 6 | // 对象字面量的括号之间打印空格 (true - Example: { foo: bar } ; false - Example: {foo:bar}) 7 | bracketSpacing: true, 8 | // 是否格式化一些文件中被嵌入的代码片段的风格(auto|off;默认auto) 9 | embeddedLanguageFormatting: "auto", 10 | // 指定 HTML 文件的空格敏感度 (css|strict|ignore;默认css) 11 | htmlWhitespaceSensitivity: "css", 12 | // 当文件已经被 Prettier 格式化之后,是否会在文件顶部插入一个特殊的 @format 标记,默认false 13 | insertPragma: false, 14 | // 在 JSX 中使用单引号替代双引号,默认false 15 | jsxSingleQuote: false, 16 | // 每行最多字符数量,超出换行(默认80) 17 | printWidth: 80, 18 | // 超出打印宽度 (always | never | preserve ) 19 | proseWrap: "preserve", 20 | // 对象属性是否使用引号(as-needed | consistent | preserve;默认as-needed:对象的属性需要加引号才添加;) 21 | quoteProps: "as-needed", 22 | // 是否只格式化在文件顶部包含特定注释(@prettier| @format)的文件,默认false 23 | requirePragma: false, 24 | // 结尾添加分号 25 | semi: true, 26 | // 使用单引号 (true:单引号;false:双引号) 27 | singleQuote: false, 28 | // 缩进空格数,默认2个空格 29 | tabWidth: 2, 30 | // 元素末尾是否加逗号,默认es5: ES5中的 objects, arrays 等会添加逗号,TypeScript 中的 type 后不加逗号 31 | trailingComma: "es5", 32 | // 指定缩进方式,空格或tab,默认false,即使用空格 33 | useTabs: false, 34 | // vue 文件中是否缩进 -------------------------------------------------------------------------------- /frontend/src/assets/icons/quota.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/refresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/report.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/setting.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/share.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/size.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/telegram.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/upload.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/user.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/icons/users.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonssonyan/h-ui/cd2935adddd043eebc4af889aa996273c166a63b/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /frontend/src/components/GithubCorner/index.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 63 | -------------------------------------------------------------------------------- /frontend/src/components/Hamburger/index.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 35 | 36 | 47 | -------------------------------------------------------------------------------- /frontend/src/components/ImputMultiple/index.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 72 | 73 | 78 | -------------------------------------------------------------------------------- /frontend/src/components/LangSelect/index.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 40 | -------------------------------------------------------------------------------- /frontend/src/components/Pagination/index.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 79 | 80 | 89 | -------------------------------------------------------------------------------- /frontend/src/components/RightPanel/index.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 75 | 76 | 137 | -------------------------------------------------------------------------------- /frontend/src/components/SizeSelect/index.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 37 | -------------------------------------------------------------------------------- /frontend/src/components/SvgIcon/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 32 | 33 | 44 | -------------------------------------------------------------------------------- /frontend/src/components/UnitSelect/index.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /frontend/src/directive/index.ts: -------------------------------------------------------------------------------- 1 | import type { App } from "vue"; 2 | 3 | import { hasRole } from "./permission"; 4 | 5 | // 全局注册 directive 6 | export function setupDirective(app: App) { 7 | // 使 v-hasRole 在所有组件中都可用 8 | app.directive("hasRole", hasRole); 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/directive/permission/index.ts: -------------------------------------------------------------------------------- 1 | import { useAccountStoreHook } from "@/store/modules/account"; 2 | import { Directive, DirectiveBinding } from "vue"; 3 | 4 | /** 5 | * 角色权限 6 | */ 7 | export const hasRole: Directive = { 8 | mounted(el: HTMLElement, binding: DirectiveBinding) { 9 | const { value } = binding; 10 | 11 | if (value) { 12 | const requiredRoles = value; // DOM绑定需要的角色编码 13 | const { roles } = useAccountStoreHook(); 14 | const hasRole = roles.some((perm) => { 15 | return requiredRoles.includes(perm); 16 | }); 17 | 18 | if (!hasRole) { 19 | el.parentNode && el.parentNode.removeChild(el); 20 | } 21 | } else { 22 | throw new Error("need roles! Like v-has-role=\"['admin', 'user']\""); 23 | } 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /frontend/src/lang/index.ts: -------------------------------------------------------------------------------- 1 | import { createI18n } from "vue-i18n"; 2 | import { useAppStore } from "@/store/modules/app"; 3 | 4 | const appStore = useAppStore(); 5 | // 本地语言包 6 | import enLocale from "./package/en"; 7 | import zhCnLocale from "./package/zh-cn"; 8 | 9 | const messages = { 10 | "zh-cn": { 11 | ...zhCnLocale, 12 | }, 13 | en: { 14 | ...enLocale, 15 | }, 16 | }; 17 | 18 | const i18n = createI18n({ 19 | legacy: false, 20 | locale: appStore.language, 21 | messages: messages, 22 | globalInjection: true, 23 | }); 24 | 25 | export default i18n; 26 | -------------------------------------------------------------------------------- /frontend/src/layout/components/AppMain.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | 19 | 46 | -------------------------------------------------------------------------------- /frontend/src/layout/components/Navbar.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 97 | 98 | 141 | -------------------------------------------------------------------------------- /frontend/src/layout/components/Sidebar/Link.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 38 | -------------------------------------------------------------------------------- /frontend/src/layout/components/Sidebar/Logo.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 41 | 42 | 54 | -------------------------------------------------------------------------------- /frontend/src/layout/components/Sidebar/SidebarItem.vue: -------------------------------------------------------------------------------- 1 | 79 | 122 | -------------------------------------------------------------------------------- /frontend/src/layout/components/Sidebar/index.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 46 | -------------------------------------------------------------------------------- /frontend/src/layout/components/TagsView/ScrollPane.vue: -------------------------------------------------------------------------------- 1 | 94 | 95 | 105 | 106 | 122 | -------------------------------------------------------------------------------- /frontend/src/layout/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Navbar } from "./Navbar.vue"; 2 | export { default as AppMain } from "./AppMain.vue"; 3 | export { default as Settings } from "./Settings/index.vue"; 4 | export { default as TagsView } from "./TagsView/index.vue"; 5 | -------------------------------------------------------------------------------- /frontend/src/layout/index.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 84 | 85 | 130 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | import router from "@/router"; 4 | import { setupStore } from "@/store"; 5 | import { setupDirective } from "@/directive"; 6 | 7 | import "@/permission"; 8 | 9 | // 本地SVG图标 10 | import "virtual:svg-icons-register"; 11 | 12 | // 国际化 13 | import i18n from "@/lang/index"; 14 | 15 | // 样式 16 | import "element-plus/theme-chalk/dark/css-vars.css"; 17 | import "@/styles/index.scss"; 18 | import "uno.css"; 19 | 20 | const app = createApp(App); 21 | // 全局注册 自定义指令(directive) 22 | setupDirective(app); 23 | // 全局注册 状态管理(store) 24 | setupStore(app); 25 | 26 | app.use(router).use(i18n).mount("#app"); 27 | -------------------------------------------------------------------------------- /frontend/src/permission.ts: -------------------------------------------------------------------------------- 1 | import router from "@/router"; 2 | import { useAccountStoreHook } from "@/store/modules/account"; 3 | import { usePermissionStoreHook } from "@/store/modules/permission"; 4 | 5 | import NProgress from "nprogress"; 6 | import "nprogress/nprogress.css"; 7 | 8 | NProgress.configure({ showSpinner: false }); // 进度条 9 | 10 | const permissionStore = usePermissionStoreHook(); 11 | 12 | // 白名单路由 13 | const whiteList = ["/login", "/register"]; 14 | 15 | router.beforeEach(async (to, from, next) => { 16 | NProgress.start(); 17 | const hasToken = localStorage.getItem("accessToken"); 18 | if (hasToken) { 19 | if (to.path === "/login") { 20 | // 如果已登录,跳转首页 21 | next({ path: "/" }); 22 | NProgress.done(); 23 | } else { 24 | const AccountStore = useAccountStoreHook(); 25 | const hasRoles = AccountStore.roles && AccountStore.roles.length > 0; 26 | if (hasRoles) { 27 | // 未匹配到任何路由,跳转404 28 | if (to.matched.length === 0) { 29 | from.name ? next({ name: from.name }) : next("/404"); 30 | } else { 31 | next(); 32 | } 33 | } else { 34 | try { 35 | const { roles } = await AccountStore.getAccountInfo(); 36 | const accessRoutes = permissionStore.generateRoutes(roles); 37 | accessRoutes.forEach((route) => { 38 | router.addRoute(route); 39 | }); 40 | next({ ...to, replace: true }); 41 | } catch (error) { 42 | // 移除 token 并跳转登录页 43 | await AccountStore.resetToken(); 44 | next(`/login?redirect=${to.path}`); 45 | NProgress.done(); 46 | } 47 | } 48 | } 49 | } else { 50 | // 未登录可以访问白名单页面 51 | if (whiteList.indexOf(to.path) !== -1) { 52 | next(); 53 | } else { 54 | next(`/login?redirect=${to.path}`); 55 | NProgress.done(); 56 | } 57 | } 58 | }); 59 | 60 | router.afterEach(() => { 61 | NProgress.done(); 62 | }); 63 | -------------------------------------------------------------------------------- /frontend/src/settings.ts: -------------------------------------------------------------------------------- 1 | // 系统设置 2 | interface DefaultSettings { 3 | /** 4 | * 系统title 5 | */ 6 | title: string; 7 | 8 | /** 9 | * 是否显示设置 10 | */ 11 | showSettings: boolean; 12 | /** 13 | * 是否显示多标签导航 14 | */ 15 | tagsView: boolean; 16 | /** 17 | *是否固定头部 18 | */ 19 | fixedHeader: boolean; 20 | /** 21 | * 是否显示侧边栏Logo 22 | */ 23 | sidebarLogo: boolean; 24 | /** 25 | * 导航栏布局 26 | */ 27 | layout: string; 28 | /** 29 | * 主题颜色 30 | */ 31 | themeColor: string; 32 | /** 33 | * 主题模式 34 | */ 35 | theme: string; 36 | 37 | /** 38 | * 布局大小 39 | */ 40 | size: string; 41 | 42 | /** 43 | * 语言 44 | */ 45 | language: string; 46 | } 47 | 48 | const defaultSettings: DefaultSettings = { 49 | title: "h-ui", 50 | showSettings: true, 51 | tagsView: true, 52 | fixedHeader: false, 53 | sidebarLogo: true, 54 | layout: "left", 55 | themeColor: "#409EFF", 56 | /** 57 | * 主题模式 58 | * 59 | * dark:暗黑模式 60 | * light: 明亮模式 61 | */ 62 | theme: "dark", 63 | size: "default", // default |large |small 64 | language: "zh-cn", // zh-cn| en 65 | }; 66 | 67 | export default defaultSettings; 68 | -------------------------------------------------------------------------------- /frontend/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import type { App } from "vue"; 2 | import { createPinia } from "pinia"; 3 | 4 | const store = createPinia(); 5 | 6 | // 全局注册 store 7 | export function setupStore(app: App) { 8 | app.use(store); 9 | } 10 | 11 | export { store }; 12 | -------------------------------------------------------------------------------- /frontend/src/store/modules/account.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | 3 | import { getAccountInfoApi, loginApi } from "@/api/account"; 4 | import { resetRouter } from "@/router"; 5 | import { store } from "@/store"; 6 | 7 | import { AccountInfo, AccountLoginDto } from "@/api/account/types"; 8 | 9 | import { useStorage } from "@vueuse/core"; 10 | 11 | export const useAccountStore = defineStore("account", () => { 12 | // state 13 | const token = useStorage("accessToken", ""); 14 | const id = ref(0); 15 | const username = ref(""); 16 | const roles = ref>([]); // 用户角色编码集合 → 判断路由权限 17 | 18 | /** 19 | * 登录 20 | * 21 | * @returns 22 | */ 23 | function login(accountLoginDto: AccountLoginDto) { 24 | return new Promise((resolve, reject) => { 25 | loginApi(accountLoginDto) 26 | .then((response) => { 27 | const { tokenType, accessToken } = response.data; 28 | token.value = tokenType + " " + accessToken; // Bearer eyJhbGciOiJIUzI1NiJ9.xxx.xxx 29 | resolve(); 30 | }) 31 | .catch((error) => { 32 | reject(error); 33 | }); 34 | }); 35 | } 36 | 37 | // 查询当前 38 | function getAccountInfo() { 39 | return new Promise((resolve, reject) => { 40 | getAccountInfoApi() 41 | .then(({ data }) => { 42 | if (!data) { 43 | return reject("Verification failed, please Login again."); 44 | } 45 | if (!data.roles || data.roles.length <= 0) { 46 | reject("getAccountInfoApi: roles must be a non-null array!"); 47 | } 48 | id.value = data.id; 49 | username.value = data.username; 50 | roles.value = data.roles; 51 | resolve(data); 52 | }) 53 | .catch((error) => { 54 | reject(error); 55 | }); 56 | }); 57 | } 58 | 59 | // 注销 60 | function logout() { 61 | return new Promise((resolve, reject) => { 62 | resetRouter(); 63 | resetToken(); 64 | resolve(); 65 | }); 66 | } 67 | 68 | // 重置 69 | function resetToken() { 70 | token.value = ""; 71 | id.value = 0; 72 | username.value = ""; 73 | roles.value = []; 74 | } 75 | 76 | return { 77 | token, 78 | id, 79 | username, 80 | roles, 81 | login, 82 | getAccountInfo, 83 | logout, 84 | resetToken, 85 | }; 86 | }); 87 | 88 | // 非setup 89 | export function useAccountStoreHook() { 90 | return useAccountStore(store); 91 | } 92 | -------------------------------------------------------------------------------- /frontend/src/store/modules/app.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import { useStorage } from "@vueuse/core"; 3 | import defaultSettings from "@/settings"; 4 | 5 | // 导入 Element Plus 中英文语言包 6 | import zhCn from "element-plus/es/locale/lang/zh-cn"; 7 | import en from "element-plus/es/locale/lang/en"; 8 | 9 | // setup 10 | export const useAppStore = defineStore("app", () => { 11 | // state 12 | const device = useStorage("device", "desktop"); 13 | const size = useStorage("size", defaultSettings.size); 14 | const language = useStorage("language", defaultSettings.language); 15 | 16 | const sidebarStatus = useStorage("sidebarStatus", "closed"); 17 | const sidebar = reactive({ 18 | opened: sidebarStatus.value !== "closed", 19 | withoutAnimation: false, 20 | }); 21 | 22 | /** 23 | * 根据语言标识读取对应的语言包 24 | */ 25 | const locale = computed(() => { 26 | if (language?.value == "en") { 27 | return en; 28 | } else { 29 | return zhCn; 30 | } 31 | }); 32 | 33 | // actions 34 | function toggleSidebar(withoutAnimation: boolean) { 35 | sidebar.opened = !sidebar.opened; 36 | sidebar.withoutAnimation = withoutAnimation; 37 | if (sidebar.opened) { 38 | sidebarStatus.value = "opened"; 39 | } else { 40 | sidebarStatus.value = "closed"; 41 | } 42 | } 43 | 44 | function closeSideBar(withoutAnimation: boolean) { 45 | sidebar.opened = false; 46 | sidebar.withoutAnimation = withoutAnimation; 47 | sidebarStatus.value = "closed"; 48 | } 49 | 50 | function openSideBar(withoutAnimation: boolean) { 51 | sidebar.opened = true; 52 | sidebar.withoutAnimation = withoutAnimation; 53 | sidebarStatus.value = "opened"; 54 | } 55 | 56 | function toggleDevice(val: string) { 57 | device.value = val; 58 | } 59 | 60 | function changeSize(val: string) { 61 | size.value = val; 62 | } 63 | /** 64 | * 切换语言 65 | * 66 | * @param val 67 | */ 68 | function changeLanguage(val: string) { 69 | language.value = val; 70 | } 71 | 72 | return { 73 | device, 74 | sidebar, 75 | language, 76 | locale, 77 | size, 78 | toggleDevice, 79 | changeSize, 80 | changeLanguage, 81 | toggleSidebar, 82 | closeSideBar, 83 | openSideBar, 84 | }; 85 | }); 86 | -------------------------------------------------------------------------------- /frontend/src/store/modules/permission.ts: -------------------------------------------------------------------------------- 1 | import { RouteRecordRaw } from "vue-router"; 2 | import { defineStore } from "pinia"; 3 | import { asyncRoutes, constantRoutes } from "@/router"; 4 | import { store } from "@/store"; 5 | 6 | const modules = import.meta.glob("../../views/**/**.vue"); 7 | const Layout = () => import("@/layout/index.vue"); 8 | 9 | /** 10 | * Use meta.role to determine if the current user has permission 11 | * 12 | * @param roles 用户角色集合 13 | * @param route 路由 14 | * @returns 15 | */ 16 | const hasPermission = (roles: string[], route: RouteRecordRaw) => { 17 | if (route.meta && route.meta.roles) { 18 | // 角色【超级管理员】拥有所有权限,忽略校验 19 | if (roles.includes("admin")) { 20 | return true; 21 | } 22 | return roles.some((role) => { 23 | if (route.meta?.roles !== undefined) { 24 | return (route.meta.roles as string[]).includes(role); 25 | } 26 | }); 27 | } 28 | return false; 29 | }; 30 | 31 | /** 32 | * 递归过滤有权限的异步(动态)路由 33 | * 34 | * @param routes 接口返回的异步(动态)路由 35 | * @param roles 用户角色集合 36 | * @returns 返回用户有权限的异步(动态)路由 37 | */ 38 | const filterAsyncRoutes = (routes: RouteRecordRaw[], roles: string[]) => { 39 | const asyncRoutes: RouteRecordRaw[] = []; 40 | 41 | routes.forEach((route) => { 42 | const tmpRoute = { ...route }; // ES6扩展运算符复制新对象 43 | 44 | // 判断用户(角色)是否有该路由的访问权限 45 | if (hasPermission(roles, tmpRoute)) { 46 | if (tmpRoute.component?.toString() == "Layout") { 47 | tmpRoute.component = Layout; 48 | } else { 49 | const component = modules[`../../views/${tmpRoute.component}.vue`]; 50 | if (component) { 51 | tmpRoute.component = component; 52 | } else { 53 | tmpRoute.component = modules[`../../views/error-page/404.vue`]; 54 | } 55 | } 56 | 57 | if (tmpRoute.children) { 58 | tmpRoute.children = filterAsyncRoutes(tmpRoute.children, roles); 59 | } 60 | 61 | asyncRoutes.push(tmpRoute); 62 | } 63 | }); 64 | 65 | return asyncRoutes; 66 | }; 67 | 68 | // setup 69 | export const usePermissionStore = defineStore("permission", () => { 70 | // state 71 | const routes = ref([]); 72 | 73 | // actions 74 | function setRoutes(newRoutes: RouteRecordRaw[]) { 75 | routes.value = constantRoutes.concat(newRoutes); 76 | } 77 | 78 | /** 79 | * 生成动态路由 80 | * 81 | * @param roles 用户角色集合 82 | * @returns 83 | */ 84 | function generateRoutes(roles: string[]) { 85 | // 根据角色获取有访问权限的路由 86 | const accessedRoutes = filterAsyncRoutes(asyncRoutes, roles); 87 | setRoutes(accessedRoutes); 88 | return accessedRoutes; 89 | } 90 | 91 | return { routes, setRoutes, generateRoutes }; 92 | }); 93 | 94 | // 非setup 95 | export function usePermissionStoreHook() { 96 | return usePermissionStore(store); 97 | } 98 | -------------------------------------------------------------------------------- /frontend/src/store/modules/settings.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import defaultSettings from "@/settings"; 3 | import { useStorage } from "@vueuse/core"; 4 | 5 | export const useSettingsStore = defineStore("setting", () => { 6 | // state 7 | const tagsView = useStorage("tagsView", defaultSettings.tagsView); 8 | 9 | const showSettings = ref(defaultSettings.showSettings); 10 | const fixedHeader = ref(defaultSettings.fixedHeader); 11 | const sidebarLogo = ref(defaultSettings.sidebarLogo); 12 | 13 | const layout = useStorage("layout", defaultSettings.layout); 14 | 15 | const themeColor = useStorage( 16 | "themeColor", 17 | defaultSettings.themeColor 18 | ); 19 | 20 | // actions 21 | function changeSetting(param: { key: string; value: any }) { 22 | const { key, value } = param; 23 | switch (key) { 24 | case "showSettings": 25 | showSettings.value = value; 26 | break; 27 | case "fixedHeader": 28 | fixedHeader.value = value; 29 | break; 30 | case "tagsView": 31 | tagsView.value = value; 32 | break; 33 | case "sidevarLogo": 34 | sidebarLogo.value = value; 35 | break; 36 | case "layout": 37 | layout.value = value; 38 | break; 39 | case "themeColor": 40 | themeColor.value = value; 41 | break; 42 | default: 43 | break; 44 | } 45 | } 46 | 47 | return { 48 | showSettings, 49 | tagsView, 50 | fixedHeader, 51 | sidebarLogo, 52 | layout, 53 | themeColor, 54 | changeSetting, 55 | }; 56 | }); 57 | -------------------------------------------------------------------------------- /frontend/src/styles/dark.scss: -------------------------------------------------------------------------------- 1 | html.dark { 2 | --menuBg: var(--el-bg-color-overlay); 3 | --menuText: #fff; 4 | --menuActiveText: var(--el-menu-active-color); 5 | --menuHover: rgb(0 0 0 / 20%); 6 | --subMenuBg: var(--el-menu-bg-color); 7 | --subMenuActiveText: var(--el-menu-active-color); 8 | --subMenuHover: rgb(0 0 0 / 20%); 9 | 10 | .navbar { 11 | color: var(--el-text-color-regular); 12 | background-color: var(--el-bg-color); 13 | 14 | .setting-container .setting-item:hover { 15 | background: var(--el-fill-color-light); 16 | } 17 | } 18 | 19 | .right-panel-btn { 20 | background-color: var(--el-color-primary-dark); 21 | } 22 | 23 | .svg-icon, 24 | svg { 25 | fill: var(--el-text-color-regular); 26 | } 27 | 28 | .sidebar-container { 29 | .el-menu-item.is-active .svg-icon { 30 | fill: var(--el-color-primary); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/styles/element-plus.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | // 这里可以设置你自定义的颜色变量 3 | // 这个是element主要按钮:active的颜色,当主题更改后此变量的值也随之更改 4 | --el-color-primary-dark: #0d84ff; 5 | } 6 | 7 | // 覆盖 element-plus 的样式 8 | .el-breadcrumb__inner, 9 | .el-breadcrumb__inner a { 10 | font-weight: 400 !important; 11 | } 12 | 13 | .el-upload { 14 | input[type="file"] { 15 | display: none !important; 16 | } 17 | } 18 | 19 | .el-upload__input { 20 | display: none; 21 | } 22 | 23 | // dropdown 24 | .el-dropdown-menu { 25 | a { 26 | display: block; 27 | } 28 | } 29 | 30 | // to fix el-date-picker css style 31 | .el-range-separator { 32 | box-sizing: content-box; 33 | } 34 | 35 | // 选中行背景色值 36 | .el-table__body tr.current-row td { 37 | background-color: #e1f3d8b5 !important; 38 | } 39 | 40 | // card 的header统一高度 41 | .el-card__header { 42 | height: 60px !important; 43 | } 44 | 45 | // 表格表头和表体未对齐 46 | .el-table__header col[name="gutter"] { 47 | display: table-cell !important; 48 | } 49 | -------------------------------------------------------------------------------- /frontend/src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import "./sidebar"; 2 | @import "./reset"; 3 | @import "./dark"; 4 | @import "./element-plus"; 5 | 6 | .app-container { 7 | margin: 20px; 8 | 9 | .search { 10 | padding: 18px 0 0 10px; 11 | margin-bottom: 10px; 12 | background-color: var(--el-bg-color-overlay); 13 | border: 1px solid var(--el-border-color-light); 14 | border-radius: 4px; 15 | box-shadow: var(--el-box-shadow-light); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/styles/reset.scss: -------------------------------------------------------------------------------- 1 | *, 2 | ::before, 3 | ::after { 4 | box-sizing: border-box; 5 | border-color: currentcolor; 6 | border-style: solid; 7 | border-width: 0; 8 | } 9 | 10 | #app { 11 | width: 100%; 12 | height: 100%; 13 | } 14 | 15 | html { 16 | box-sizing: border-box; 17 | width: 100%; 18 | height: 100%; 19 | line-height: 1.5; 20 | tab-size: 4; 21 | text-size-adjust: 100%; 22 | } 23 | 24 | body { 25 | width: 100%; 26 | height: 100%; 27 | margin: 0; 28 | font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", 29 | "Microsoft YaHei", "微软雅黑", Arial, sans-serif; 30 | line-height: inherit; 31 | -moz-osx-font-smoothing: grayscale; 32 | -webkit-font-smoothing: antialiased; 33 | text-rendering: optimizelegibility; 34 | } 35 | 36 | a { 37 | color: inherit; 38 | text-decoration: inherit; 39 | } 40 | 41 | img, 42 | svg { 43 | display: inline-block; 44 | } 45 | 46 | svg { 47 | vertical-align: -0.15em; //因icon大小被设置为和字体大小一致,而span等标签的下边缘会和字体的基线对齐,故需设置一个往下的偏移比例,来纠正视觉上的未对齐效果 48 | } 49 | 50 | ul, 51 | li { 52 | padding: 0; 53 | margin: 0; 54 | list-style: none; 55 | } 56 | 57 | *, 58 | *::before, 59 | *::after { 60 | box-sizing: inherit; 61 | } 62 | 63 | a, 64 | a:focus, 65 | a:hover { 66 | color: inherit; 67 | text-decoration: none; 68 | cursor: pointer; 69 | } 70 | 71 | a:focus, 72 | a:active, 73 | div:focus { 74 | outline: none; 75 | } 76 | -------------------------------------------------------------------------------- /frontend/src/styles/variables.module.scss: -------------------------------------------------------------------------------- 1 | // 导出 variables.module.scss 变量提供给TypeScript使用 2 | :export { 3 | menuBg: $menuBg; 4 | menuText: $menuText; 5 | menuActiveText: $menuActiveText; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/styles/variables.scss: -------------------------------------------------------------------------------- 1 | // 全局SCSS变量 2 | 3 | :root { 4 | --menuBg: #304156; 5 | --menuText: #bfcbd9; 6 | --menuActiveText: #409eff; 7 | --menuHover: #263445; 8 | --subMenuBg: #1f2d3d; 9 | --subMenuActiveText: #f4f4f5; 10 | --subMenuHover: #001528; 11 | } 12 | 13 | $menuBg: var(--menuBg); 14 | $menuText: var(--menuText); 15 | $menuActiveText: var(--menuActiveText); 16 | $menuHover: var(--menuHover); 17 | 18 | $subMenuBg: var(--subMenuBg); 19 | $subMenuActiveText: var(--subMenuActiveText); 20 | $subMenuHover: var(--subMenuHover); 21 | 22 | $sideBarWidth: 210px; 23 | -------------------------------------------------------------------------------- /frontend/src/types/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module "*.vue" { 4 | import { DefineComponent } from "vue"; 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 6 | const component: DefineComponent<{}, {}, any>; 7 | export default component; 8 | } 9 | 10 | // 环境变量 TypeScript的智能提示 11 | interface ImportMetaEnv { 12 | VITE_APP_TITLE: string; 13 | VITE_APP_PORT: string; 14 | VITE_APP_BASE_API: string; 15 | } 16 | 17 | interface ImportMeta { 18 | readonly env: ImportMetaEnv; 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface IdDto { 3 | id: number; 4 | } 5 | 6 | interface BaseDto { 7 | pageNum: number; 8 | pageSize: number; 9 | startTime?: string; 10 | endTime?: string; 11 | } 12 | 13 | interface PageVo { 14 | records: T[]; 15 | total: number; 16 | } 17 | } 18 | export {}; 19 | -------------------------------------------------------------------------------- /frontend/src/utils/byte.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 格式化字节大小 3 | * @param bytes 字节数 4 | * @param decimals 小数位数,默认为 2 5 | * @returns 格式化后的字节大小字符串 6 | */ 7 | export const formatBytes = (bytes: number, decimals = 2): string => { 8 | // 检查是否为特殊值 9 | if (bytes === -1) { 10 | return "Unlimited"; 11 | } 12 | if (bytes === 0) { 13 | return "0 Bytes"; 14 | } 15 | 16 | // 计算单位和大小 17 | const k = 1024; 18 | const dm = decimals < 0 ? 0 : decimals; 19 | const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; 20 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 21 | 22 | // 返回格式化后的字符串 23 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; 24 | }; 25 | 26 | export const calculateBytes = (value = 0, unit = "Bytes"): number => { 27 | // 将单位转换为大写,并去除空格 28 | const formattedUnit = unit.toUpperCase().trim(); 29 | 30 | // 定义存储单位和对应的字节数的映射关系 31 | const unitToBytes: Record = { 32 | BYTES: 1, 33 | KB: 1024 ** 1, 34 | MB: 1024 ** 2, 35 | GB: 1024 ** 3, 36 | TB: 1024 ** 4, 37 | PB: 1024 ** 5, 38 | EB: 1024 ** 6, 39 | ZB: 1024 ** 7, 40 | YB: 1024 ** 8, 41 | }; 42 | 43 | // 检查传入的单位是否存在于映射关系中 44 | if (!Object.prototype.hasOwnProperty.call(unitToBytes, formattedUnit)) { 45 | throw new Error("Invalid unit"); 46 | } 47 | 48 | if (value == -1) { 49 | return -1; 50 | } 51 | 52 | // 计算并返回字节数 53 | return value * unitToBytes[formattedUnit]; 54 | }; 55 | 56 | /** 57 | * 格式化存储容量单位 58 | * @param bytes 存储容量(字节数) 59 | * @param decimals 小数位数,默认为 2 60 | * @returns 格式化后的存储容量值 61 | */ 62 | export const formatStorageCapacity = (bytes: number, decimals = 2): number => { 63 | // 检查输入是否有效 64 | if (!bytes || bytes <= 0) { 65 | return bytes; 66 | } 67 | 68 | // 计算存储单位 69 | const k = 1024; 70 | const dm = decimals < 0 ? 0 : decimals; 71 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 72 | 73 | // 格式化存储容量值并返回 74 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)); 75 | }; 76 | 77 | /** 78 | * 格式化存储容量单位 79 | * @param bytes 存储容量(字节数) 80 | * @returns 格式化后的存储单位 81 | */ 82 | export const formatStorageUnit = (bytes: number): string => { 83 | // 检查输入是否有效 84 | if (!bytes || bytes <= 0) { 85 | return "Bytes"; 86 | } 87 | 88 | // 计算存储单位 89 | const k = 1024; 90 | const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; 91 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 92 | 93 | // 返回格式化后的存储单位 94 | return sizes[i]; 95 | }; 96 | -------------------------------------------------------------------------------- /frontend/src/utils/copy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 浅拷贝,忽略 null,支持嵌套对象 3 | * @param target 4 | * @param source 5 | */ 6 | export const assignWith = (target: T, source: Partial): void => { 7 | if (source === null || typeof source !== "object") { 8 | return; 9 | } 10 | 11 | for (const key in source) { 12 | if (source[key] !== null) { 13 | if (typeof source[key] === "object") { 14 | if (!target[key]) { 15 | target[key] = (Array.isArray(source[key]) ? [] : {}) as T[Extract< 16 | keyof T, 17 | string 18 | >]; 19 | } 20 | assignWith(target[key] as any, source[key] as any); 21 | } else { 22 | target[key] = source[key] as T[Extract]; 23 | } 24 | } 25 | } 26 | }; 27 | 28 | /** 29 | * 深拷贝,忽略 null,支持嵌套对象 30 | * @param source 31 | */ 32 | export const deepCopy = (source: Partial): T => { 33 | if (source === null || typeof source !== "object") { 34 | return source; 35 | } 36 | 37 | if (Array.isArray(source)) { 38 | const arrCopy = [] as any[]; 39 | source.forEach((item, index) => { 40 | arrCopy[index] = deepCopy(item); 41 | }); 42 | return arrCopy as any; 43 | } 44 | 45 | const objCopy = {} as { [key: string]: any }; 46 | Object.keys(source).forEach((key) => { 47 | objCopy[key] = deepCopy((source as { [key: string]: any })[key]); 48 | }); 49 | 50 | return objCopy as T; 51 | }; 52 | -------------------------------------------------------------------------------- /frontend/src/utils/i18n.ts: -------------------------------------------------------------------------------- 1 | // translate router.meta.title, be used in breadcrumb sidebar tagsview 2 | import i18n from "@/lang/index"; 3 | 4 | export function translateRouteTitleI18n(title: any) { 5 | // 判断是否存在国际化配置,如果没有原生返回 6 | const hasKey = i18n.global.te("route." + title); 7 | if (hasKey) { 8 | const translatedTitle = i18n.global.t("route." + title); 9 | return translatedTitle; 10 | } 11 | return title; 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if an element has a class 3 | * @param {HTMLElement} ele 4 | * @param {string} cls 5 | * @returns {boolean} 6 | */ 7 | export function hasClass(ele: HTMLElement, cls: string) { 8 | return !!ele.className.match(new RegExp("(\\s|^)" + cls + "(\\s|$)")); 9 | } 10 | 11 | /** 12 | * Add class to element 13 | * @param {HTMLElement} ele 14 | * @param {string} cls 15 | */ 16 | export function addClass(ele: HTMLElement, cls: string) { 17 | if (!hasClass(ele, cls)) ele.className += " " + cls; 18 | } 19 | 20 | /** 21 | * Remove class from element 22 | * @param {HTMLElement} ele 23 | * @param {string} cls 24 | */ 25 | export function removeClass(ele: HTMLElement, cls: string) { 26 | if (hasClass(ele, cls)) { 27 | const reg = new RegExp("(\\s|^)" + cls + "(\\s|$)"); 28 | ele.className = ele.className.replace(reg, " "); 29 | } 30 | } 31 | 32 | /** 33 | * @param {string} path 34 | * @returns {Boolean} 35 | */ 36 | export function isExternal(path: string) { 37 | const isExternal = /^(https?:|http?:|mailto:|tel:)/.test(path); 38 | return isExternal; 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/utils/request.ts: -------------------------------------------------------------------------------- 1 | import axios, { InternalAxiosRequestConfig, AxiosResponse } from "axios"; 2 | import { useAccountStoreHook } from "@/store/modules/account"; 3 | 4 | const dynamicBase = (window as any).__dynamic_base__ || ""; 5 | // 创建 axios 实例 6 | const service = axios.create({ 7 | baseURL: `${dynamicBase}${import.meta.env.VITE_APP_BASE_API}`, 8 | timeout: 50000, 9 | headers: { "Content-Type": "application/json;charset=utf-8" }, 10 | }); 11 | 12 | // 请求拦截器 13 | service.interceptors.request.use( 14 | (config: InternalAxiosRequestConfig) => { 15 | const accountStore = useAccountStoreHook(); 16 | if (accountStore.token) { 17 | config.headers.Authorization = accountStore.token; 18 | } 19 | return config; 20 | }, 21 | (error: any) => { 22 | return Promise.reject(error); 23 | } 24 | ); 25 | 26 | // 响应拦截器 27 | service.interceptors.response.use( 28 | (response: AxiosResponse) => { 29 | const { code, message } = response.data; 30 | if (code === 20000) { 31 | return response.data; 32 | } 33 | // 响应数据为二进制流处理(文件导出) 34 | if (response.data instanceof ArrayBuffer || response.data instanceof Blob) { 35 | return response; 36 | } 37 | 38 | ElMessage.error(message || "系统出错"); 39 | return Promise.reject(new Error(message || "Error")); 40 | }, 41 | (error: any) => { 42 | if (error.response.data) { 43 | const { code, msg } = error.response.data; 44 | // token 过期,重新登录 45 | if (code === "A0230") { 46 | ElMessageBox.confirm("当前页面已失效,请重新登录", "提示", { 47 | confirmButtonText: "确定", 48 | type: "warning", 49 | }).then(() => { 50 | localStorage.clear(); 51 | window.location.href = "/"; 52 | }); 53 | } else { 54 | ElMessage.error(msg || "系统出错"); 55 | } 56 | } 57 | return Promise.reject(error.message); 58 | } 59 | ); 60 | 61 | // 导出 axios 实例 62 | export default service; 63 | -------------------------------------------------------------------------------- /frontend/src/utils/scroll-to.ts: -------------------------------------------------------------------------------- 1 | const easeInOutQuad = (t: number, b: number, c: number, d: number) => { 2 | t /= d / 2; 3 | if (t < 1) { 4 | return (c / 2) * t * t + b; 5 | } 6 | t--; 7 | return (-c / 2) * (t * (t - 2) - 1) + b; 8 | }; 9 | 10 | // requestAnimationFrame for Smart Animating http://goo.gl/sx5sts 11 | const requestAnimFrame = (function () { 12 | return ( 13 | window.requestAnimationFrame || 14 | (window as any).webkitRequestAnimationFrame || 15 | (window as any).mozRequestAnimationFrame || 16 | function (callback) { 17 | window.setTimeout(callback, 1000 / 60); 18 | } 19 | ); 20 | })(); 21 | 22 | /** 23 | * Because it's so fucking difficult to detect the scrolling element, just move them all 24 | * @param {number} amount 25 | */ 26 | const move = (amount: number) => { 27 | document.documentElement.scrollTop = amount; 28 | (document.body.parentNode as HTMLElement).scrollTop = amount; 29 | document.body.scrollTop = amount; 30 | }; 31 | 32 | const position = () => { 33 | return ( 34 | document.documentElement.scrollTop || 35 | (document.body.parentNode as HTMLElement).scrollTop || 36 | document.body.scrollTop 37 | ); 38 | }; 39 | 40 | /** 41 | * @param {number} to 42 | * @param {number} duration 43 | * @param {Function} callback 44 | */ 45 | export const scrollTo = (to: number, duration: number, callback?: any) => { 46 | const start = position(); 47 | const change = to - start; 48 | const increment = 20; 49 | let currentTime = 0; 50 | duration = typeof duration === "undefined" ? 500 : duration; 51 | const animateScroll = function () { 52 | // increment the time 53 | currentTime += increment; 54 | // find the value with the quadratic in-out easing function 55 | const val = easeInOutQuad(currentTime, start, change, duration); 56 | // move the document.body 57 | move(val); 58 | // do the animation unless its over 59 | if (currentTime < duration) { 60 | requestAnimFrame(animateScroll); 61 | } else { 62 | if (callback && typeof callback === "function") { 63 | // the animation is done so lets callback 64 | callback(); 65 | } 66 | } 67 | }; 68 | animateScroll(); 69 | }; 70 | -------------------------------------------------------------------------------- /frontend/src/utils/time.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 将时间戳转换为格式化日期时间字符串(YYYY-MM-DD HH:mm:ss) 3 | * @param timestamp 时间戳 4 | * @returns 格式化日期时间字符串 5 | */ 6 | export const timestampToDateTime = (timestamp: number): string => { 7 | const date = new Date(timestamp); 8 | const year = date.getFullYear(); 9 | const month = (date.getMonth() + 1).toString().padStart(2, "0"); 10 | const day = date.getDate().toString().padStart(2, "0"); 11 | const hours = date.getHours().toString().padStart(2, "0"); 12 | const minutes = date.getMinutes().toString().padStart(2, "0"); 13 | const seconds = date.getSeconds().toString().padStart(2, "0"); 14 | 15 | return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; 16 | }; 17 | 18 | export const calculateTimeDifference = (timestamp: number): string => { 19 | const now = Date.now(); 20 | const diff = timestamp - now; 21 | 22 | if (diff <= 0) { 23 | return "-"; 24 | } 25 | 26 | const seconds = Math.floor(diff / 1000); 27 | const minutes = Math.floor(seconds / 60); 28 | const hours = Math.floor(minutes / 60); 29 | const days = Math.floor(hours / 24); 30 | 31 | const remainingHours = hours % 24; 32 | const remainingMinutes = minutes % 60; 33 | const remainingSeconds = seconds % 60; 34 | 35 | const parts: string[] = []; 36 | 37 | if (days > 0) { 38 | parts.push(`${days}天`); 39 | } 40 | if (remainingHours > 0) { 41 | parts.push(`${remainingHours}小时`); 42 | } 43 | if (remainingMinutes > 0) { 44 | parts.push(`${remainingMinutes}分钟`); 45 | } 46 | if (remainingSeconds > 0) { 47 | parts.push(`${remainingSeconds}秒`); 48 | } 49 | 50 | return parts.join(" "); 51 | }; 52 | 53 | /** 54 | * 获取一小时后的时间戳 55 | * @returns 一周后的时间戳 56 | */ 57 | export const getHourLater = (): number => { 58 | const date = new Date(); 59 | date.setHours(date.getHours() + 1); 60 | return date.getTime(); 61 | }; 62 | 63 | /** 64 | * 获取一天后的时间戳 65 | * @returns 一周后的时间戳 66 | */ 67 | export const getDayLater = (): number => { 68 | const date = new Date(); 69 | date.setDate(date.getDate() + 1); 70 | return date.getTime(); 71 | }; 72 | 73 | /** 74 | * 获取一周后的时间戳 75 | * @returns 一周后的时间戳 76 | */ 77 | export const getWeekLater = (): number => { 78 | const date = new Date(); 79 | date.setDate(date.getDate() + 7); 80 | return date.getTime(); 81 | }; 82 | 83 | /** 84 | * 获取一个月后的时间戳 85 | * @returns 一个月后的时间戳 86 | */ 87 | export const getMonthLater = (): number => { 88 | const date = new Date(); 89 | date.setMonth(date.getMonth() + 1); 90 | return date.getTime(); 91 | }; 92 | 93 | /** 94 | * 获取一年后的时间戳 95 | * @returns 一年后的时间戳 96 | */ 97 | export const getYearLater = (): number => { 98 | const date = new Date(); 99 | date.setFullYear(date.getFullYear() + 1); 100 | return date.getTime(); 101 | }; 102 | -------------------------------------------------------------------------------- /frontend/src/views/error-page/401.vue: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 24 | 25 | 50 | 51 | 98 | -------------------------------------------------------------------------------- /frontend/src/views/log/hysteria/index.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 55 | 56 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /frontend/src/views/log/system/index.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 55 | 56 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /frontend/src/views/redirect/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "useDefineForClassFields": true, 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "esModuleInterop": true, 12 | "lib": ["esnext", "dom"], 13 | "baseUrl": ".", 14 | "allowJs": true, 15 | "paths": { 16 | "@/*": ["src/*"] 17 | }, 18 | "types": ["vite/client", "element-plus/global", "unplugin-icons/types/vue"], 19 | "skipLibCheck": true /* Skip type checking all .d.ts files. */, 20 | "allowSyntheticDefaultImports": true /* 允许默认导入 */, 21 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */ 22 | }, 23 | "include": [ 24 | "src/**/*.ts", 25 | "src/**/*.vue", 26 | "src/types/**/*.d.ts", 27 | "types/index.d.ts" 28 | ], 29 | "exclude": ["node_modules", "dist", "**/*.js"], 30 | "references": [{ "path": "./tsconfig.node.json" }] 31 | } 32 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /frontend/types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare type DialogType = { 2 | title: string; 3 | visible: boolean; 4 | }; 5 | 6 | declare type OptionType = { 7 | value: string; 8 | label: string; 9 | checked?: boolean; 10 | children?: OptionType[]; 11 | }; 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module h-ui 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/didip/tollbooth v4.0.2+incompatible 7 | github.com/gin-gonic/gin v1.9.1 8 | github.com/glebarez/sqlite v1.11.0 9 | github.com/go-playground/validator/v10 v10.14.0 10 | github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 11 | github.com/golang-jwt/jwt v3.2.2+incompatible 12 | github.com/google/go-github/v39 v39.2.0 13 | github.com/robfig/cron/v3 v3.0.1 14 | github.com/shirou/gopsutil v3.21.11+incompatible 15 | github.com/sirupsen/logrus v1.9.3 16 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e 17 | github.com/spf13/cobra v1.8.1 18 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 19 | gopkg.in/yaml.v3 v3.0.1 20 | gorm.io/gorm v1.25.9 21 | ) 22 | 23 | require ( 24 | github.com/bytedance/sonic v1.9.1 // indirect 25 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect 26 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 27 | github.com/dustin/go-humanize v1.0.1 // indirect 28 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 29 | github.com/gin-contrib/sse v0.1.0 // indirect 30 | github.com/glebarez/go-sqlite v1.21.2 // indirect 31 | github.com/go-ole/go-ole v1.2.6 // indirect 32 | github.com/go-playground/locales v0.14.1 // indirect 33 | github.com/go-playground/universal-translator v0.18.1 // indirect 34 | github.com/goccy/go-json v0.10.2 // indirect 35 | github.com/google/go-cmp v0.5.9 // indirect 36 | github.com/google/go-querystring v1.1.0 // indirect 37 | github.com/google/uuid v1.3.0 // indirect 38 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 39 | github.com/jinzhu/inflection v1.0.0 // indirect 40 | github.com/jinzhu/now v1.1.5 // indirect 41 | github.com/json-iterator/go v1.1.12 // indirect 42 | github.com/klauspost/cpuid/v2 v2.2.4 // indirect 43 | github.com/kr/pretty v0.3.1 // indirect 44 | github.com/leodido/go-urn v1.2.4 // indirect 45 | github.com/mattn/go-isatty v0.0.19 // indirect 46 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 47 | github.com/modern-go/reflect2 v1.0.2 // indirect 48 | github.com/patrickmn/go-cache v2.1.0+incompatible // indirect 49 | github.com/pelletier/go-toml/v2 v2.1.0 // indirect 50 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 51 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 52 | github.com/spf13/pflag v1.0.5 // indirect 53 | github.com/tklauser/go-sysconf v0.3.13 // indirect 54 | github.com/tklauser/numcpus v0.7.0 // indirect 55 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 56 | github.com/ugorji/go/codec v1.2.11 // indirect 57 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 58 | golang.org/x/arch v0.3.0 // indirect 59 | golang.org/x/crypto v0.16.0 // indirect 60 | golang.org/x/net v0.19.0 // indirect 61 | golang.org/x/sys v0.15.0 // indirect 62 | golang.org/x/text v0.14.0 // indirect 63 | golang.org/x/time v0.5.0 // indirect 64 | google.golang.org/protobuf v1.31.0 // indirect 65 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 66 | modernc.org/libc v1.22.5 // indirect 67 | modernc.org/mathutil v1.5.0 // indirect 68 | modernc.org/memory v1.5.0 // indirect 69 | modernc.org/sqlite v1.23.1 // indirect 70 | ) 71 | -------------------------------------------------------------------------------- /h-ui.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=h-ui Service 3 | After=network.target 4 | Wants=network.target 5 | 6 | [Service] 7 | Type=simple 8 | WorkingDirectory=/usr/local/h-ui/ 9 | ExecStart=/usr/local/h-ui/h-ui 10 | 11 | [Install] 12 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /local/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "menu": { 3 | "recommend_os": "Recommended OS", 4 | "description": "Description: Quick Installation of H UI", 5 | "author": "Author", 6 | "install_hui_systemd": "Install H UI (systemd)", 7 | "upgrade_h_ui_systemd": "Upgrade H UI (systemd)", 8 | "uninstall_h_ui_systemd": "Uninstall H UI (systemd)", 9 | "install_h_ui_docker": "Install H UI (Docker)", 10 | "upgrade_h_ui_docker": "Upgrade H UI (Docker)", 11 | "uninstall_h_ui_docker": "Uninstall H UI (Docker)", 12 | "ssh_local_port_forwarding": "SSH local port forwarding (Invalid after restarting the server)", 13 | "reset_sysadmin": "Reset sysadmin username and password" 14 | } 15 | } -------------------------------------------------------------------------------- /local/zh_cn.json: -------------------------------------------------------------------------------- 1 | { 2 | "menu": { 3 | "recommend_os": "推荐系统", 4 | "description": "描述: 一键安装 H UI", 5 | "author": "作者", 6 | "install_hui_systemd": "安装 H UI (systemd)", 7 | "upgrade_h_ui_systemd": "更新 H UI (systemd)", 8 | "uninstall_h_ui_systemd": "卸载 H UI (systemd)", 9 | "install_h_ui_docker": "安装 H UI (Docker)", 10 | "upgrade_h_ui_docker": "更新 H UI (Docker)", 11 | "uninstall_h_ui_docker": "卸载 H UI (Docker)", 12 | "ssh_local_port_forwarding": "SSH 本地端口转发(重启服务器后失效)", 13 | "reset_sysadmin": "重置系统管理员用户名和密码" 14 | } 15 | } -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "h-ui/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /middleware/admin.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "h-ui/model/constant" 6 | "h-ui/model/vo" 7 | "h-ui/service" 8 | "h-ui/util" 9 | ) 10 | 11 | func AdminHandler() gin.HandlerFunc { 12 | return func(c *gin.Context) { 13 | myClaims, err := service.ParseToken(service.GetToken(c)) 14 | if err != nil { 15 | vo.Fail(err.Error(), c) 16 | c.Abort() 17 | return 18 | } 19 | if !util.ArrContain(myClaims.AccountBo.Roles, "admin") { 20 | vo.Fail(constant.ForbiddenError, c) 21 | c.Abort() 22 | return 23 | } 24 | c.Next() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /middleware/cron.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "errors" 5 | "github.com/robfig/cron/v3" 6 | "github.com/sirupsen/logrus" 7 | "h-ui/dao" 8 | "h-ui/model/constant" 9 | "h-ui/service" 10 | "time" 11 | ) 12 | 13 | func InitCron() error { 14 | loc := time.Now().Location() 15 | c := cron.New(cron.WithLocation(loc)) 16 | _, err := c.AddFunc("@every 30s", service.CronHandleAccount) 17 | if err != nil { 18 | logrus.Errorf("cron add func CronHandleAccount err: %v", err) 19 | return errors.New("cron add func CronHandleAccount err") 20 | } 21 | resetTrafficCron, err := dao.GetConfig("key = ?", constant.ResetTrafficCron) 22 | if err != nil { 23 | return err 24 | } 25 | if *resetTrafficCron.Value != "" { 26 | _, err := c.AddFunc(*resetTrafficCron.Value, service.CronResetTraffic) 27 | if err != nil { 28 | logrus.Errorf("cron add func CronResetTraffic err: %v", err) 29 | } 30 | } 31 | c.Start() 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /middleware/filter.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "h-ui/model/vo" 6 | "net/http" 7 | "regexp" 8 | ) 9 | 10 | func FilterHandler() gin.HandlerFunc { 11 | return func(c *gin.Context) { 12 | matched, err := regexp.MatchString(`(?i)fofa|shodan|curl|wget`, c.Request.UserAgent()) 13 | if err != nil { 14 | vo.Fail("Internal error", c) 15 | c.AbortWithStatus(http.StatusInternalServerError) 16 | return 17 | } 18 | if matched { 19 | vo.Fail("Forbidden: Scanning tools are not allowed", c) 20 | c.AbortWithStatus(http.StatusForbidden) 21 | return 22 | } 23 | c.Next() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /middleware/jwt.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "h-ui/model/constant" 6 | "h-ui/model/vo" 7 | "h-ui/service" 8 | "strings" 9 | ) 10 | 11 | func JWTHandler() gin.HandlerFunc { 12 | return func(c *gin.Context) { 13 | authHeader := c.Request.Header.Get("Authorization") 14 | if authHeader == "" { 15 | vo.Fail(constant.UnauthorizedError, c) 16 | c.Abort() 17 | return 18 | } 19 | parts := strings.SplitN(authHeader, " ", 2) 20 | if !(len(parts) == 2 && parts[0] == "Bearer") { 21 | vo.Fail(constant.IllegalTokenError, c) 22 | c.Abort() 23 | return 24 | } 25 | myClaims, err := service.ParseToken(parts[1]) 26 | if err != nil { 27 | vo.Fail(err.Error(), c) 28 | c.Abort() 29 | return 30 | } 31 | if myClaims.AccountBo.Deleted != 0 { 32 | vo.Fail("this account has been disabled", c) 33 | c.Abort() 34 | return 35 | } 36 | c.Next() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /middleware/log.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/sirupsen/logrus" 6 | "gopkg.in/natefinch/lumberjack.v2" 7 | "h-ui/model/constant" 8 | "time" 9 | ) 10 | 11 | func InitLog() { 12 | logrus.SetOutput(&lumberjack.Logger{ 13 | Filename: constant.SystemLogPath, 14 | MaxSize: 1, 15 | MaxBackups: 2, 16 | MaxAge: 30, 17 | Compress: true, 18 | LocalTime: true, 19 | }) 20 | logrus.SetFormatter(&logrus.JSONFormatter{TimestampFormat: "2006-01-02 15:04:05"}) 21 | logrus.SetLevel(logrus.WarnLevel) 22 | } 23 | 24 | func LogHandler() gin.HandlerFunc { 25 | return func(c *gin.Context) { 26 | startTime := time.Now() 27 | endTime := time.Now() 28 | statusCode := c.Writer.Status() 29 | latencyTime := endTime.Sub(startTime) 30 | clientIP := c.ClientIP() 31 | reqMethod := c.Request.Method 32 | reqUri := c.Request.RequestURI 33 | 34 | logrus.WithFields(logrus.Fields{ 35 | "statusCode": statusCode, 36 | "latencyTime": latencyTime, 37 | "clientIP": clientIP, 38 | "reqMethod": reqMethod, 39 | "reqUri": reqUri, 40 | }).Info() 41 | c.Next() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /middleware/rate_limiter.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/didip/tollbooth" 5 | "github.com/didip/tollbooth/limiter" 6 | "github.com/gin-gonic/gin" 7 | "h-ui/model/vo" 8 | ) 9 | 10 | var limit *limiter.Limiter 11 | 12 | func init() { 13 | limit = tollbooth.NewLimiter(5, nil) 14 | } 15 | 16 | func RateLimiterHandler() gin.HandlerFunc { 17 | return func(c *gin.Context) { 18 | httpError := tollbooth.LimitByRequest(limit, c.Writer, c.Request) 19 | if httpError != nil { 20 | vo.Fail("click too fast", c) 21 | c.Abort() 22 | return 23 | } 24 | c.Next() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /model/bo/account.go: -------------------------------------------------------------------------------- 1 | package bo 2 | 3 | import "time" 4 | 5 | type AccountBo struct { 6 | Id int64 `json:"id"` 7 | Username string `json:"username"` 8 | Roles []string `json:"roles"` 9 | Deleted int64 `json:"deleted"` 10 | } 11 | 12 | type AccountExport struct { 13 | Id int64 `json:"id"` 14 | Username string `json:"username"` 15 | Pass string `json:"pass"` 16 | ConPass string `json:"conPass"` 17 | Quota int64 `json:"quota"` 18 | Download int64 `json:"download"` 19 | Upload int64 `json:"upload"` 20 | ExpireTime int64 `json:"expireTime"` 21 | DeviceNo int64 `json:"deviceNo"` 22 | KickUtilTime int64 `json:"kickUtilTime"` 23 | Role string `json:"role"` 24 | Deleted int64 `json:"deleted"` 25 | CreateTime time.Time `json:"createTime"` 26 | UpdateTime time.Time `json:"updateTime"` 27 | LoginAt int64 `json:"loginAt"` 28 | ConAt int64 `json:"conAt"` 29 | } 30 | -------------------------------------------------------------------------------- /model/bo/hysteria2_api.go: -------------------------------------------------------------------------------- 1 | package bo 2 | 3 | type Hysteria2UserTraffic struct { 4 | Tx int64 `json:"tx"` // upload 5 | Rx int64 `json:"rx"` // download 6 | } 7 | -------------------------------------------------------------------------------- /model/bo/subscribe.go: -------------------------------------------------------------------------------- 1 | package bo 2 | 3 | type Hysteria2 struct { 4 | Name string `yaml:"name"` 5 | Type string `yaml:"type"` 6 | Server string `yaml:"server"` 7 | Port string `yaml:"port"` 8 | Ports string `yaml:"ports,omitempty"` 9 | Password string `yaml:"password"` 10 | Up string `yaml:"up,omitempty"` 11 | Down string `yaml:"down,omitempty"` 12 | Obfs string `yaml:"obfs,omitempty"` 13 | ObfsPassword string `yaml:"obfs-password,omitempty"` 14 | Sni string `yaml:"sni,omitempty"` 15 | SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` 16 | } 17 | 18 | type ProxyGroup struct { 19 | Name string `yaml:"name"` 20 | Type string `yaml:"type"` 21 | Proxies []string `yaml:"proxies"` 22 | } 23 | 24 | type ClashConfig struct { 25 | Proxies []interface{} `yaml:"proxies"` 26 | ProxyGroups []ProxyGroup `yaml:"proxy-groups"` 27 | } 28 | -------------------------------------------------------------------------------- /model/constant/client.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | const ( 4 | Shadowrocket = "shadowrocket" 5 | Clash = "clash" 6 | V2rayN = "v2rayn" 7 | NekoBox = "nekobox" 8 | ) 9 | -------------------------------------------------------------------------------- /model/constant/code.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | const ( 4 | CodeSuccess int = 20000 5 | CodeSysError int = 50000 6 | CodeUnauthorizedError int = 50401 7 | CodeForbiddenError int = 50403 8 | CodeInvalidError int = 50001 9 | ) 10 | -------------------------------------------------------------------------------- /model/constant/config.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | const ( 4 | HUIWebPort = "H_UI_WEB_PORT" 5 | HUIWebContext = "H_UI_WEB_CONTEXT" 6 | HUICrtPath = "H_UI_CRT_PATH" 7 | HUIKeyPath = "H_UI_KEY_PATH" 8 | JwtSecret = "JWT_SECRET" 9 | Hysteria2Enable = "HYSTERIA2_ENABLE" 10 | Hysteria2Config = "HYSTERIA2_CONFIG" 11 | Hysteria2TrafficTime = "HYSTERIA2_TRAFFIC_TIME" 12 | Hysteria2ConfigRemark = "HYSTERIA2_CONFIG_REMARK" 13 | Hysteria2ConfigPortHopping = "HYSTERIA2_CONFIG_PORT_HOPPING" 14 | ResetTrafficCron = "RESET_TRAFFIC_CRON" 15 | TelegramEnable = "TELEGRAM_ENABLE" 16 | TelegramToken = "TELEGRAM_TOKEN" 17 | TelegramChatId = "TELEGRAM_CHAT_ID" 18 | TelegramDebug = "TELEGRAM_DEBUG" 19 | TelegramLoginJobEnable = "TELEGRAM_LOGIN_JOB_ENABLE" 20 | TelegramLoginJobText = "TELEGRAM_LOGIN_JOB_TEXT" 21 | ClashExtension = "CLASH_EXTENSION" 22 | ) 23 | -------------------------------------------------------------------------------- /model/constant/error.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | const ( 4 | SysError string = "system error" 5 | UnauthorizedError string = "unauthorized" 6 | ForbiddenError string = "permission denied" 7 | InvalidError string = "invalid" 8 | 9 | IllegalTokenError string = "authentication failed" 10 | TokenExpiredError string = "token expired" 11 | 12 | WrongPassword string = "wrong password" 13 | ConfigNotExist string = "config not exist" 14 | ) 15 | -------------------------------------------------------------------------------- /model/constant/system.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | const ( 4 | LogDir = "logs/" 5 | SqliteDBDir = "data/" 6 | BinDir = "bin/" 7 | ExportPathDir = "export/" 8 | 9 | SqliteDBPath = "data/h_ui.db" 10 | 11 | Hysteria2ConfigPath = "bin/hysteria2.yaml" 12 | 13 | SystemLogPath = "logs/h-ui.log" 14 | Hysteria2LogPath = "logs/hysteria2.log" 15 | 16 | TokenType = "Bearer" 17 | 18 | Version = "v0.0.20" 19 | ) 20 | -------------------------------------------------------------------------------- /model/dto/account.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type AccountPageDto struct { 4 | BaseDto 5 | Username *string `json:"username" form:"username" validate:"omitempty,min=1,max=32"` 6 | Deleted *int64 `json:"deleted" form:"deleted" validate:"omitempty,oneof=0 1"` 7 | } 8 | 9 | type LoginDto struct { 10 | Username *string `json:"username" form:"username" validate:"required,min=6,max=32,validateStr"` 11 | Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=32,validateStr"` 12 | } 13 | 14 | type AccountSaveDto struct { 15 | Username *string `json:"username" form:"username" validate:"required,min=6,max=32,validateStr"` 16 | Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=32,validateStr"` 17 | ConPass *string `json:"conPass" form:"conPass" validate:"required,min=6,max=32,validateStr"` 18 | Quota *int64 `json:"quota" form:"quota" validate:"required,min=-1"` 19 | ExpireTime *int64 `json:"expireTime" form:"expireTime" validate:"required,min=0"` 20 | DeviceNo *int64 `json:"deviceNo" form:"deviceNo" validate:"required,min=1"` 21 | Deleted *int64 `json:"deleted" form:"deleted" validate:"required,oneof=0 1"` 22 | } 23 | 24 | type AccountUpdateDto struct { 25 | IdDto 26 | Username *string `json:"username" form:"username" validate:"omitempty,min=6,max=32,validateStr"` 27 | Pass *string `json:"pass" form:"pass" validate:"omitempty,min=6,max=32,validateStr"` 28 | ConPass *string `json:"conPass" form:"conPass" validate:"omitempty,min=6,max=32,validateStr"` 29 | Quota *int64 `json:"quota" form:"quota" validate:"omitempty,min=-1"` 30 | ExpireTime *int64 `json:"expireTime" form:"expireTime" validate:"omitempty,min=0"` 31 | DeviceNo *int64 `json:"deviceNo" form:"deviceNo" validate:"omitempty,min=1"` 32 | Deleted *int64 `json:"deleted" form:"deleted" validate:"omitempty,oneof=0 1"` 33 | } 34 | -------------------------------------------------------------------------------- /model/dto/config.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type ConfigDto struct { 4 | Key *string `json:"key" form:"key" validate:"required,min=1,max=128"` 5 | } 6 | 7 | type ConfigsDto struct { 8 | Keys []string `json:"keys" form:"keys" validate:"required"` 9 | } 10 | 11 | type ConfigUpdateDto struct { 12 | Key *string `json:"key" form:"key" validate:"required,min=1,max=128"` 13 | Value *string `json:"value" form:"value" validate:"required,min=0,max=128"` 14 | } 15 | 16 | type ConfigsUpdateDto struct { 17 | ConfigUpdateDtos []ConfigUpdateDto `json:"configUpdateDtos" form:"configUpdateDtos" validate:"required"` 18 | } 19 | -------------------------------------------------------------------------------- /model/dto/dto.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type BaseDto struct { 4 | PageNum *int64 `json:"pageNum" form:"pageNum" validate:"required,gt=0"` // 页号 5 | PageSize *int64 `json:"pageSize" form:"pageSize" validate:"required,gt=0"` // 页大小 6 | StartTime *int64 `json:"startTime" form:"startTime" validate:"omitempty,gt=0"` // 开始时间 7 | EndTime *int64 `json:"endTime" form:"endTime" validate:"omitempty,gt=0"` // 结束时间 8 | } 9 | 10 | type IdDto struct { 11 | Id *int64 `json:"id" form:"id" validate:"required,gt=0"` // 主键 12 | } 13 | -------------------------------------------------------------------------------- /model/dto/hysteria2.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type Hysteria2AuthDto struct { 4 | Addr *string `json:"addr" form:"addr" validate:"required"` 5 | Auth *string `json:"auth" form:"auth" validate:"required"` 6 | Tx *string `json:"tx" form:"tx" validate:"required"` 7 | } 8 | 9 | type Hysteria2KickDto struct { 10 | Ids []int64 `json:"ids" form:"ids" validate:"required"` 11 | KickUtilTime *int64 `json:"kickUtilTime" form:"kickUtilTime" validate:"required"` // 解禁时间 12 | } 13 | 14 | type Hysteria2VersionDto struct { 15 | Version *string `json:"version" form:"version" validate:"required,min=1,max=10"` 16 | } 17 | 18 | type Hysteria2SubscribeUrlDto struct { 19 | AccountId *int64 `json:"accountId" form:"accountId" validate:"required,gt=0"` 20 | Protocol *string `json:"protocol" form:"protocol" validate:"required,min=1,max=8"` 21 | Host *string `json:"host" form:"host" validate:"required,min=1,max=301"` 22 | } 23 | 24 | type Hysteria2UrlDto struct { 25 | AccountId *int64 `json:"accountId" form:"accountId" validate:"required,gt=0"` 26 | Hostname *string `json:"hostname" form:"hostname" validate:"required,min=1,max=255"` 27 | } 28 | -------------------------------------------------------------------------------- /model/dto/log.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type LogDto struct { 4 | NumLine *int `json:"numLine" form:"numLine" validate:"omitempty,min=1,max=300"` 5 | } 6 | 7 | type LogExportDto struct { 8 | Option *int `json:"option" form:"option" validate:"required,oneof=0 1"` 9 | } 10 | -------------------------------------------------------------------------------- /model/dto/server.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type ServerDto struct { 4 | Port *int64 `json:"port" form:"port" validate:"required,min=1,max=65535"` 5 | } 6 | -------------------------------------------------------------------------------- /model/entity/account.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | type Account struct { 4 | Username *string `gorm:"column:username;default:''" json:"username"` 5 | Pass *string `gorm:"column:pass;default:''" json:"pass"` 6 | ConPass *string `gorm:"column:con_pass;default:''" json:"conPass"` 7 | Quota *int64 `gorm:"column:quota;default:0" json:"quota"` 8 | Download *int64 `gorm:"column:download;default:0" json:"download"` 9 | Upload *int64 `gorm:"column:upload;default:0" json:"upload"` 10 | ExpireTime *int64 `gorm:"column:expire_time;default:0" json:"expireTime"` 11 | KickUtilTime *int64 `gorm:"column:kick_util_time;default:0" json:"kickUtilTime"` 12 | DeviceNo *int64 `gorm:"column:device_no;default:3" json:"deviceNo"` 13 | Role *string `gorm:"column:role;default:'user'" json:"role"` 14 | Deleted *int64 `gorm:"column:deleted;default:0" json:"deleted"` 15 | BaseEntity `gorm:"embedded"` 16 | 17 | LoginAt *int64 `gorm:"column:login_at;default:0" json:"loginAt"` 18 | ConAt *int64 `gorm:"column:con_at;default:0" json:"conAt"` 19 | } 20 | -------------------------------------------------------------------------------- /model/entity/config.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | type Config struct { 4 | Key *string `gorm:"column:key;default:''" json:"key"` 5 | Value *string `gorm:"column:value;default:''" json:"value"` 6 | Remark *string `gorm:"column:remark;default:''" json:"remark"` 7 | BaseEntity `gorm:"embedded"` 8 | } 9 | -------------------------------------------------------------------------------- /model/entity/entity.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import "time" 4 | 5 | type BaseEntity struct { 6 | Id *int64 `gorm:"column:id;primaryKey" json:"id"` 7 | CreateTime *time.Time `gorm:"column:create_time;default:null" json:"createTime"` 8 | UpdateTime *time.Time `gorm:"column:update_time;default:null" json:"updateTime"` 9 | } 10 | -------------------------------------------------------------------------------- /model/vo/account.go: -------------------------------------------------------------------------------- 1 | package vo 2 | 3 | type AccountVo struct { 4 | BaseVo 5 | Username string `json:"username"` 6 | Quota int64 `json:"quota"` 7 | Download int64 `json:"download"` 8 | Upload int64 `json:"upload"` 9 | ExpireTime int64 `json:"expireTime"` 10 | KickUtilTime int64 `json:"kickUtilTime"` // Offline remaining time 11 | DeviceNo int64 `json:"deviceNo"` // Limit the number of devices 12 | Role string `json:"role"` 13 | Deleted int64 `json:"deleted"` 14 | 15 | Online bool `json:"online"` // online status 16 | Device int64 `json:"device"` // Number of online devices 17 | 18 | LoginAt int64 `json:"loginAt"` 19 | ConAt int64 `json:"conAt"` 20 | } 21 | type AccountPageVo struct { 22 | AccountVos []AccountVo `json:"records"` 23 | Total int64 `json:"total"` 24 | } 25 | 26 | type AccountInfoVo struct { 27 | Id int64 `json:"id"` 28 | Username string `json:"username"` 29 | Roles []string `json:"roles"` 30 | } 31 | -------------------------------------------------------------------------------- /model/vo/config.go: -------------------------------------------------------------------------------- 1 | package vo 2 | 3 | type ConfigVo struct { 4 | Key string `json:"key"` 5 | Value string `json:"value"` 6 | Remark string `json:"remark"` 7 | } 8 | -------------------------------------------------------------------------------- /model/vo/hysteria2.go: -------------------------------------------------------------------------------- 1 | package vo 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "net/http" 6 | ) 7 | 8 | type hysteria2Result struct { 9 | Ok bool `json:"ok"` 10 | Id string `json:"id"` 11 | } 12 | 13 | func Hysteria2AuthSuccess(id string, c *gin.Context) { 14 | c.JSON(http.StatusOK, hysteria2Result{ 15 | Ok: true, 16 | Id: id, 17 | }) 18 | } 19 | 20 | func Hysteria2AuthFail(id string, c *gin.Context) { 21 | c.JSON(http.StatusOK, hysteria2Result{ 22 | Ok: false, 23 | Id: id, 24 | }) 25 | } 26 | 27 | type Hysteria2SubscribeVo struct { 28 | Url string `json:"url"` 29 | QrCode []byte `json:"qrCode"` 30 | } 31 | 32 | type Hysteria2UrlVo struct { 33 | Url string `json:"url"` 34 | QrCode []byte `json:"qrCode"` 35 | } 36 | 37 | type Hysteria2AcmePathVo struct { 38 | CrtPath string `json:"crtPath"` 39 | KeyPath string `json:"keyPath"` 40 | } 41 | -------------------------------------------------------------------------------- /model/vo/jwt.go: -------------------------------------------------------------------------------- 1 | package vo 2 | 3 | type JwtVo struct { 4 | TokenType string `json:"tokenType"` 5 | AccessToken string `json:"accessToken"` 6 | } 7 | -------------------------------------------------------------------------------- /model/vo/log.go: -------------------------------------------------------------------------------- 1 | package vo 2 | 3 | type LogSystemPage[T LogSystemVo | LogHysteria2Vo] struct { 4 | LogSystemVos []T `json:"records"` 5 | Total int64 `json:"total"` 6 | } 7 | 8 | type LogSystemVo struct { 9 | ClientIP string `json:"clientIp"` 10 | LatencyTime int64 `json:"latencyTime"` 11 | Level string `json:"level"` 12 | Msg string `json:"msg"` 13 | ReqMethod string `json:"reqMethod"` 14 | ReqUri string `json:"reqUri"` 15 | StatusCode int64 `json:"statusCode"` 16 | Time string `json:"time"` 17 | } 18 | 19 | type LogHysteria2Vo struct { 20 | Level string `json:"level"` 21 | Msg string `json:"msg"` 22 | Time string `json:"time"` 23 | } 24 | -------------------------------------------------------------------------------- /model/vo/monitor.go: -------------------------------------------------------------------------------- 1 | package vo 2 | 3 | type SystemMonitorVo struct { 4 | HUIVersion string `json:"huiVersion"` 5 | CpuPercent float64 `json:"cpuPercent"` 6 | MemPercent float64 `json:"memPercent"` 7 | DiskPercent float64 `json:"diskPercent"` 8 | } 9 | 10 | type Hysteria2MonitorVo struct { 11 | UserTotal int64 `json:"userTotal"` // 在线用户数 12 | DeviceTotal int64 `json:"deviceTotal"` // 在线设备数 13 | Version string `json:"version"` // 版本 14 | Running bool `json:"running"` // 运行状态 15 | } 16 | -------------------------------------------------------------------------------- /model/vo/result.go: -------------------------------------------------------------------------------- 1 | package vo 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "h-ui/model/constant" 6 | "net/http" 7 | ) 8 | 9 | type result struct { 10 | Code int `json:"code"` 11 | Type string `json:"type"` 12 | Message string `json:"message"` 13 | Data interface{} `json:"data"` 14 | } 15 | 16 | const ( 17 | TypeSuccess = "ok" 18 | TypeError = "no" 19 | ) 20 | 21 | func Success(data interface{}, c *gin.Context) { 22 | c.JSON(http.StatusOK, result{ 23 | Code: constant.CodeSuccess, 24 | Type: TypeSuccess, 25 | Data: data, 26 | }) 27 | } 28 | 29 | func Fail(message string, c *gin.Context) { 30 | var code int 31 | if constant.UnauthorizedError == message { 32 | code = constant.CodeUnauthorizedError 33 | } else if constant.ForbiddenError == message { 34 | code = constant.CodeForbiddenError 35 | } else if constant.InvalidError == message { 36 | code = constant.CodeInvalidError 37 | } else { 38 | code = constant.CodeSysError 39 | } 40 | c.JSON(http.StatusOK, result{ 41 | Code: code, 42 | Type: TypeError, 43 | Message: message, 44 | Data: nil, 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /model/vo/vo.go: -------------------------------------------------------------------------------- 1 | package vo 2 | 3 | import "time" 4 | 5 | type BaseVo struct { 6 | Id int64 `json:"id"` 7 | CreateTime time.Time `json:"createTime"` 8 | } 9 | -------------------------------------------------------------------------------- /proxy/hysteria2.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "errors" 5 | "github.com/sirupsen/logrus" 6 | "h-ui/model/constant" 7 | "h-ui/util" 8 | "os/exec" 9 | "sync" 10 | ) 11 | 12 | type Hysteria2Process struct { 13 | process 14 | binPath string 15 | configPath string 16 | } 17 | 18 | var mutexHysteria2 sync.Mutex 19 | var cmdHysteria2 exec.Cmd 20 | var hysteria2Instance *Hysteria2Process 21 | 22 | func init() { 23 | hysteria2Instance = &Hysteria2Process{process{mutex: &mutexHysteria2, cmd: &cmdHysteria2}, util.GetHysteria2BinPath(), constant.Hysteria2ConfigPath} 24 | } 25 | 26 | func NewHysteria2Instance() *Hysteria2Process { 27 | return hysteria2Instance 28 | } 29 | 30 | func (h *Hysteria2Process) IsRunning() bool { 31 | return h.isRunning() 32 | } 33 | 34 | func (h *Hysteria2Process) StartHysteria2() error { 35 | if err := h.start(h.binPath, "-c", h.configPath, "server"); err != nil { 36 | _ = util.RemoveFile(h.configPath) 37 | logrus.Errorf("start hysteria2 err: %v", err) 38 | return errors.New("start hysteria2 err") 39 | } 40 | return nil 41 | } 42 | 43 | func (h *Hysteria2Process) StopHysteria2() error { 44 | if err := h.stop(); err != nil { 45 | logrus.Errorf("stop hysteria2 err: %v", err) 46 | return errors.New("stop hysteria2 err") 47 | } 48 | _ = util.RemoveFile(h.configPath) 49 | return nil 50 | } 51 | 52 | func (h *Hysteria2Process) Release() error { 53 | if err := h.release(); err != nil { 54 | logrus.Errorf("release hysteria2 err: %v", err) 55 | return errors.New("release hysteria2 err") 56 | } 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /router/account.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "h-ui/controller" 6 | ) 7 | 8 | func initAccountAdminRouter(accountApi *gin.RouterGroup) { 9 | account := accountApi.Group("/account") 10 | { 11 | account.GET("/pageAccount", controller.PageAccount) 12 | account.POST("/saveAccount", controller.SaveAccount) 13 | account.POST("/deleteAccount", controller.DeleteAccount) 14 | account.POST("/updateAccount", controller.UpdateAccount) 15 | account.POST("/resetTraffic", controller.ResetTraffic) 16 | account.GET("/getAccountInfo", controller.GetAccountInfo) 17 | account.GET("/getAccount", controller.GetAccount) 18 | account.POST("/importAccount", controller.ImportAccount) 19 | account.POST("/exportAccount", controller.ExportAccount) 20 | account.POST("/releaseKickAccount", controller.ReleaseKickAccount) 21 | account.GET("/verifyDefaultPass", controller.VerifyDefaultPass) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /router/auth.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "h-ui/controller" 6 | ) 7 | 8 | func initAuthRouter(authApi *gin.RouterGroup) { 9 | auth := authApi.Group("/auth") 10 | { 11 | auth.POST("/login", controller.Login) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /router/config.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "h-ui/controller" 6 | ) 7 | 8 | func initConfigRouter(configApi *gin.RouterGroup) { 9 | config := configApi.Group("/config") 10 | { 11 | config.POST("/updateConfigs", controller.UpdateConfigs) 12 | config.GET("/getConfig", controller.GetConfig) 13 | config.POST("/listConfig", controller.ListConfig) 14 | config.GET("/getHysteria2Config", controller.GetHysteria2Config) 15 | config.POST("/updateHysteria2Config", controller.UpdateHysteria2Config) 16 | config.POST("/exportHysteria2Config", controller.ExportHysteria2Config) 17 | config.POST("/importHysteria2Config", controller.ImportHysteria2Config) 18 | config.POST("/exportConfig", controller.ExportConfig) 19 | config.POST("/importConfig", controller.ImportConfig) 20 | config.GET("/hysteria2AcmePath", controller.Hysteria2AcmePath) 21 | config.POST("/restartServer", controller.RestartServer) 22 | config.POST("/uploadCertFile", controller.UploadCertFile) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /router/hysteria2.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "h-ui/controller" 6 | ) 7 | 8 | func initHysteria2AuthRouter(hysteria2Api *gin.RouterGroup) { 9 | hysteria2 := hysteria2Api.Group("/hysteria2") 10 | { 11 | hysteria2.POST("/auth", controller.Hysteria2Auth) 12 | 13 | } 14 | hysteria2Api.GET("/:conPass", controller.Hysteria2Subscribe) 15 | } 16 | 17 | func initHysteria2Router(hysteria2Api *gin.RouterGroup) { 18 | hysteria2 := hysteria2Api.Group("/hysteria2") 19 | { 20 | hysteria2.POST("/hysteria2Kick", controller.Hysteria2Kick) 21 | hysteria2.POST("/hysteria2ChangeVersion", controller.Hysteria2ChangeVersion) 22 | hysteria2.GET("/listRelease", controller.ListRelease) 23 | hysteria2.GET("/hysteria2SubscribeUrl", controller.Hysteria2SubscribeUrl) 24 | hysteria2.GET("/hysteria2Url", controller.Hysteria2Url) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /router/log.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "h-ui/controller" 6 | ) 7 | 8 | func initLogRouter(accountApi *gin.RouterGroup) { 9 | account := accountApi.Group("/log") 10 | { 11 | account.GET("/logSystem", controller.LogSystem) 12 | account.GET("/logHysteria2", controller.LogHysteria2) 13 | account.POST("/exportLog", controller.ExportLog) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /router/monitor.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "h-ui/controller" 6 | ) 7 | 8 | func initMonitorRouter(accountApi *gin.RouterGroup) { 9 | account := accountApi.Group("/monitor") 10 | { 11 | account.GET("/monitorSystem", controller.MonitorSystem) 12 | account.GET("/monitorHysteria2", controller.MonitorHysteria2) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "h-ui/frontend" 6 | "h-ui/middleware" 7 | "strings" 8 | ) 9 | 10 | func Router(router *gin.Engine, huiWebContext *string) { 11 | // global context 12 | relativePath := "/" 13 | if huiWebContext != nil && strings.HasPrefix(*huiWebContext, "/") { 14 | relativePath = *huiWebContext 15 | } 16 | globalGroup := router.Group(relativePath) 17 | { 18 | globalGroup.Use(middleware.FilterHandler(), middleware.LogHandler(), middleware.RateLimiterHandler()) 19 | 20 | frontend.InitFrontend(router, relativePath) 21 | 22 | authApi := globalGroup.Group("/hui") 23 | { 24 | initAuthRouter(authApi) 25 | initHysteria2AuthRouter(authApi) 26 | } 27 | 28 | globalGroup.Use(middleware.JWTHandler()) 29 | 30 | globalGroup.Use(middleware.AdminHandler()) 31 | 32 | huiAdminApi := globalGroup.Group("/hui") 33 | { 34 | initAccountAdminRouter(huiAdminApi) 35 | initConfigRouter(huiAdminApi) 36 | initHysteria2Router(huiAdminApi) 37 | initLogRouter(huiAdminApi) 38 | initMonitorRouter(huiAdminApi) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /service/cron.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/sirupsen/logrus" 5 | "h-ui/dao" 6 | "h-ui/model/bo" 7 | "h-ui/model/constant" 8 | "h-ui/proxy" 9 | "h-ui/util" 10 | "strconv" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | var trafficMutex sync.Mutex 16 | var kickMutex sync.Mutex 17 | 18 | func CronHandleAccount() { 19 | go func() { 20 | hysteriaEnable, err := dao.GetConfig("key = ?", constant.Hysteria2Enable) 21 | if err != nil { 22 | return 23 | } 24 | if hysteriaEnable.Value != nil && *hysteriaEnable.Value == "1" { 25 | apiPort, err := GetHysteria2ApiPort() 26 | if err != nil { 27 | return 28 | } 29 | 30 | jwtSecretConfig, err := dao.GetConfig("key = ?", constant.JwtSecret) 31 | if err != nil { 32 | return 33 | } 34 | 35 | // 保存流量数据 36 | go saveAccountTraffic(apiPort, *jwtSecretConfig.Value) 37 | 38 | // 踢下线 39 | go kickAccount(apiPort, *jwtSecretConfig.Value) 40 | } 41 | }() 42 | } 43 | 44 | func CronResetTraffic() { 45 | accounts, err := dao.ListAccount(nil, nil) 46 | if err != nil { 47 | return 48 | } 49 | var ids []int64 50 | for _, item := range accounts { 51 | ids = append(ids, *item.Id) 52 | } 53 | idsList := util.SplitArr(ids, 100) 54 | for _, item := range idsList { 55 | if err := dao.UpdateAccount(item, map[string]interface{}{"download": 0, "upload": 0}); err != nil { 56 | continue 57 | } 58 | } 59 | } 60 | 61 | func saveAccountTraffic(apiPort int64, jwtSecret string) { 62 | if !trafficMutex.TryLock() { 63 | return 64 | } 65 | defer trafficMutex.Unlock() 66 | 67 | hysteria2TrafficTime, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficTime) 68 | if err != nil { 69 | return 70 | } 71 | hysteria2TrafficTimeFloat, err := strconv.ParseFloat(*hysteria2TrafficTime.Value, 64) 72 | if err != nil { 73 | logrus.Errorf("hysteria2TrafficTime string conv int64 err: %v", err) 74 | return 75 | } 76 | 77 | users, err := proxy.NewHysteria2Api(apiPort).ListUsers(true, jwtSecret) 78 | if err != nil { 79 | return 80 | } 81 | if len(users) > 0 { 82 | userLists := util.SplitMap(users, 10) 83 | var wg sync.WaitGroup 84 | for _, userList := range userLists { 85 | wg.Add(1) 86 | go func(userList map[string]bo.Hysteria2UserTraffic) { 87 | defer wg.Done() 88 | for username, traffic := range userList { 89 | if err = dao.UpdateAccountTraffic(username, int64(float64(traffic.Rx)*hysteria2TrafficTimeFloat), int64(float64(traffic.Tx)*hysteria2TrafficTimeFloat)); err != nil { 90 | continue 91 | } 92 | } 93 | }(userList) 94 | } 95 | wg.Wait() 96 | } 97 | } 98 | 99 | func kickAccount(apiPort int64, jwtSecret string) { 100 | if !kickMutex.TryLock() { 101 | return 102 | } 103 | defer kickMutex.Unlock() 104 | 105 | users, err := proxy.NewHysteria2Api(apiPort).OnlineUsers(jwtSecret) 106 | if err != nil { 107 | return 108 | } 109 | if len(users) > 0 { 110 | i := 0 111 | usernames := make([]string, len(users)) 112 | for k := range users { 113 | usernames[i] = k 114 | i++ 115 | } 116 | usernameLists := util.SplitArr(usernames, 10) 117 | var wg sync.WaitGroup 118 | for _, usernameList := range usernameLists { 119 | wg.Add(1) 120 | go func(usernameList []string) { 121 | defer wg.Done() 122 | now := time.Now().UnixMilli() 123 | accounts, err := dao.ListAccount("username in ? and (deleted = 1 or (quota > 0 and quota < download + upload)) or ? > expire_time or ? < kick_util_time", usernameList, now, now) 124 | if err != nil { 125 | return 126 | } 127 | kickUsernames := make([]string, len(accounts)) 128 | j := 0 129 | for _, item := range accounts { 130 | kickUsernames[j] = *item.Username 131 | j++ 132 | } 133 | if err = proxy.NewHysteria2Api(apiPort).KickUsers(kickUsernames, jwtSecret); err != nil { 134 | return 135 | } 136 | }(usernameList) 137 | } 138 | wg.Wait() 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /service/jwt.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "errors" 5 | "github.com/gin-gonic/gin" 6 | "github.com/golang-jwt/jwt" 7 | "h-ui/dao" 8 | "h-ui/model/bo" 9 | "h-ui/model/constant" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | const TokenExpireDuration = time.Hour * 24 15 | 16 | type MyClaims struct { 17 | AccountBo bo.AccountBo `json:"account"` 18 | jwt.StandardClaims 19 | } 20 | 21 | func GenToken(accountBo bo.AccountBo) (string, error) { 22 | c := MyClaims{ 23 | AccountBo: accountBo, 24 | StandardClaims: jwt.StandardClaims{ 25 | ExpiresAt: time.Now().Add(TokenExpireDuration).Unix(), 26 | Issuer: "h-ui", 27 | }, 28 | } 29 | config, err := dao.GetConfig("key = ?", constant.JwtSecret) 30 | if err != nil { 31 | return "", errors.New(constant.SysError) 32 | } 33 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, c) 34 | return token.SignedString([]byte(*config.Value)) 35 | } 36 | 37 | func ParseToken(tokenString string) (*MyClaims, error) { 38 | config, err := dao.GetConfig("key = ?", constant.JwtSecret) 39 | if err != nil { 40 | return nil, errors.New(constant.SysError) 41 | } 42 | token, err := jwt.ParseWithClaims(tokenString, &MyClaims{}, func(token *jwt.Token) (i interface{}, err error) { 43 | return []byte(*config.Value), nil 44 | }) 45 | if err != nil { 46 | return nil, errors.New(constant.IllegalTokenError) 47 | } 48 | if claims, ok := token.Claims.(*MyClaims); ok && token.Valid { 49 | return claims, nil 50 | } 51 | return nil, errors.New(constant.TokenExpiredError) 52 | } 53 | 54 | func GetToken(c *gin.Context) string { 55 | tokenStr := c.Request.Header.Get("Authorization") 56 | if tokenStr == "" { 57 | return "" 58 | } 59 | return strings.SplitN(tokenStr, " ", 2)[1] 60 | } 61 | -------------------------------------------------------------------------------- /service/monitor.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "h-ui/model/constant" 7 | "h-ui/model/vo" 8 | "h-ui/util" 9 | "regexp" 10 | "strings" 11 | ) 12 | 13 | func MonitorSystem() (vo.SystemMonitorVo, error) { 14 | cpuPercent, err := util.GetCpuPercent() 15 | if err != nil { 16 | return vo.SystemMonitorVo{}, errors.New("cpu query failed") 17 | } 18 | memPercent, err := util.GetMemPercent() 19 | if err != nil { 20 | return vo.SystemMonitorVo{}, errors.New("mem query failed") 21 | } 22 | diskPercent, err := util.GetDiskPercent() 23 | if err != nil { 24 | return vo.SystemMonitorVo{}, errors.New("disk query failed") 25 | } 26 | return vo.SystemMonitorVo{ 27 | HUIVersion: constant.Version, 28 | CpuPercent: cpuPercent, 29 | MemPercent: memPercent, 30 | DiskPercent: diskPercent, 31 | }, nil 32 | } 33 | 34 | func MonitorHysteria2() (vo.Hysteria2MonitorVo, error) { 35 | var hysteria2MonitorVo vo.Hysteria2MonitorVo 36 | onlineUsers, err := Hysteria2Online() 37 | if err != nil { 38 | return hysteria2MonitorVo, err 39 | } 40 | 41 | if len(onlineUsers) > 0 { 42 | hysteria2MonitorVo.UserTotal = int64(len(onlineUsers)) 43 | var deviceTotal int64 = 0 44 | for _, value := range onlineUsers { 45 | deviceTotal += value 46 | } 47 | hysteria2MonitorVo.DeviceTotal = deviceTotal 48 | } 49 | 50 | hysteria2MonitorVo.Version = "-" 51 | content, err := util.Exec(fmt.Sprintf("%s version", util.GetHysteria2BinPath())) 52 | if err == nil { 53 | pattern := `v\d+\.\d+\.\d+` 54 | re := regexp.MustCompile(pattern) 55 | matches := re.FindAllString(strings.TrimSpace(content), -1) 56 | if len(matches) > 0 { 57 | hysteria2MonitorVo.Version = matches[0] 58 | } 59 | } 60 | 61 | running := Hysteria2IsRunning() 62 | hysteria2MonitorVo.Running = running 63 | return hysteria2MonitorVo, nil 64 | } 65 | -------------------------------------------------------------------------------- /service/server.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/sirupsen/logrus" 8 | "h-ui/util" 9 | "net/http" 10 | "time" 11 | ) 12 | 13 | var server *http.Server 14 | 15 | func InitServer(addr string, handler http.Handler) { 16 | server = &http.Server{ 17 | Addr: addr, 18 | Handler: handler, 19 | } 20 | } 21 | 22 | func StartServer(crtPath string, keyPath string) error { 23 | if crtPath != "" && keyPath != "" { 24 | return server.ListenAndServeTLS(crtPath, keyPath) 25 | } 26 | return server.ListenAndServe() 27 | } 28 | 29 | func StopServer() error { 30 | if err := StopHysteria2(); err != nil { 31 | return err 32 | } 33 | 34 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 35 | defer cancel() 36 | if err := server.Shutdown(ctx); err != nil { 37 | logrus.Errorf("failed to shutdown server: %v", err) 38 | return errors.New("failed to shutdown server") 39 | } 40 | 41 | return nil 42 | } 43 | 44 | func GetServerPortAndCert() (int64, string, string, error) { 45 | port, crtPath, keyPath, err := GetPortAndCert() 46 | if err != nil { 47 | return 0, "", "", err 48 | } 49 | 50 | if !util.IsPortAvailable(uint(port), "tcp") { 51 | errMsg := fmt.Sprintf("port %d is taken", port) 52 | logrus.Errorf(errMsg) 53 | return 0, "", "", errors.New(errMsg) 54 | } 55 | 56 | if crtPath != "" && !util.Exists(crtPath) { 57 | errMsg := fmt.Sprintf("crt path: %s does not exist", crtPath) 58 | logrus.Errorf(errMsg) 59 | return 0, "", "", errors.New(errMsg) 60 | } 61 | 62 | if keyPath != "" && !util.Exists(keyPath) { 63 | errMsg := fmt.Sprintf("key path: %s does not exist", keyPath) 64 | logrus.Errorf(errMsg) 65 | return 0, "", "", errors.New(errMsg) 66 | } 67 | 68 | return port, crtPath, keyPath, nil 69 | } 70 | -------------------------------------------------------------------------------- /util/arr.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | func ArrContain[T comparable](arr []T, key T) bool { 4 | for _, item := range arr { 5 | if item == key { 6 | return true 7 | } 8 | } 9 | return false 10 | } 11 | 12 | func SplitArr[T any](arr []T, num int) [][]T { 13 | length := len(arr) 14 | if length <= num { 15 | return [][]T{arr} 16 | } 17 | 18 | quantity := (length + num - 1) / num 19 | segments := make([][]T, 0, quantity) 20 | 21 | for i := 0; i < quantity; i++ { 22 | end := (i + 1) * num 23 | if end > length { 24 | end = length 25 | } 26 | 27 | segment := arr[i*num : end] 28 | segments = append(segments, segment) 29 | } 30 | 31 | return segments 32 | } 33 | -------------------------------------------------------------------------------- /util/encrypt.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/sha256" 5 | "fmt" 6 | ) 7 | 8 | func SHA224String(password string) string { 9 | hash := sha256.New224() 10 | hash.Write([]byte(password)) 11 | val := hash.Sum(nil) 12 | str := "" 13 | for _, v := range val { 14 | str += fmt.Sprintf("%02x", v) 15 | } 16 | return str 17 | } 18 | -------------------------------------------------------------------------------- /util/encrypt_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "testing" 4 | 5 | func TestSHA224String(t *testing.T) { 6 | println(SHA224String("sysadmin")) 7 | } 8 | -------------------------------------------------------------------------------- /util/export.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "github.com/sirupsen/logrus" 7 | "gopkg.in/yaml.v3" 8 | "h-ui/model/constant" 9 | "os" 10 | ) 11 | 12 | // ExportFile t 0/json 1/yaml 13 | func ExportFile(filePath string, data any, t int) error { 14 | file, err := os.Create(filePath) 15 | if err != nil { 16 | logrus.Errorf("ExportFile create file err filePath: %s err: %v", filePath, err) 17 | return errors.New(constant.SysError) 18 | } 19 | defer file.Close() 20 | var bytes []byte 21 | if t == 0 { 22 | bytes, err = json.MarshalIndent(data, "", " ") 23 | if err != nil { 24 | logrus.Errorf("ExportFile Marshal json err filePath: %s err: %v", filePath, err) 25 | return errors.New(constant.SysError) 26 | } 27 | } else if t == 1 { 28 | bytes, err = yaml.Marshal(&data) 29 | if err != nil { 30 | logrus.Errorf("ExportFile Marshal yaml err filePath: %s err: %v", filePath, err) 31 | return errors.New(constant.SysError) 32 | } 33 | } 34 | _, err = file.Write(bytes) 35 | if err != nil { 36 | logrus.Errorf("ExportFile writer WriteString err filePath: %s err: %v", filePath, err) 37 | return errors.New(constant.SysError) 38 | } 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /util/file.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | func Exists(path string) bool { 12 | _, err := os.Stat(path) 13 | if err != nil { 14 | if os.IsExist(err) { 15 | return true 16 | } 17 | return false 18 | } 19 | return true 20 | } 21 | 22 | func RemoveFile(fileName string) error { 23 | if Exists(fileName) { 24 | if err := os.Remove(fileName); err != nil { 25 | return errors.New("failed to delete file") 26 | } 27 | } 28 | return nil 29 | } 30 | 31 | // ReadLinesFromBottom Read the file contents sequentially from bottom to top and return the specified number of lines 32 | func ReadLinesFromBottom(filePath string, numLines int) ([]string, int, error) { 33 | file, err := os.Open(filePath) 34 | if err != nil { 35 | return nil, 0, err 36 | } 37 | defer file.Close() 38 | 39 | var lines []string 40 | scanner := bufio.NewScanner(file) 41 | 42 | // Read the file contents line by line and reverse the order of the lines 43 | total := 0 44 | for scanner.Scan() { 45 | lines = append(lines, scanner.Text()) 46 | total++ 47 | } 48 | 49 | if err := scanner.Err(); err != nil { 50 | return nil, 0, err 51 | } 52 | 53 | // Reverse row order 54 | for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 { 55 | lines[i], lines[j] = lines[j], lines[i] 56 | } 57 | 58 | // Returns the specified number of rows 59 | if len(lines) < numLines { 60 | numLines = len(lines) 61 | } 62 | return lines[:numLines], total, nil 63 | } 64 | 65 | func FindFile(dir, filename string) (string, error) { 66 | var result string 67 | err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { 68 | if err != nil { 69 | return err 70 | } 71 | if !info.IsDir() && info.Name() == filename { 72 | absPath, err := filepath.Abs(path) 73 | if err != nil { 74 | return err 75 | } 76 | result = absPath 77 | return errors.New("file found") 78 | } 79 | return nil 80 | }) 81 | if err != nil && err.Error() != "file found" { 82 | return "", err 83 | } 84 | if result == "" { 85 | return "", fmt.Errorf("file %s not found in directory %s", filename, dir) 86 | } 87 | return result, nil 88 | } 89 | -------------------------------------------------------------------------------- /util/github.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/google/go-github/v39/github" 7 | ) 8 | 9 | var githubClient *github.Client 10 | 11 | func init() { 12 | githubClient = github.NewClient(nil) 13 | } 14 | 15 | func GetReleaseAssetURL(owner, repo, version, fileName string) (string, error) { 16 | ctx := context.Background() 17 | 18 | var release *github.RepositoryRelease 19 | var err error 20 | if version != "" { 21 | release, _, err = githubClient.Repositories.GetReleaseByTag(ctx, owner, repo, version) 22 | if err != nil { 23 | return "", fmt.Errorf("failed to get release for version %s: %v", version, err) 24 | } 25 | } else { 26 | releases, _, err := githubClient.Repositories.ListReleases(ctx, owner, repo, nil) 27 | if err != nil { 28 | return "", fmt.Errorf("failed to list releases: %v", err) 29 | } 30 | if len(releases) == 0 { 31 | return "", fmt.Errorf("no releases found") 32 | } 33 | release = releases[0] 34 | } 35 | 36 | assets, _, err := githubClient.Repositories.ListReleaseAssets(ctx, owner, repo, release.GetID(), nil) 37 | if err != nil { 38 | return "", fmt.Errorf("failed to list release assets: %v", err) 39 | } 40 | 41 | for _, asset := range assets { 42 | if asset.GetName() == fileName { 43 | return asset.GetBrowserDownloadURL(), nil 44 | } 45 | } 46 | 47 | return "", fmt.Errorf("file '%s' not found in release '%s'", fileName, release.GetTagName()) 48 | } 49 | 50 | func ListRelease(owner, repo string) ([]*github.RepositoryRelease, error) { 51 | ctx := context.Background() 52 | releases, _, err := githubClient.Repositories.ListReleases(ctx, owner, repo, nil) 53 | if err != nil { 54 | return nil, fmt.Errorf("failed to list releases: %v", err) 55 | } 56 | return releases, nil 57 | } 58 | -------------------------------------------------------------------------------- /util/hysteria2.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "h-ui/model/constant" 6 | "io" 7 | "net/http" 8 | "os" 9 | "runtime" 10 | ) 11 | 12 | func GetHysteria2BinPath() string { 13 | return constant.BinDir + GetHysteria2BinName() 14 | } 15 | 16 | func GetHysteria2BinName() string { 17 | hysteria2FileName := fmt.Sprintf("hysteria-%s-%s", runtime.GOOS, runtime.GOARCH) 18 | if runtime.GOOS == "windows" { 19 | hysteria2FileName += ".exe" 20 | } 21 | return hysteria2FileName 22 | } 23 | 24 | func DownloadHysteria2(version string) error { 25 | hysteria2BinName := GetHysteria2BinName() 26 | hysteria2BinPath := GetHysteria2BinPath() 27 | 28 | // Download the latest version of Hysteria2 29 | url, err := GetReleaseAssetURL("apernet", "hysteria", version, hysteria2BinName) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | resp, err := http.Get(url) 35 | defer resp.Body.Close() 36 | if err != nil { 37 | return fmt.Errorf("failed to download file: %v", err) 38 | } 39 | 40 | if resp.StatusCode != http.StatusOK { 41 | return fmt.Errorf("failed to download file, status code: %d", resp.StatusCode) 42 | } 43 | 44 | if Exists(hysteria2BinPath) { 45 | if err = os.Remove(hysteria2BinPath); err != nil { 46 | return fmt.Errorf("failed to remove existing file: %v", err) 47 | } 48 | } 49 | 50 | file, err := os.Create(hysteria2BinPath) 51 | defer file.Close() 52 | if err != nil { 53 | return fmt.Errorf("failed to create file %s: %v", hysteria2BinPath, err) 54 | } 55 | 56 | _, err = io.Copy(file, resp.Body) 57 | if err != nil { 58 | return fmt.Errorf("failed to write to file: %v", err) 59 | } 60 | 61 | if err = os.Chmod(hysteria2BinPath, 0755); err != nil { 62 | return fmt.Errorf("failed to change file permissions: %v", err) 63 | } 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /util/linux.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/shirou/gopsutil/cpu" 7 | "github.com/shirou/gopsutil/disk" 8 | "github.com/shirou/gopsutil/mem" 9 | "github.com/sirupsen/logrus" 10 | "net" 11 | "os" 12 | "os/exec" 13 | "strconv" 14 | "time" 15 | ) 16 | 17 | func Exec(cmd string) (string, error) { 18 | command := exec.Command("bash", "-c", cmd) 19 | command.Env = os.Environ() 20 | output, err := command.CombinedOutput() 21 | if err != nil { 22 | logrus.Errorf("execute command failed cmd: %s err: %v", cmd, err) 23 | return "", fmt.Errorf("execute command failed cmd: %s", cmd) 24 | } 25 | return string(output), nil 26 | } 27 | 28 | func IsPortAvailable(port uint, network string) bool { 29 | if network == "tcp" { 30 | listener, err := net.ListenTCP(network, &net.TCPAddr{ 31 | IP: net.IPv4(0, 0, 0, 0), 32 | Port: int(port), 33 | }) 34 | defer func() { 35 | if listener != nil { 36 | listener.Close() 37 | } 38 | }() 39 | if err != nil { 40 | logrus.Errorf("port %d is taken err: %s", port, err) 41 | return false 42 | } 43 | } 44 | if network == "udp" { 45 | listener, err := net.ListenUDP("udp", &net.UDPAddr{ 46 | IP: net.IPv4(0, 0, 0, 0), 47 | Port: int(port), 48 | }) 49 | defer func() { 50 | if listener != nil { 51 | listener.Close() 52 | } 53 | }() 54 | if err != nil { 55 | logrus.Errorf("port %d is taken err: %s", port, err) 56 | return false 57 | } 58 | } 59 | return true 60 | } 61 | 62 | func GetCpuPercent() (float64, error) { 63 | var err error 64 | percent, err := cpu.Percent(time.Second, false) 65 | value, err := strconv.ParseFloat(fmt.Sprintf("%.1f", percent[0]), 64) 66 | return value, err 67 | } 68 | 69 | func GetMemPercent() (float64, error) { 70 | var err error 71 | memInfo, err := mem.VirtualMemory() 72 | value, err := strconv.ParseFloat(fmt.Sprintf("%.1f", memInfo.UsedPercent), 64) 73 | return value, err 74 | } 75 | 76 | func GetDiskPercent() (float64, error) { 77 | var err error 78 | parts, err := disk.Partitions(true) 79 | diskInfo, err := disk.Usage(parts[0].Mountpoint) 80 | value, err := strconv.ParseFloat(fmt.Sprintf("%.1f", diskInfo.UsedPercent), 64) 81 | return value, err 82 | } 83 | 84 | func VerifyPort(port string) error { 85 | if port != "" { 86 | value, err := strconv.ParseInt(port, 10, 64) 87 | if err != nil { 88 | return errors.New("invalid port value") 89 | } 90 | if value <= 0 || value > 65535 { 91 | return errors.New("the port range is between 0-65535") 92 | } 93 | } 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /util/map.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | func SplitMap[T any](inputMap map[string]T, chunkSize int) []map[string]T { 4 | length := len(inputMap) 5 | quantity := (length + chunkSize - 1) / chunkSize 6 | segments := make([]map[string]T, 0, quantity) 7 | 8 | var groupIndex int 9 | currentGroup := make(map[string]T) 10 | 11 | for key, value := range inputMap { 12 | currentGroup[key] = value 13 | 14 | if len(currentGroup) == chunkSize || groupIndex+1 == quantity { 15 | // When the current group is full or the last group is reached, add the mapping to the result slice 16 | segments = append(segments, currentGroup) 17 | currentGroup = make(map[string]T) // Initialize a new mapping 18 | groupIndex++ 19 | } 20 | } 21 | 22 | return segments 23 | } 24 | -------------------------------------------------------------------------------- /util/rand.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "crypto/rand" 4 | 5 | const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 6 | 7 | func RandomString(length int) (string, error) { 8 | bytes := make([]byte, length) 9 | _, err := rand.Read(bytes) 10 | if err != nil { 11 | return "", err 12 | } 13 | 14 | for i := range bytes { 15 | bytes[i] = charset[int(bytes[i])%len(charset)] 16 | } 17 | 18 | return string(bytes), nil 19 | } 20 | -------------------------------------------------------------------------------- /util/string.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "strings" 4 | 5 | func CompareVersion(version1, version2 string) int { 6 | v1 := strings.Split(version1, ".") 7 | v2 := strings.Split(version2, ".") 8 | 9 | // Compare major version numbers 10 | if v1[0] > v2[0] { 11 | return 1 12 | } else if v1[0] < v2[0] { 13 | return -1 14 | } 15 | 16 | // If the major version numbers are the same, compare the minor version numbers 17 | if len(v1) > 1 && len(v2) > 1 { 18 | if v1[1] > v2[1] { 19 | return 1 20 | } else if v1[1] < v2[1] { 21 | return -1 22 | } 23 | } 24 | 25 | // If the major and minor versions are the same, compare the revision numbers 26 | if len(v1) > 2 && len(v2) > 2 { 27 | if v1[2] > v2[2] { 28 | return 1 29 | } else if v1[2] < v2[2] { 30 | return -1 31 | } 32 | } 33 | 34 | // The version number is exactly the same 35 | return 0 36 | } 37 | --------------------------------------------------------------------------------