refactor server command (#206)

This commit is contained in:
Sarv
2025-08-22 16:12:32 +08:00
committed by GitHub
parent 8f2830a9f1
commit cd8ddae8ee
27 changed files with 1234 additions and 690 deletions
+30 -18
View File
@@ -6,41 +6,53 @@ on:
- 'v*'
env:
ENABLE_UPX: 1
IMAGE_NAME: ${{ github.repository }}
jobs:
release:
name: Release Binary
name: Release Binary and Docker
runs-on: ubuntu-latest
container:
image: goreleaser/goreleaser-cross:v1.24
permissions:
contents: write
packages: write
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- run: git config --global --add safe.directory "$(pwd)"
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: '^1.24'
- name: Cache go module
uses: actions/cache@v3
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Fix git permissions
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Install UPX
uses: crazy-max/ghaction-upx@v3
with:
install-only: true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run GoReleaser
run: goreleaser release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ENABLE_UPX: true
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+148 -50
View File
@@ -6,61 +6,67 @@ before:
- go mod tidy
builds:
- id: darwin-amd64
- id: chatlog-builds
binary: chatlog
ldflags:
- -s -w -X github.com/sjzar/chatlog/pkg/version.Version={{.Version}}
env:
- CGO_ENABLED=1
- CC=o64-clang
- CXX=o64-clang++
goos:
- darwin
- windows
- linux
goarch:
- amd64
ldflags:
- -s -w -X github.com/sjzar/chatlog/pkg/version.Version={{.Version}}
- id: darwin-arm64
binary: chatlog
env:
- CGO_ENABLED=1
- CC=oa64-clang
- CXX=oa64-clang++
goos:
- darwin
goarch:
- arm64
ldflags:
- -s -w -X github.com/sjzar/chatlog/pkg/version.Version={{.Version}}
overrides:
- goos: darwin
goarch: amd64
env:
- CGO_ENABLED=1
- CC=o64-clang
- CXX=o64-clang++
- goos: darwin
goarch: arm64
env:
- CGO_ENABLED=1
- CC=oa64-clang
- CXX=oa64-clang++
- goos: windows
goarch: amd64
env:
- CGO_ENABLED=1
- CC=x86_64-w64-mingw32-gcc
- CXX=x86_64-w64-mingw32-g++
- goos: windows
goarch: arm64
env:
- CGO_ENABLED=1
- CC=/llvm-mingw/bin/aarch64-w64-mingw32-gcc
- CXX=/llvm-mingw/bin/aarch64-w64-mingw32-g++
- goos: linux
goarch: amd64
env:
- CGO_ENABLED=1
- CC=x86_64-linux-gnu-gcc
- CXX=x86_64-linux-gnu-g++
- goos: linux
goarch: arm64
env:
- CGO_ENABLED=1
- CC=aarch64-linux-gnu-gcc
- CXX=aarch64-linux-gnu-g++
- id: windows-amd64
binary: chatlog
env:
- CGO_ENABLED=1
- CC=x86_64-w64-mingw32-gcc
- CXX=x86_64-w64-mingw32-g++
goos:
- windows
goarch:
- amd64
ldflags:
- -s -w -X github.com/sjzar/chatlog/pkg/version.Version={{.Version}}
- id: windows-arm64
binary: chatlog
env:
- CGO_ENABLED=1
- CC=/llvm-mingw/bin/aarch64-w64-mingw32-gcc
- CXX=/llvm-mingw/bin/aarch64-w64-mingw32-g++
goos:
- windows
goarch:
- arm64
ldflags:
- -s -w -X github.com/sjzar/chatlog/pkg/version.Version={{.Version}}
upx:
- enabled: true
goos: [darwin, windows, linux]
goarch: [amd64, arm64]
compress: best
lzma: true
archives:
- id: default
format: tar.gz
formats: tar.gz
name_template: >-
{{ .ProjectName }}_
{{- .Version }}_
@@ -68,17 +74,11 @@ archives:
{{- .Arch }}
format_overrides:
- goos: windows
format: zip
formats: zip
files:
- LICENSE
- README.md
upx:
- enabled: "{{ .Env.ENABLE_UPX }}"
goos: [darwin, windows]
goarch: [amd64]
compress: best
checksum:
name_template: 'checksums.txt'
algorithm: sha256
@@ -88,3 +88,101 @@ release:
draft: true
prerelease: auto
mode: replace
# Docker 镜像配置
dockers:
# GitHub Container Registry
- image_templates:
- "ghcr.io/sjzar/chatlog:{{ .Tag }}-amd64"
- "ghcr.io/sjzar/chatlog:latest-amd64"
dockerfile: Dockerfile
use: buildx
goos: linux
goarch: amd64
build_flag_templates:
- "--platform=linux/amd64"
- "--label=org.opencontainers.image.title={{.ProjectName}}"
- "--label=org.opencontainers.image.description=chat log tool, easily use your own chat data."
- "--label=org.opencontainers.image.url=https://github.com/sjzar/chatlog"
- "--label=org.opencontainers.image.source=https://github.com/sjzar/chatlog"
- "--label=org.opencontainers.image.version={{.Version}}"
- "--label=org.opencontainers.image.created={{.Date}}"
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.licenses=Apache-2.0"
- image_templates:
- "ghcr.io/sjzar/chatlog:{{ .Tag }}-arm64"
- "ghcr.io/sjzar/chatlog:latest-arm64"
dockerfile: Dockerfile
use: buildx
goos: linux
goarch: arm64
build_flag_templates:
- "--platform=linux/arm64"
- "--label=org.opencontainers.image.title={{.ProjectName}}"
- "--label=org.opencontainers.image.description=chat log tool, easily use your own chat data."
- "--label=org.opencontainers.image.url=https://github.com/sjzar/chatlog"
- "--label=org.opencontainers.image.source=https://github.com/sjzar/chatlog"
- "--label=org.opencontainers.image.version={{.Version}}"
- "--label=org.opencontainers.image.created={{.Date}}"
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.licenses=Apache-2.0"
# Docker Hub
- image_templates:
- "sjzar/chatlog:{{ .Tag }}-amd64"
- "sjzar/chatlog:latest-amd64"
dockerfile: Dockerfile
use: buildx
goos: linux
goarch: amd64
build_flag_templates:
- "--platform=linux/amd64"
- "--label=org.opencontainers.image.title={{.ProjectName}}"
- "--label=org.opencontainers.image.description=chat log tool, easily use your own chat data."
- "--label=org.opencontainers.image.url=https://github.com/sjzar/chatlog"
- "--label=org.opencontainers.image.source=https://github.com/sjzar/chatlog"
- "--label=org.opencontainers.image.version={{.Version}}"
- "--label=org.opencontainers.image.created={{.Date}}"
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.licenses=Apache-2.0"
- image_templates:
- "sjzar/chatlog:{{ .Tag }}-arm64"
- "sjzar/chatlog:latest-arm64"
dockerfile: Dockerfile
use: buildx
goos: linux
goarch: arm64
build_flag_templates:
- "--platform=linux/arm64"
- "--label=org.opencontainers.image.title={{.ProjectName}}"
- "--label=org.opencontainers.image.description=chat log tool, easily use your own chat data."
- "--label=org.opencontainers.image.url=https://github.com/sjzar/chatlog"
- "--label=org.opencontainers.image.source=https://github.com/sjzar/chatlog"
- "--label=org.opencontainers.image.version={{.Version}}"
- "--label=org.opencontainers.image.created={{.Date}}"
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.licenses=Apache-2.0"
# Docker manifest 配置用于多架构镜像
docker_manifests:
# GitHub Container Registry manifests
- name_template: "ghcr.io/sjzar/chatlog:{{ .Tag }}"
image_templates:
- "ghcr.io/sjzar/chatlog:{{ .Tag }}-amd64"
- "ghcr.io/sjzar/chatlog:{{ .Tag }}-arm64"
- name_template: "ghcr.io/sjzar/chatlog:latest"
image_templates:
- "ghcr.io/sjzar/chatlog:latest-amd64"
- "ghcr.io/sjzar/chatlog:latest-arm64"
# Docker Hub manifests
- name_template: "sjzar/chatlog:{{ .Tag }}"
image_templates:
- "sjzar/chatlog:{{ .Tag }}-amd64"
- "sjzar/chatlog:{{ .Tag }}-arm64"
- name_template: "sjzar/chatlog:latest"
image_templates:
- "sjzar/chatlog:latest-amd64"
- "sjzar/chatlog:latest-arm64"
+34
View File
@@ -0,0 +1,34 @@
FROM debian:12-slim
LABEL maintainer="Sarv <https://github.com/sjzar>"
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates tzdata curl && \
rm -rf /var/lib/apt/lists/*
RUN groupadd -r -g 1001 chatlog && \
useradd -r -u 1001 -g chatlog -m -d /home/chatlog chatlog && \
mkdir -p /app/data /app/work && \
chown -R chatlog:chatlog /app
USER chatlog
WORKDIR /app
COPY --from=mwader/static-ffmpeg:7.1.1 --chown=chatlog:chatlog /ffmpeg /usr/local/bin/
COPY --chown=chatlog:chatlog chatlog /usr/local/bin/chatlog
EXPOSE 5030
ENV CHATLOG_DATA_DIR=/app/data \
CHATLOG_WORK_DIR=/app/work \
CHATLOG_HTTP_ADDR=0.0.0.0:5030 \
PATH="/usr/local/bin:${PATH}"
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:5030/health || exit 1
CMD ["chatlog", "server"]
+4 -1
View File
@@ -8,12 +8,15 @@ LDFLAGS := -ldflags '-X "github.com/sjzar/chatlog/pkg/version.Version=$(VERSION)
PLATFORMS := \
darwin/amd64 \
darwin/arm64 \
linux/amd64 \
linux/arm64 \
windows/amd64 \
windows/arm64
UPX_PLATFORMS := \
darwin/amd64 \
windows/386 \
linux/amd64 \
linux/arm64 \
windows/amd64
.PHONY: all clean lint tidy test build crossbuild upx
+33 -15
View File
@@ -2,7 +2,6 @@ package chatlog
import (
"fmt"
"runtime"
"github.com/sjzar/chatlog/internal/chatlog"
@@ -12,34 +11,53 @@ import (
func init() {
rootCmd.AddCommand(decryptCmd)
decryptCmd.Flags().StringVarP(&dataDir, "data-dir", "d", "", "data dir")
decryptCmd.Flags().StringVarP(&workDir, "work-dir", "w", "", "work dir")
decryptCmd.Flags().StringVarP(&key, "key", "k", "", "key")
decryptCmd.Flags().StringVarP(&decryptPlatform, "platform", "p", runtime.GOOS, "platform")
decryptCmd.Flags().IntVarP(&decryptVer, "version", "v", 3, "version")
decryptCmd.Flags().StringVarP(&decryptPlatform, "platform", "p", "", "platform")
decryptCmd.Flags().IntVarP(&decryptVer, "version", "v", 0, "version")
decryptCmd.Flags().StringVarP(&decryptDataDir, "data-dir", "d", "", "data dir")
decryptCmd.Flags().StringVarP(&decryptDatakey, "data-key", "k", "", "data key")
decryptCmd.Flags().StringVarP(&decryptWorkDir, "work-dir", "w", "", "work dir")
}
var (
dataDir string
workDir string
key string
decryptPlatform string
decryptVer int
decryptDataDir string
decryptDatakey string
decryptWorkDir string
)
var decryptCmd = &cobra.Command{
Use: "decrypt",
Short: "decrypt",
Run: func(cmd *cobra.Command, args []string) {
m, err := chatlog.New("")
if err != nil {
log.Err(err).Msg("failed to create chatlog instance")
return
}
if err := m.CommandDecrypt(dataDir, workDir, key, decryptPlatform, decryptVer); err != nil {
cmdConf := getDecryptConfig()
m := chatlog.New()
if err := m.CommandDecrypt("", cmdConf); err != nil {
log.Err(err).Msg("failed to decrypt")
return
}
fmt.Println("decrypt success")
},
}
func getDecryptConfig() map[string]any {
cmdConf := make(map[string]any)
if len(decryptDataDir) != 0 {
cmdConf["data_dir"] = decryptDataDir
}
if len(decryptDatakey) != 0 {
cmdConf["data_key"] = serverDataKey
}
if len(decryptWorkDir) != 0 {
cmdConf["work_dir"] = decryptWorkDir
}
if len(decryptPlatform) != 0 {
cmdConf["platform"] = decryptPlatform
}
if decryptVer != 0 {
cmdConf["version"] = decryptVer
}
return cmdConf
}
+10 -8
View File
@@ -11,20 +11,22 @@ import (
func init() {
rootCmd.AddCommand(keyCmd)
keyCmd.Flags().IntVarP(&pid, "pid", "p", 0, "pid")
keyCmd.Flags().IntVarP(&keyPID, "pid", "p", 0, "pid")
keyCmd.Flags().BoolVarP(&keyForce, "force", "f", false, "force")
keyCmd.Flags().BoolVarP(&keyShowXorKey, "xor-key", "x", false, "show xor key")
}
var pid int
var (
keyPID int
keyForce bool
keyShowXorKey bool
)
var keyCmd = &cobra.Command{
Use: "key",
Short: "key",
Run: func(cmd *cobra.Command, args []string) {
m, err := chatlog.New("")
if err != nil {
log.Err(err).Msg("failed to create chatlog instance")
return
}
ret, err := m.CommandKey(pid)
m := chatlog.New()
ret, err := m.CommandKey("", keyPID, keyForce, keyShowXorKey)
if err != nil {
log.Err(err).Msg("failed to get key")
return
+51 -18
View File
@@ -1,43 +1,76 @@
package chatlog
import (
"runtime"
"github.com/sjzar/chatlog/internal/chatlog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/sjzar/chatlog/internal/chatlog"
)
func init() {
rootCmd.AddCommand(serverCmd)
serverCmd.Flags().StringVarP(&serverAddr, "addr", "a", "127.0.0.1:5030", "server address")
serverCmd.Flags().StringVarP(&serverAddr, "addr", "a", "", "server address")
serverCmd.Flags().StringVarP(&serverPlatform, "platform", "p", "", "platform")
serverCmd.Flags().IntVarP(&serverVer, "version", "v", 0, "version")
serverCmd.Flags().StringVarP(&serverDataDir, "data-dir", "d", "", "data dir")
serverCmd.Flags().StringVarP(&serverDataKey, "data-key", "k", "", "data key")
serverCmd.Flags().StringVarP(&serverImgKey, "img-key", "i", "", "img key")
serverCmd.Flags().StringVarP(&serverWorkDir, "work-dir", "w", "", "work dir")
serverCmd.Flags().StringVarP(&serverPlatform, "platform", "p", runtime.GOOS, "platform")
serverCmd.Flags().IntVarP(&serverVer, "version", "v", 3, "version")
serverCmd.Flags().BoolVarP(&serverAutoDecrypt, "auto-decrypt", "", false, "auto decrypt")
}
var (
serverAddr string
serverDataDir string
serverWorkDir string
serverPlatform string
serverVer int
serverAddr string
serverDataDir string
serverDataKey string
serverImgKey string
serverWorkDir string
serverPlatform string
serverVer int
serverAutoDecrypt bool
)
var serverCmd = &cobra.Command{
Use: "server",
Short: "Start HTTP server",
Run: func(cmd *cobra.Command, args []string) {
m, err := chatlog.New("")
if err != nil {
log.Err(err).Msg("failed to create chatlog instance")
return
}
if err := m.CommandHTTPServer(serverAddr, serverDataDir, serverWorkDir, serverPlatform, serverVer); err != nil {
cmdConf := getServerConfig()
log.Info().Msgf("server cmd config: %+v", cmdConf)
m := chatlog.New()
if err := m.CommandHTTPServer("", cmdConf); err != nil {
log.Err(err).Msg("failed to start server")
return
}
},
}
func getServerConfig() map[string]any {
cmdConf := make(map[string]any)
if len(serverAddr) != 0 {
cmdConf["http_addr"] = serverAddr
}
if len(serverDataDir) != 0 {
cmdConf["data_dir"] = serverDataDir
}
if len(serverDataKey) != 0 {
cmdConf["data_key"] = serverDataKey
}
if len(serverImgKey) != 0 {
cmdConf["img_key"] = serverImgKey
}
if len(serverWorkDir) != 0 {
cmdConf["work_dir"] = serverWorkDir
}
if len(serverPlatform) != 0 {
cmdConf["platform"] = serverPlatform
}
if serverVer != 0 {
cmdConf["version"] = serverVer
}
if serverAutoDecrypt {
cmdConf["auto_decrypt"] = true
}
return cmdConf
}
+2 -8
View File
@@ -35,14 +35,8 @@ var rootCmd = &cobra.Command{
}
func Root(cmd *cobra.Command, args []string) {
m, err := chatlog.New("")
if err != nil {
log.Err(err).Msg("failed to create chatlog instance")
return
}
if err := m.Run(); err != nil {
m := chatlog.New()
if err := m.Run(""); err != nil {
log.Err(err).Msg("failed to run chatlog instance")
}
}
+39
View File
@@ -0,0 +1,39 @@
version: '3.8'
services:
chatlog:
image: sjzar/chatlog:latest
restart: unless-stopped
ports:
# 将主机的 5030 端口映射到 chatlog 容器的 5030 端口
# 你可以根据需要修改主机端口,例如 "8080:5030"
- "5030:5030"
environment:
- TZ=Asia/Shanghai
# # 微信平台类型,可选:windows, darwin
# - CHATLOG_PLATFORM=${CHATLOG_PLATFORM}
# # 微信版本,可选:3, 4
# - CHATLOG_VERSION=${CHATLOG_VERSION}
# # 微信数据密钥
# - CHATLOG_DATA_KEY=${CHATLOG_DATA_KEY}
# # 微信图片密钥
# - CHATLOG_IMG_KEY=${CHATLOG_IMG_KEY}
# # 服务地址
# - CHATLOG_HTTP_ADDR=${CHATLOG_HTTP_ADDR}
# # 是否自动解密
# - CHATLOG_AUTO_DECRYPT=${CHATLOG_AUTO_DECRYPT}
# 数据目录
- CHATLOG_DATA_DIR=/app/data
# 工作目录
- CHATLOG_WORK_DIR=/app/work
volumes:
# 数据目录,微信的本地数据目录
# 请修改左侧的 /path/to/your/wechat/data 为实际的微信数据目录
# 例如 "/Users/sarv/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/wxid_xxx:/app/data"
- "/path/to/your/wechat/data:/app/data"
# 工作目录,用于保存已解密的数据库文件
- "work-dir:/app/work"
volumes:
work-dir:
driver: local
+1 -1
View File
@@ -10,6 +10,7 @@ require (
github.com/google/uuid v1.6.0
github.com/klauspost/compress v1.18.0
github.com/mattn/go-sqlite3 v1.14.32
github.com/mitchellh/mapstructure v1.5.0
github.com/pierrec/lz4/v4 v4.1.22
github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb
github.com/rs/zerolog v1.34.0
@@ -64,7 +65,6 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/term v0.34.0 // indirect
+2 -56
View File
@@ -1,34 +1,22 @@
github.com/Eyevinn/mp4ff v0.49.0 h1:00eRg5/KwcLGWUbv+hlifldf74qg46G1IxoWXHrgDvw=
github.com/Eyevinn/mp4ff v0.49.0/go.mod h1:hJNUUqOBryLAzUW9wpCJyw2HaI+TCd2rUPhafoS5lgg=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
@@ -37,8 +25,6 @@ github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997N
github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
@@ -50,14 +36,10 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
@@ -76,12 +58,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -101,10 +79,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU=
github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -119,8 +97,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rivo/tview v0.0.0-20250330220935-949945f8d922 h1:SMyqkaRfpE8ZQUSRTZKO3uN84xov++OGa+e3NCksaQw=
github.com/rivo/tview v0.0.0-20250330220935-949945f8d922/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss=
github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb h1:n7UJ8X9UnrTZBYXnd1kAIBc067SWyuPIrsocjketYW8=
github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -133,35 +109,24 @@ github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc=
github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw=
github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
github.com/shirou/gopsutil/v4 v4.25.7 h1:bNb2JuqKuAu3tRlPv5piSmBZyMfecwQ+t/ILq+1JqVM=
github.com/shirou/gopsutil/v4 v4.25.7/go.mod h1:XV/egmwJtd3ZQjBpJVY5kndsiOO4IRqy9TQnmm6VP7U=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sjzar/go-lame v0.0.8 h1:AS9l32R6foMiMEXWfUY8i79WIMfDoBC2QqQ9s5yziIk=
github.com/sjzar/go-lame v0.0.8/go.mod h1:8RmqWcAKSbBAk6bTRV9d8mdDxqK3hY9vFyoJ4DoQE6Y=
github.com/sjzar/go-lame v0.0.9 h1:x/X3I+uKVsx8WeKO5cu4fvkAbm2okOaP1ibeeI3urAU=
github.com/sjzar/go-lame v0.0.9/go.mod h1:8RmqWcAKSbBAk6bTRV9d8mdDxqK3hY9vFyoJ4DoQE6Y=
github.com/sjzar/go-silk v0.0.1 h1:cXD9dsIZti3n+g0Fd3IUvLH9A7tyL4jvUsHEyhff21s=
github.com/sjzar/go-silk v0.0.1/go.mod h1:IXVcHEXKiU9j3ZtHEiGS37OFKkex9pdAhZVcFzAIOlM=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -185,17 +150,11 @@ github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfj
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -203,8 +162,6 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -220,8 +177,6 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -248,8 +203,6 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
@@ -261,8 +214,6 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -274,8 +225,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -285,8 +234,6 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -298,4 +245,3 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+98
View File
@@ -0,0 +1,98 @@
package conf
import (
"encoding/json"
"os"
"path/filepath"
"github.com/rs/zerolog/log"
"github.com/sjzar/chatlog/pkg/config"
)
const (
AppName = "chatlog"
ServerConfigName = "chatlog-server"
EnvPrefix = "CHATLOG"
EnvConfigDir = "CHATLOG_DIR"
)
// LoadTUIConfig 加载 TUI 配置
func LoadTUIConfig(configPath string) (*TUIConfig, *config.Manager, error) {
if configPath == "" {
configPath = os.Getenv(EnvConfigDir)
}
tcm, err := config.New(AppName, configPath, "", "", true)
if err != nil {
log.Error().Err(err).Msg("load tui config failed")
return nil, nil, err
}
conf := &TUIConfig{}
config.SetDefaults(tcm.Viper, conf, TUIDefaults)
if err := tcm.Load(conf); err != nil {
log.Error().Err(err).Msg("load tui config failed")
return nil, nil, err
}
conf.ConfigDir = tcm.Path
return conf, tcm, nil
}
// LoadServiceConfig 加载服务配置
func LoadServiceConfig(configPath string, cmdConf map[string]any) (*ServerConfig, *config.Manager, error) {
if configPath == "" {
configPath = os.Getenv(EnvConfigDir)
}
scm, err := config.New(AppName, configPath, ServerConfigName, EnvPrefix, false)
if err != nil {
log.Error().Err(err).Msg("load server config failed")
return nil, nil, err
}
conf := &ServerConfig{}
config.SetDefaults(scm.Viper, conf, ServerDefaults)
// Load cmd Conf
for key, value := range cmdConf {
scm.SetConfig(key, value)
}
if err := scm.Load(conf); err != nil {
log.Error().Err(err).Msg("load server config failed")
return nil, nil, err
}
// Load Data Dir config
if len(conf.DataDir) != 0 && len(conf.DataKey) == 0 {
if b, err := os.ReadFile(filepath.Join(conf.DataDir, "chatlog.json")); err == nil {
var pconf map[string]any
if err := json.Unmarshal(b, &pconf); err == nil {
for key, value := range pconf {
if !DataDirConfigs[key] {
continue
}
scm.SetConfig(key, value)
}
}
}
if err := scm.Load(conf); err != nil {
log.Error().Err(err).Msg("reload server config failed")
return nil, nil, err
}
}
return conf, scm, nil
}
var DataDirConfigs = map[string]bool{
"type": true,
"platform": true,
"version": true,
"full_version": true,
"data_key": true,
"img_key": true,
}
+55
View File
@@ -0,0 +1,55 @@
package conf
const (
DefalutHTTPAddr = "0.0.0.0:5030"
)
type ServerConfig struct {
Type string `mapstructure:"type"`
Platform string `mapstructure:"platform"`
Version int `mapstructure:"version"`
FullVersion string `mapstructure:"full_version"`
DataDir string `mapstructure:"data_dir"`
DataKey string `mapstructure:"data_key"`
ImgKey string `mapstructure:"img_key"`
WorkDir string `mapstructure:"work_dir"`
HTTPAddr string `mapstructure:"http_addr"`
AutoDecrypt bool `mapstructure:"auto_decrypt"`
}
var ServerDefaults = map[string]any{}
func (c *ServerConfig) GetDataDir() string {
return c.DataDir
}
func (c *ServerConfig) GetWorkDir() string {
return c.WorkDir
}
func (c *ServerConfig) GetPlatform() string {
return c.Platform
}
func (c *ServerConfig) GetVersion() int {
return c.Version
}
func (c *ServerConfig) GetDataKey() string {
return c.DataKey
}
func (c *ServerConfig) GetImgKey() string {
return c.ImgKey
}
func (c *ServerConfig) GetAutoDecrypt() bool {
return c.AutoDecrypt
}
func (c *ServerConfig) GetHTTPAddr() string {
if c.HTTPAddr == "" {
c.HTTPAddr = DefalutHTTPAddr
}
return c.HTTPAddr
}
-68
View File
@@ -1,68 +0,0 @@
package conf
import (
"log"
"os"
"sync"
"github.com/sjzar/chatlog/pkg/config"
)
const (
ConfigName = "chatlog"
ConfigType = "json"
EnvConfigDir = "CHATLOG_DIR"
)
// Service 配置服务
type Service struct {
configPath string
config *Config
mu sync.RWMutex
}
// NewService 创建配置服务
func NewService(configPath string) (*Service, error) {
service := &Service{
configPath: configPath,
}
if err := service.Load(); err != nil {
return nil, err
}
return service, nil
}
// Load 加载配置
func (s *Service) Load() error {
s.mu.Lock()
defer s.mu.Unlock()
configPath := s.configPath
if configPath == "" {
configPath = os.Getenv(EnvConfigDir)
}
if err := config.Init(ConfigName, ConfigType, configPath); err != nil {
log.Fatal(err)
}
conf := &Config{}
if err := config.Load(conf); err != nil {
log.Fatal(err)
}
conf.ConfigDir = config.ConfigPath
s.config = conf
return nil
}
// GetConfig 获取配置副本
func (s *Service) GetConfig() *Config {
s.mu.RLock()
defer s.mu.RUnlock()
// 返回配置副本
configCopy := *s.config
return &configCopy
}
@@ -1,13 +1,13 @@
package conf
import "github.com/sjzar/chatlog/pkg/config"
type Config struct {
type TUIConfig struct {
ConfigDir string `mapstructure:"-"`
LastAccount string `mapstructure:"last_account" json:"last_account"`
History []ProcessConfig `mapstructure:"history" json:"history"`
}
var TUIDefaults = map[string]any{}
type ProcessConfig struct {
Type string `mapstructure:"type" json:"type"`
Account string `mapstructure:"account" json:"account"`
@@ -30,33 +30,10 @@ type File struct {
Size int64 `mapstructure:"size" json:"size"`
}
func (c *Config) ParseHistory() map[string]ProcessConfig {
func (c *TUIConfig) ParseHistory() map[string]ProcessConfig {
m := make(map[string]ProcessConfig)
for _, v := range c.History {
m[v.Account] = v
}
return m
}
func (c *Config) UpdateHistory(account string, conf ProcessConfig) error {
if c.History == nil {
c.History = make([]ProcessConfig, 0)
}
if len(c.History) == 0 {
c.History = append(c.History, conf)
} else {
isFind := false
for i, v := range c.History {
if v.Account == account {
isFind = true
c.History[i] = conf
break
}
}
if !isFind {
c.History = append(c.History, conf)
}
}
config.SetConfig("last_account", account)
return config.SetConfig("history", c.History)
}
+102 -8
View File
@@ -1,18 +1,28 @@
package ctx
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"github.com/rs/zerolog/log"
"github.com/sjzar/chatlog/internal/chatlog/conf"
"github.com/sjzar/chatlog/internal/wechat"
"github.com/sjzar/chatlog/pkg/config"
"github.com/sjzar/chatlog/pkg/util"
)
const (
DefalutHTTPAddr = "127.0.0.1:5030"
)
// Context is a context for a chatlog.
// It is used to store information about the chatlog.
type Context struct {
conf *conf.Service
conf *conf.TUIConfig
cm *config.Manager
mu sync.RWMutex
History map[string]conf.ProcessConfig
@@ -49,20 +59,26 @@ type Context struct {
WeChatInstances []*wechat.Account
}
func New(conf *conf.Service) *Context {
func New(configPath string) (*Context, error) {
conf, tcm, err := conf.LoadTUIConfig(configPath)
if err != nil {
return nil, err
}
ctx := &Context{
conf: conf,
cm: tcm,
}
ctx.loadConfig()
return ctx
return ctx, nil
}
func (c *Context) loadConfig() {
conf := c.conf.GetConfig()
c.History = conf.ParseHistory()
c.SwitchHistory(conf.LastAccount)
c.History = c.conf.ParseHistory()
c.SwitchHistory(c.conf.LastAccount)
c.Refresh()
}
@@ -138,9 +154,39 @@ func (c *Context) Refresh() {
}
}
func (c *Context) GetDataDir() string {
return c.DataDir
}
func (c *Context) GetWorkDir() string {
return c.WorkDir
}
func (c *Context) GetPlatform() string {
return c.Platform
}
func (c *Context) GetVersion() int {
return c.Version
}
func (c *Context) GetDataKey() string {
return c.DataKey
}
func (c *Context) GetHTTPAddr() string {
if c.HTTPAddr == "" {
c.HTTPAddr = DefalutHTTPAddr
}
return c.HTTPAddr
}
func (c *Context) SetHTTPEnabled(enabled bool) {
c.mu.Lock()
defer c.mu.Unlock()
if c.HTTPEnabled == enabled {
return
}
c.HTTPEnabled = enabled
c.UpdateConfig()
}
@@ -148,6 +194,9 @@ func (c *Context) SetHTTPEnabled(enabled bool) {
func (c *Context) SetHTTPAddr(addr string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.HTTPAddr == addr {
return
}
c.HTTPAddr = addr
c.UpdateConfig()
}
@@ -155,6 +204,9 @@ func (c *Context) SetHTTPAddr(addr string) {
func (c *Context) SetWorkDir(dir string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.WorkDir == dir {
return
}
c.WorkDir = dir
c.UpdateConfig()
c.Refresh()
@@ -163,6 +215,9 @@ func (c *Context) SetWorkDir(dir string) {
func (c *Context) SetDataDir(dir string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.DataDir == dir {
return
}
c.DataDir = dir
c.UpdateConfig()
c.Refresh()
@@ -171,12 +226,16 @@ func (c *Context) SetDataDir(dir string) {
func (c *Context) SetAutoDecrypt(enabled bool) {
c.mu.Lock()
defer c.mu.Unlock()
if c.AutoDecrypt == enabled {
return
}
c.AutoDecrypt = enabled
c.UpdateConfig()
}
// 更新配置
func (c *Context) UpdateConfig() {
pconf := conf.ProcessConfig{
Type: "wechat",
Account: c.Account,
@@ -190,6 +249,41 @@ func (c *Context) UpdateConfig() {
HTTPEnabled: c.HTTPEnabled,
HTTPAddr: c.HTTPAddr,
}
conf := c.conf.GetConfig()
conf.UpdateHistory(c.Account, pconf)
if c.conf.History == nil {
c.conf.History = make([]conf.ProcessConfig, 0)
}
if len(c.conf.History) == 0 {
c.conf.History = append(c.conf.History, pconf)
} else {
isFind := false
for i, v := range c.conf.History {
if v.Account == c.Account {
isFind = true
c.conf.History[i] = pconf
break
}
}
if !isFind {
c.conf.History = append(c.conf.History, pconf)
}
}
if err := c.cm.SetConfig("last_account", c.Account); err != nil {
log.Error().Err(err).Msg("set last_account failed")
return
}
if err := c.cm.SetConfig("history", c.conf.History); err != nil {
log.Error().Err(err).Msg("set history failed")
return
}
if len(pconf.DataDir) != 0 {
if b, err := json.Marshal(pconf); err == nil {
if err := os.WriteFile(filepath.Join(pconf.DataDir, "chatlog.json"), b, 0644); err != nil {
log.Error().Err(err).Msg("save chatlog.json failed")
}
}
}
}
+39 -6
View File
@@ -3,27 +3,42 @@ package database
import (
"time"
"github.com/sjzar/chatlog/internal/chatlog/ctx"
"github.com/sjzar/chatlog/internal/model"
"github.com/sjzar/chatlog/internal/wechatdb"
)
const (
StateInit = iota
StateDecrypting
StateReady
StateError
)
type Service struct {
ctx *ctx.Context
db *wechatdb.DB
State int
StateMsg string
conf Config
db *wechatdb.DB
}
func NewService(ctx *ctx.Context) *Service {
type Config interface {
GetWorkDir() string
GetPlatform() string
GetVersion() int
}
func NewService(conf Config) *Service {
return &Service{
ctx: ctx,
conf: conf,
}
}
func (s *Service) Start() error {
db, err := wechatdb.New(s.ctx.WorkDir, s.ctx.Platform, s.ctx.Version)
db, err := wechatdb.New(s.conf.GetWorkDir(), s.conf.GetPlatform(), s.conf.GetVersion())
if err != nil {
return err
}
s.SetReady()
s.db = db
return nil
}
@@ -32,10 +47,28 @@ func (s *Service) Stop() error {
if s.db != nil {
s.db.Close()
}
s.SetInit()
s.db = nil
return nil
}
func (s *Service) SetInit() {
s.State = StateInit
}
func (s *Service) SetDecrypting() {
s.State = StateDecrypting
}
func (s *Service) SetReady() {
s.State = StateReady
}
func (s *Service) SetError(msg string) {
s.State = StateError
s.StateMsg = msg
}
func (s *Service) GetDB() *wechatdb.DB {
return s.db
}
+45
View File
@@ -0,0 +1,45 @@
package http
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/sjzar/chatlog/internal/chatlog/database"
)
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Accept, Authorization, Content-Type, X-CSRF-Token")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
func (s *Service) checkDBStateMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
switch s.db.State {
case database.StateInit:
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database is not ready"})
c.Abort()
return
case database.StateDecrypting:
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database is decrypting, please wait"})
c.Abort()
return
case database.StateError:
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database is error: " + s.db.StateMsg})
c.Abort()
return
}
c.Next()
}
}
+6 -3
View File
@@ -30,6 +30,9 @@ func (s *Service) initRouter() {
router.StaticFS("/static", http.FS(staticDir))
router.StaticFileFS("/favicon.ico", "./favicon.ico", http.FS(staticDir))
router.StaticFileFS("/", "./index.htm", http.FS(staticDir))
router.GET("/health", func(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"status": "ok"})
})
// Media
router.GET("/image/*key", s.GetImage)
@@ -48,7 +51,7 @@ func (s *Service) initRouter() {
}
// API V1 Router
api := router.Group("/api/v1")
api := router.Group("/api/v1", s.checkDBStateMiddleware())
{
api.GET("/chatlog", s.GetChatlog)
api.GET("/contact", s.GetContacts)
@@ -296,7 +299,7 @@ func (s *Service) GetMedia(c *gin.Context, _type string) {
var _err error
for _, k := range keys {
if len(k) != 32 {
absolutePath := filepath.Join(s.ctx.DataDir, k)
absolutePath := filepath.Join(s.conf.GetDataDir(), k)
if _, err := os.Stat(absolutePath); os.IsNotExist(err) {
continue
}
@@ -331,7 +334,7 @@ func (s *Service) GetMedia(c *gin.Context, _type string) {
func (s *Service) GetMediaData(c *gin.Context) {
relativePath := filepath.Clean(c.Param("path"))
absolutePath := filepath.Join(s.ctx.DataDir, relativePath)
absolutePath := filepath.Join(s.conf.GetDataDir(), relativePath)
if _, err := os.Stat(absolutePath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{
+16 -23
View File
@@ -5,7 +5,6 @@ import (
"net/http"
"time"
"github.com/sjzar/chatlog/internal/chatlog/ctx"
"github.com/sjzar/chatlog/internal/chatlog/database"
"github.com/sjzar/chatlog/internal/chatlog/mcp"
"github.com/sjzar/chatlog/internal/errors"
@@ -14,20 +13,21 @@ import (
"github.com/rs/zerolog/log"
)
const (
DefalutHTTPAddr = "127.0.0.1:5030"
)
type Service struct {
ctx *ctx.Context
db *database.Service
mcp *mcp.Service
conf Config
db *database.Service
mcp *mcp.Service
router *gin.Engine
server *http.Server
}
func NewService(ctx *ctx.Context, db *database.Service, mcp *mcp.Service) *Service {
type Config interface {
GetHTTPAddr() string
GetDataDir() string
}
func NewService(conf Config, db *database.Service, mcp *mcp.Service) *Service {
gin.SetMode(gin.ReleaseMode)
router := gin.New()
@@ -40,11 +40,12 @@ func NewService(ctx *ctx.Context, db *database.Service, mcp *mcp.Service) *Servi
router.Use(
errors.RecoveryMiddleware(),
errors.ErrorHandlerMiddleware(),
gin.LoggerWithWriter(log.Logger),
gin.LoggerWithWriter(log.Logger, "/health"),
corsMiddleware(),
)
s := &Service{
ctx: ctx,
conf: conf,
db: db,
mcp: mcp,
router: router,
@@ -56,12 +57,8 @@ func NewService(ctx *ctx.Context, db *database.Service, mcp *mcp.Service) *Servi
func (s *Service) Start() error {
if s.ctx.HTTPAddr == "" {
s.ctx.HTTPAddr = DefalutHTTPAddr
}
s.server = &http.Server{
Addr: s.ctx.HTTPAddr,
Addr: s.conf.GetHTTPAddr(),
Handler: s.router,
}
@@ -72,23 +69,19 @@ func (s *Service) Start() error {
}
}()
log.Info().Msg("Starting HTTP server on " + s.ctx.HTTPAddr)
log.Info().Msg("Starting HTTP server on " + s.conf.GetHTTPAddr())
return nil
}
func (s *Service) ListenAndServe() error {
if s.ctx.HTTPAddr == "" {
s.ctx.HTTPAddr = DefalutHTTPAddr
}
s.server = &http.Server{
Addr: s.ctx.HTTPAddr,
Addr: s.conf.GetHTTPAddr(),
Handler: s.router,
}
log.Info().Msg("Starting HTTP server on " + s.ctx.HTTPAddr)
log.Info().Msg("Starting HTTP server on " + s.conf.GetHTTPAddr())
return s.server.ListenAndServe()
}
+157 -87
View File
@@ -3,7 +3,7 @@ package chatlog
import (
"context"
"fmt"
"path/filepath"
"os"
"strings"
"github.com/rs/zerolog/log"
@@ -14,14 +14,16 @@ import (
"github.com/sjzar/chatlog/internal/chatlog/mcp"
"github.com/sjzar/chatlog/internal/chatlog/wechat"
iwechat "github.com/sjzar/chatlog/internal/wechat"
"github.com/sjzar/chatlog/pkg/config"
"github.com/sjzar/chatlog/pkg/util"
"github.com/sjzar/chatlog/pkg/util/dat2img"
)
// Manager 管理聊天日志应用
type Manager struct {
conf *conf.Service
ctx *ctx.Context
ctx *ctx.Context
sc *conf.ServerConfig
scm *config.Manager
// Services
db *database.Service
@@ -33,36 +35,25 @@ type Manager struct {
app *App
}
func New(configPath string) (*Manager, error) {
// 创建配置服务
conf, err := conf.NewService(configPath)
if err != nil {
return nil, err
}
// 创建应用上下文
ctx := ctx.New(conf)
wechat := wechat.NewService(ctx)
db := database.NewService(ctx)
mcp := mcp.NewService(ctx, db)
http := http.NewService(ctx, db, mcp)
return &Manager{
conf: conf,
ctx: ctx,
db: db,
mcp: mcp,
http: http,
wechat: wechat,
}, nil
func New() *Manager {
return &Manager{}
}
func (m *Manager) Run() error {
func (m *Manager) Run(configPath string) error {
var err error
m.ctx, err = ctx.New(configPath)
if err != nil {
return err
}
m.wechat = wechat.NewService(m.ctx)
m.db = database.NewService(m.ctx)
m.mcp = mcp.NewService(m.db)
m.http = http.NewService(m.ctx, m.db, m.mcp)
m.ctx.WeChatInstances = m.wechat.GetWeChatInstances()
if len(m.ctx.WeChatInstances) >= 1 {
@@ -263,52 +254,91 @@ func (m *Manager) RefreshSession() error {
return nil
}
func (m *Manager) CommandKey(pid int) (string, error) {
instances := m.wechat.GetWeChatInstances()
if len(instances) == 0 {
func (m *Manager) CommandKey(configPath string, pid int, force bool, showXorKey bool) (string, error) {
var err error
m.ctx, err = ctx.New(configPath)
if err != nil {
return "", err
}
m.wechat = wechat.NewService(m.ctx)
m.ctx.WeChatInstances = m.wechat.GetWeChatInstances()
if len(m.ctx.WeChatInstances) == 0 {
return "", fmt.Errorf("wechat process not found")
}
if len(instances) == 1 {
key, _, err := instances[0].GetKey(context.Background())
if err != nil {
return "", err
if len(m.ctx.WeChatInstances) == 1 {
key, imgKey := m.ctx.DataKey, m.ctx.ImgKey
if len(key) == 0 || len(imgKey) == 0 || force {
key, imgKey, err = m.ctx.WeChatInstances[0].GetKey(context.Background())
if err != nil {
return "", err
}
m.ctx.Refresh()
m.ctx.UpdateConfig()
}
return key, nil
result := fmt.Sprintf("Data Key: [%s]\nImage Key: [%s]", key, imgKey)
if m.ctx.Version == 4 && showXorKey {
if b, err := dat2img.ScanAndSetXorKey(m.ctx.DataDir); err == nil {
result += fmt.Sprintf("\nXor Key: [0x%X]", b)
}
}
return result, nil
}
if pid == 0 {
str := "Select a process:\n"
for _, ins := range instances {
for _, ins := range m.ctx.WeChatInstances {
str += fmt.Sprintf("PID: %d. %s[Version: %s Data Dir: %s ]\n", ins.PID, ins.Name, ins.FullVersion, ins.DataDir)
}
return str, nil
}
for _, ins := range instances {
for _, ins := range m.ctx.WeChatInstances {
if ins.PID == uint32(pid) {
key, _, err := ins.GetKey(context.Background())
if err != nil {
return "", err
key, imgKey := ins.Key, ins.ImgKey
if len(key) == 0 || len(imgKey) == 0 || force {
key, imgKey, err = ins.GetKey(context.Background())
if err != nil {
return "", err
}
m.ctx.Refresh()
m.ctx.UpdateConfig()
}
return key, nil
result := fmt.Sprintf("Data Key: [%s]\nImage Key: [%s]", key, imgKey)
if m.ctx.Version == 4 && showXorKey {
if b, err := dat2img.ScanAndSetXorKey(m.ctx.DataDir); err == nil {
result += fmt.Sprintf("\nXor Key: [0x%X]", b)
}
}
return result, nil
}
}
return "", fmt.Errorf("wechat process not found")
}
func (m *Manager) CommandDecrypt(dataDir string, workDir string, key string, platform string, version int) error {
if dataDir == "" {
func (m *Manager) CommandDecrypt(configPath string, cmdConf map[string]any) error {
var err error
m.sc, m.scm, err = conf.LoadServiceConfig(configPath, cmdConf)
if err != nil {
return err
}
dataDir := m.sc.GetDataDir()
if len(dataDir) == 0 {
return fmt.Errorf("dataDir is required")
}
if key == "" {
return fmt.Errorf("key is required")
dataKey := m.sc.GetDataKey()
if len(dataKey) == 0 {
return fmt.Errorf("dataKey is required")
}
if workDir == "" {
workDir = util.DefaultWorkDir(filepath.Base(filepath.Dir(dataDir)))
}
m.ctx.DataDir = dataDir
m.ctx.WorkDir = workDir
m.ctx.DataKey = key
m.ctx.Platform = platform
m.ctx.Version = version
m.wechat = wechat.NewService(m.sc)
if err := m.wechat.DecryptDBFiles(); err != nil {
return err
}
@@ -316,43 +346,83 @@ func (m *Manager) CommandDecrypt(dataDir string, workDir string, key string, pla
return nil
}
func (m *Manager) CommandHTTPServer(addr string, dataDir string, workDir string, platform string, version int) error {
func (m *Manager) CommandHTTPServer(configPath string, cmdConf map[string]any) error {
if addr == "" {
addr = "127.0.0.1:5030"
}
if workDir == "" {
return fmt.Errorf("workDir is required")
}
if platform == "" {
return fmt.Errorf("platform is required")
}
if version == 0 {
return fmt.Errorf("version is required")
}
m.ctx.HTTPAddr = addr
m.ctx.DataDir = dataDir
m.ctx.WorkDir = workDir
m.ctx.Platform = platform
m.ctx.Version = version
// 如果是 4.0 版本,更新下 xorkey
if m.ctx.Version == 4 && m.ctx.DataDir != "" {
go dat2img.ScanAndSetXorKey(m.ctx.DataDir)
}
// 按依赖顺序启动服务
if err := m.db.Start(); err != nil {
var err error
m.sc, m.scm, err = conf.LoadServiceConfig(configPath, cmdConf)
if err != nil {
return err
}
dataDir := m.sc.GetDataDir()
workDir := m.sc.GetWorkDir()
if len(dataDir) == 0 && len(workDir) == 0 {
return fmt.Errorf("dataDir or workDir is required")
}
dataKey := m.sc.GetDataKey()
if len(dataKey) == 0 {
return fmt.Errorf("dataKey is required")
}
// 如果是 4.0 版本,处理图片密钥
version := m.sc.GetVersion()
if version == 4 && len(dataDir) != 0 {
dat2img.SetAesKey(m.sc.GetImgKey())
go dat2img.ScanAndSetXorKey(dataDir)
}
log.Info().Msgf("server config: %+v", m.sc)
m.wechat = wechat.NewService(m.sc)
m.db = database.NewService(m.sc)
m.mcp = mcp.NewService(m.db)
m.http = http.NewService(m.sc, m.db, m.mcp)
if m.sc.GetAutoDecrypt() {
if err := m.wechat.StartAutoDecrypt(); err != nil {
return err
}
log.Info().Msg("auto decrypt is enabled")
}
// init db
go func() {
// 如果工作目录为空,则解密数据
if entries, err := os.ReadDir(workDir); err == nil && len(entries) == 0 {
log.Info().Msgf("work dir is empty, decrypt data.")
m.db.SetDecrypting()
if err := m.wechat.DecryptDBFiles(); err != nil {
log.Info().Msgf("decrypt data failed: %v", err)
return
}
log.Info().Msg("decrypt data success")
}
// 按依赖顺序启动服务
if err := m.db.Start(); err != nil {
log.Info().Msgf("start db failed, try to decrypt data.")
m.db.SetDecrypting()
if err := m.wechat.DecryptDBFiles(); err != nil {
log.Info().Msgf("decrypt data failed: %v", err)
return
}
log.Info().Msg("decrypt data success")
if err := m.db.Start(); err != nil {
log.Info().Msgf("start db failed: %v", err)
m.db.SetError(err.Error())
return
}
}
}()
if err := m.mcp.Start(); err != nil {
return err
}
defer m.mcp.Stop()
return m.http.ListenAndServe()
}
+3 -6
View File
@@ -9,7 +9,6 @@ import (
"strings"
"time"
"github.com/sjzar/chatlog/internal/chatlog/ctx"
"github.com/sjzar/chatlog/internal/chatlog/database"
"github.com/sjzar/chatlog/internal/mcp"
"github.com/sjzar/chatlog/pkg/util"
@@ -18,16 +17,14 @@ import (
)
type Service struct {
ctx *ctx.Context
db *database.Service
db *database.Service
mcp *mcp.MCP
}
func NewService(ctx *ctx.Context, db *database.Service) *Service {
func NewService(db *database.Service) *Service {
return &Service{
ctx: ctx,
db: db,
db: db,
}
}
+16 -9
View File
@@ -11,7 +11,6 @@ import (
"github.com/fsnotify/fsnotify"
"github.com/rs/zerolog/log"
"github.com/sjzar/chatlog/internal/chatlog/ctx"
"github.com/sjzar/chatlog/internal/errors"
"github.com/sjzar/chatlog/internal/wechat"
"github.com/sjzar/chatlog/internal/wechat/decrypt"
@@ -25,16 +24,24 @@ var (
)
type Service struct {
ctx *ctx.Context
conf Config
lastEvents map[string]time.Time
pendingActions map[string]bool
mutex sync.Mutex
fm *filemonitor.FileMonitor
}
func NewService(ctx *ctx.Context) *Service {
type Config interface {
GetDataKey() string
GetDataDir() string
GetWorkDir() string
GetPlatform() string
GetVersion() int
}
func NewService(conf Config) *Service {
return &Service{
ctx: ctx,
conf: conf,
lastEvents: make(map[string]time.Time),
pendingActions: make(map[string]bool),
}
@@ -61,7 +68,7 @@ func (s *Service) GetDataKey(info *wechat.Account) (string, error) {
}
func (s *Service) StartAutoDecrypt() error {
dbGroup, err := filemonitor.NewFileGroup("wechat", s.ctx.DataDir, `.*\.db$`, []string{"fts"})
dbGroup, err := filemonitor.NewFileGroup("wechat", s.conf.GetDataDir(), `.*\.db$`, []string{"fts"})
if err != nil {
return err
}
@@ -129,12 +136,12 @@ func (s *Service) waitAndProcess(dbFile string) {
func (s *Service) DecryptDBFile(dbFile string) error {
decryptor, err := decrypt.NewDecryptor(s.ctx.Platform, s.ctx.Version)
decryptor, err := decrypt.NewDecryptor(s.conf.GetPlatform(), s.conf.GetVersion())
if err != nil {
return err
}
output := filepath.Join(s.ctx.WorkDir, dbFile[len(s.ctx.DataDir):])
output := filepath.Join(s.conf.GetWorkDir(), dbFile[len(s.conf.GetDataDir()):])
if err := util.PrepareDir(filepath.Dir(output)); err != nil {
return err
}
@@ -151,7 +158,7 @@ func (s *Service) DecryptDBFile(dbFile string) error {
}
}()
if err := decryptor.Decrypt(context.Background(), dbFile, s.ctx.DataKey, outputFile); err != nil {
if err := decryptor.Decrypt(context.Background(), dbFile, s.conf.GetDataKey(), outputFile); err != nil {
if err == errors.ErrAlreadyDecrypted {
if data, err := os.ReadFile(dbFile); err == nil {
outputFile.Write(data)
@@ -168,7 +175,7 @@ func (s *Service) DecryptDBFile(dbFile string) error {
}
func (s *Service) DecryptDBFiles() error {
dbGroup, err := filemonitor.NewFileGroup("wechat", s.ctx.DataDir, `.*\.db$`, []string{"fts"})
dbGroup, err := filemonitor.NewFileGroup("wechat", s.conf.GetDataDir(), `.*\.db$`, []string{"fts"})
if err != nil {
return err
}
+61 -52
View File
@@ -19,6 +19,7 @@ package config
import (
"errors"
"os"
"strings"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
@@ -29,103 +30,111 @@ const (
)
var (
// ConfigName holds the name of the configuration file.
ConfigName = ""
// ConfigType specifies the type/format of the configuration file.
ConfigType = ""
// ConfigPath denotes the path to the configuration file.
ConfigPath = ""
// ERROR
ErrInvalidDirectory = errors.New("invalid directory path")
ErrMissingConfigName = errors.New("config name not specified")
)
// Init initializes the configuration settings.
type Manager struct {
App string
EnvPrefix string
Path string
Name string
WriteConfig bool
Viper *viper.Viper
}
// New initializes the configuration settings.
// It sets up the name, type, and path for the configuration file.
func Init(name, _type, path string) error {
if len(name) == 0 {
return ErrMissingConfigName
}
if len(_type) == 0 {
_type = DefaultConfigType
func New(app, path, name, envPrefix string, writeConfig bool) (*Manager, error) {
if len(app) == 0 {
return nil, ErrMissingConfigName
}
v := viper.New()
v.SetConfigType(DefaultConfigType)
var err error
// Path
if len(path) == 0 {
path, err = os.UserHomeDir()
if err != nil {
path = os.TempDir()
}
path += string(os.PathSeparator) + "." + name
path += string(os.PathSeparator) + "." + app
}
if err := PrepareDir(path); err != nil {
return err
return nil, err
}
v.AddConfigPath(path)
// Name
if len(name) == 0 {
name = app
}
v.SetConfigName(name)
// Env
if len(envPrefix) != 0 {
v.SetEnvPrefix(strings.ToUpper(app))
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
}
ConfigName = name
ConfigType = _type
ConfigPath = path
return nil
return &Manager{
App: app,
EnvPrefix: envPrefix,
Path: path,
Name: name,
Viper: v,
}, nil
}
// Load loads the configuration from the previously initialized file.
// It unmarshals the configuration into the provided conf interface.
func Load(conf interface{}) error {
viper.SetConfigName(ConfigName)
viper.SetConfigType(ConfigType)
viper.AddConfigPath(ConfigPath)
if err := viper.ReadInConfig(); err != nil {
if err := viper.SafeWriteConfig(); err != nil {
return err
func (c *Manager) Load(conf interface{}) error {
if err := c.Viper.ReadInConfig(); err != nil {
if c.WriteConfig {
if err := c.Viper.SafeWriteConfig(); err != nil {
return err
}
}
}
if err := viper.Unmarshal(conf); err != nil {
if err := c.Viper.Unmarshal(conf, decoderConfig()); err != nil {
return err
}
SetDefault(conf)
return nil
}
// LoadFile loads the configuration from a specified file.
// It unmarshals the configuration into the provided conf interface.
func LoadFile(file string, conf interface{}) error {
viper.SetConfigFile(file)
if err := viper.ReadInConfig(); err != nil {
func (c *Manager) LoadFile(file string, conf interface{}) error {
c.Viper.SetConfigFile(file)
if err := c.Viper.ReadInConfig(); err != nil {
return err
}
if err := viper.Unmarshal(conf); err != nil {
if err := c.Viper.Unmarshal(conf, decoderConfig()); err != nil {
return err
}
SetDefault(conf)
return nil
}
// SetConfig sets a configuration key to a specified value.
// It also writes the updated configuration back to the file.
func SetConfig(key string, value interface{}) error {
viper.Set(key, value)
if err := viper.WriteConfig(); err != nil {
return err
func (c *Manager) SetConfig(key string, value interface{}) error {
c.Viper.Set(key, value)
if c.WriteConfig {
if err := c.Viper.WriteConfig(); err != nil {
return err
}
}
return nil
}
// ResetConfig resets the configuration to empty.
func ResetConfig() error {
viper.Reset()
viper.SetConfigName(ConfigName)
viper.SetConfigType(ConfigType)
viper.AddConfigPath(ConfigPath)
return viper.WriteConfig()
}
// GetConfig retrieves all configuration settings as a map.
func GetConfig() map[string]interface{} {
return viper.AllSettings()
func (c *Manager) GetConfig() map[string]interface{} {
return c.Viper.AllSettings()
}
// PrepareDir ensures that the specified directory path exists.
+8 -226
View File
@@ -17,235 +17,17 @@
package config
import (
"encoding/json"
"reflect"
"strconv"
"github.com/spf13/viper"
)
// DefaultTag is the default tag used to identify default values for struct fields.
var DefaultTag string
func init() {
DefaultTag = "default"
}
// SetDefaultTag updates the tag used to identify default values.
func SetDefaultTag(tag string) {
DefaultTag = tag
}
// SetDefault sets the default values for a given interface{}.
// The interface{} must be a pointer to a struct, bcs the default values are SET on the struct fields.
func SetDefault(v interface{}) {
if v == nil {
return
func SetDefaults(v *viper.Viper, c any, defaults map[string]any) {
keys := GetStructKeys(reflect.TypeOf(c), "mapstructure", "squash")
for _, key := range keys {
v.SetDefault(key, nil)
}
val := reflect.ValueOf(v)
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
setDefault(val, "")
}
// setDefault recursively sets default values based on the struct's tags.
func setDefault(val reflect.Value, tag string) {
if !val.CanSet() {
return
}
switch val.Kind() {
case reflect.Struct:
handleStruct(val, tag)
case reflect.Ptr:
handlePtr(val, tag)
case reflect.Interface:
handleInterface(val, tag)
case reflect.Map:
handleMap(val, tag)
case reflect.Slice, reflect.Array:
handleSliceArray(val, tag)
default:
handleSimpleType(val, tag)
for key := range defaults {
v.SetDefault(key, defaults[key])
}
}
// handleSimpleType handles the assignment of default values for simple data types.
func handleSimpleType(val reflect.Value, tag string) {
if len(tag) == 0 || !val.IsZero() || !val.CanSet() {
return
}
// Assign appropriate default values based on the data type.
switch val.Kind() {
case reflect.String:
val.SetString(tag)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
if intValue, err := strconv.ParseInt(tag, 10, 64); err == nil {
val.SetInt(intValue)
}
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
if uintVal, err := strconv.ParseUint(tag, 10, 64); err == nil {
val.SetUint(uintVal)
}
case reflect.Float32, reflect.Float64:
if floatValue, err := strconv.ParseFloat(tag, 64); err == nil {
val.SetFloat(floatValue)
}
case reflect.Bool:
if boolVal, err := strconv.ParseBool(tag); err == nil {
val.SetBool(boolVal)
}
}
}
// handleStruct processes struct type fields and sets default values based on their tags.
func handleStruct(val reflect.Value, tag string) {
if val.Kind() != reflect.Struct {
return
}
if !val.IsZero() || len(tag) == 0 {
typ := val.Type()
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
fieldType := typ.Field(i)
setDefault(field, fieldType.Tag.Get(DefaultTag))
}
return
}
if !val.CanSet() {
return
}
// If tag is provided, unmarshal the default value and set it.
newStruct := reflect.New(val.Type()).Interface()
if err := json.Unmarshal([]byte(tag), newStruct); err == nil {
newStructVal := reflect.ValueOf(newStruct).Elem()
for i := 0; i < newStructVal.NumField(); i++ {
field := newStructVal.Field(i)
fieldType := newStructVal.Type().Field(i)
setDefault(field, fieldType.Tag.Get(DefaultTag))
}
val.Set(newStructVal)
}
}
// handleSliceArray sets default values for slice or array types.
func handleSliceArray(val reflect.Value, tag string) {
if val.Kind() != reflect.Slice && val.Kind() != reflect.Array {
return
}
if !val.IsZero() || len(tag) == 0 {
for i := 0; i < val.Len(); i++ {
setDefault(val.Index(i), "")
}
return
}
if !val.CanSet() {
return
}
// array 提前初始化子结构体,解决 default 长度小于 array 长度的问题
if val.Kind() == reflect.Array {
for i := 0; i < val.Len(); i++ {
setDefault(val.Index(i), "")
}
}
// If tag is provided, unmarshal the default value and set it.
newSlice := reflect.New(reflect.SliceOf(val.Type().Elem())).Interface()
if err := json.Unmarshal([]byte(tag), newSlice); err == nil {
sliceValue := reflect.ValueOf(newSlice).Elem()
for j := 0; j < sliceValue.Len(); j++ {
v := sliceValue.Index(j)
setDefault(v, "")
if val.Kind() == reflect.Array {
if j >= val.Len() {
return
}
val.Index(j).Set(v)
} else {
val.Set(reflect.Append(val, v))
}
}
}
}
// handleMap sets default values for map types.
func handleMap(val reflect.Value, tag string) {
if val.Kind() != reflect.Map {
return
}
if !val.IsZero() || len(tag) == 0 {
for _, key := range val.MapKeys() {
setDefault(val.MapIndex(key), "")
}
return
}
if !val.CanSet() {
return
}
// If tag is provided, unmarshal the default value and set it.
newMap := reflect.New(val.Type()).Interface()
if err := json.Unmarshal([]byte(tag), newMap); err == nil {
newMapVal := reflect.ValueOf(newMap).Elem()
for _, k := range newMapVal.MapKeys() {
v := newMapVal.MapIndex(k)
setDefault(v, "")
}
val.Set(newMapVal)
}
}
// handlePtr handles pointer types and sets default values based on their tags.
func handlePtr(val reflect.Value, tag string) {
if val.Kind() != reflect.Ptr {
return
}
if !val.IsZero() || len(tag) == 0 || !val.CanSet() {
return
}
// If tag is provided, unmarshal the default value and set it.
newPtr := reflect.New(val.Type()).Interface()
if err := json.Unmarshal([]byte(tag), newPtr); err == nil {
newPtrVal := reflect.ValueOf(newPtr).Elem()
setDefault(newPtrVal, "")
val.Set(newPtrVal)
}
}
// handleInterface processes interface types and sets default values based on their tags.
// support pointer interface only, bcs the default values are SET on the struct fields.
func handleInterface(val reflect.Value, tag string) {
if val.Kind() != reflect.Interface {
return
}
if val.IsNil() {
return
}
if val.Elem().Kind() != reflect.Ptr {
return
}
// Recursively set the default for the inner value of the interface.
setDefault(reflect.ValueOf(val.Elem().Interface()).Elem(), tag)
}
+149
View File
@@ -0,0 +1,149 @@
package config
import (
"reflect"
"strings"
)
const sep = "."
// GetStructKeys returns all keys in a nested struct type, taking the name from the tag name or
// the field name. It handles an additional suffix squashValue like mapstructure does: if
// present on an embedded struct, name components for that embedded struct should not be
// included. It does not handle maps, does chase pointers, but does not check for loops in
// nesting.
func GetStructKeys(typ reflect.Type, tag, squashValue string) []string {
return appendStructKeys(typ, tag, ","+squashValue, nil, nil)
}
// appendStructKeys recursively appends to keys all keys of nested struct type typ, taking tag
// and squashValue from GetStructKeys. prefix holds all components of the path from the typ
// passed to GetStructKeys down to this typ.
func appendStructKeys(typ reflect.Type, tag, squashValue string, prefix []string, keys []string) []string {
// Dereference any pointers. This is a finite loop: Go types are well-founded.
for ; typ.Kind() == reflect.Ptr; typ = typ.Elem() {
}
// Handle only struct containers; terminate the recursion on anything else.
if typ.Kind() != reflect.Struct {
return append(keys, strings.Join(prefix, sep))
}
for i := 0; i < typ.NumField(); i++ {
fieldType := typ.Field(i)
var (
// fieldName is the name to use for the field.
fieldName string
// If squash is true, squash the sub-struct no additional accessor.
squash bool
ok bool
)
if fieldName, ok = fieldType.Tag.Lookup(tag); ok {
if strings.HasSuffix(fieldName, squashValue) {
squash = true
fieldName = strings.TrimSuffix(fieldName, squashValue)
}
} else {
fieldName = strings.ToLower(fieldType.Name)
}
// Update prefix to recurse into this field.
if !squash {
prefix = append(prefix, fieldName)
}
keys = appendStructKeys(fieldType.Type, tag, squashValue, prefix, keys)
// Restore prefix.
if !squash {
prefix = prefix[:len(prefix)-1]
}
}
return keys
}
// ValidateMissingRequiredKeys returns all keys of value in GetStructKeys format that have an
// additional required tag set but are unset.
func ValidateMissingRequiredKeys(value interface{}, tag, squashValue string) []string {
return appendStructKeysIfZero(reflect.ValueOf(value), tag, ","+squashValue, "validate", "required", nil, nil)
}
// isScalar returns true if kind is "scalar", i.e. has no Elem(). This
// recites the list from reflect/type.go and may start to give incorrect
// results if new kinds are added to the language.
func isScalar(kind reflect.Kind) bool {
switch kind {
case reflect.Array:
case reflect.Chan:
case reflect.Map:
case reflect.Ptr:
case reflect.Slice:
return false
}
return true
}
func appendStructKeysIfZero(value reflect.Value, tag, squashValue, validateTag, requiredValue string, prefix []string, keys []string) []string {
// finite loop: Go types are well-founded.
for value.Kind() == reflect.Ptr {
if value.IsZero() { // If required, would already have errored out.
return keys
}
value = value.Elem()
}
if !isScalar(value.Kind()) {
// Why use Type().Elem() when reflect.Value provides a perfectly good Elem()
// method? The two are *not* the same, e.g. for nil pointers value.Elem() is
// invalid and has no Kind(). (See https://play.golang.org/p/M3ZV19AZAW0)
if !isScalar(value.Type().Elem().Kind()) {
// TODO(ariels): Possible to add, but need to define the semantics. One
// way might be to validate each field according to its type.
panic("No support for detecting required keys inside " + value.Kind().String() + " of structs")
}
}
// Handle only struct containers; terminate the recursion on anything else.
if value.Kind() != reflect.Struct {
return keys
}
for i := 0; i < value.NumField(); i++ {
fieldType := value.Type().Field(i)
fieldValue := value.Field(i)
var (
// fieldName is the name to use for the field.
fieldName string
// If squash is true, squash the sub-struct no additional accessor.
squash bool
ok bool
)
if fieldName, ok = fieldType.Tag.Lookup(tag); ok {
if strings.HasSuffix(fieldName, squashValue) {
squash = true
fieldName = strings.TrimSuffix(fieldName, squashValue)
}
} else {
fieldName = strings.ToLower(fieldType.Name)
}
// Perform any needed validations.
if validationsString, ok := fieldType.Tag.Lookup(validateTag); ok {
for _, validation := range strings.Split(validationsString, ",") {
// Validate "required" field.
if validation == requiredValue && fieldValue.IsZero() {
keys = append(keys, strings.Join(append(prefix, fieldName), sep))
}
}
}
// Update prefix to recurse into this field.
if !squash {
prefix = append(prefix, fieldName)
}
keys = appendStructKeysIfZero(fieldValue, tag, squashValue, validateTag, requiredValue, prefix, keys)
// Restore prefix.
if !squash {
prefix = prefix[:len(prefix)-1]
}
}
return keys
}
+121
View File
@@ -0,0 +1,121 @@
package config
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"github.com/mitchellh/mapstructure"
"github.com/spf13/viper"
)
// DecodeStringToMap returns a DecodeHookFunc that converts a string to a map[string]string.
// The string is expected to be a comma-separated list of key-value pairs, where the key and value
// are separated by an equal sign.
func DecodeStringToMap() mapstructure.DecodeHookFunc {
return func(f reflect.Kind, t reflect.Kind, data interface{}) (interface{}, error) {
// check if field is a string and target is a map
if f != reflect.String || t != reflect.Map {
return data, nil
}
// check if target is map[string]string
if t != reflect.TypeOf(map[string]string{}).Kind() {
return data, nil
}
raw := data.(string)
if raw == "" {
return map[string]string{}, nil
}
// parse raw string as key1=value1,key2=value2
const pairSep = ","
const valueSep = "="
pairs := strings.Split(raw, pairSep)
m := make(map[string]string, len(pairs))
for _, pair := range pairs {
key, value, found := strings.Cut(pair, valueSep)
if !found {
return nil, fmt.Errorf("invalid key-value pair: %s", pair)
}
m[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
return m, nil
}
}
// StringToSliceWithBracketHookFunc returns a DecodeHookFunc that converts a string to a slice of strings.
// Useful when configuration values are provided as JSON arrays in string form, but need to be parsed into slices.
// The string is expected to be a JSON array.
// If the string is empty, an empty slice is returned.
// If the string cannot be parsed as a JSON array, the original data is returned unchanged.
func StringToSliceWithBracketHookFunc() mapstructure.DecodeHookFunc {
return func(f reflect.Kind, t reflect.Kind, data interface{}) (interface{}, error) {
if f != reflect.String || t != reflect.Slice {
return data, nil
}
raw := data.(string)
if raw == "" {
return []string{}, nil
}
var result any
err := json.Unmarshal([]byte(raw), &result)
if err != nil {
return data, nil
}
// Verify that the result matches the target (slice)
if reflect.TypeOf(result).Kind() != t {
return data, nil
}
return result, nil
}
}
// StringToStructHookFunc returns a DecodeHookFunc that converts a string to a struct.
// Useful for parsing configuration values that are provided as JSON strings but need to be converted to sturcts.
// The string is expected to be a JSON object that can be unmarshaled into the target struct.
// If the string is empty, a new instance of the target struct is returned.
// If the string cannot be parsed as a JSON object, the original data is returned unchanged.
func StringToStructHookFunc() mapstructure.DecodeHookFunc {
return func(f reflect.Type, t reflect.Type, data interface{}) (interface{}, error) {
if f.Kind() != reflect.String ||
(t.Kind() != reflect.Struct && !(t.Kind() == reflect.Pointer && t.Elem().Kind() == reflect.Struct)) {
return data, nil
}
raw := data.(string)
var val reflect.Value
// Struct or the pointer to a struct
if t.Kind() == reflect.Struct {
val = reflect.New(t)
} else {
val = reflect.New(t.Elem())
}
if raw == "" {
return val, nil
}
var m map[string]interface{}
err := json.Unmarshal([]byte(raw), &m)
if err != nil {
return data, nil
}
return m, nil
}
}
// CompositeDecodeHook 组合所有解码钩子
func CompositeDecodeHook() mapstructure.DecodeHookFunc {
return mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
DecodeStringToMap(),
StringToStructHookFunc(),
StringToSliceWithBracketHookFunc(),
)
}
func decoderConfig() viper.DecoderConfigOption {
return viper.DecodeHook(CompositeDecodeHook())
}