Initial implementation of Codex Helper

This commit is contained in:
zhoujun0601
2026-08-13 05:46:03 -04:00
commit 72f1e24fb9
28 changed files with 4920 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
frontend/node_modules
*.db
.DS_Store
coverage
*.tsbuildinfo
+8
View File
@@ -0,0 +1,8 @@
frontend/node_modules/
backend/internal/web/dist/*
!backend/internal/web/dist/index.html
*.db
*.db-shm
*.db-wal
.env
*.tsbuildinfo
+34
View File
@@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1.7
FROM node:24.19.0-bookworm-slim AS frontend
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json frontend/tsconfig.json frontend/vite.config.ts ./
COPY frontend/src ./src
COPY frontend/index.html ./index.html
RUN npm ci && npm run build
FROM golang:1.26.0-bookworm AS backend
WORKDIR /src/backend
COPY backend/go.mod backend/go.sum* ./
RUN go mod download
COPY backend/cmd ./cmd
COPY backend/internal ./internal
COPY --from=frontend /src/backend/internal/web/dist ./internal/web/dist
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/codex-helper ./cmd/server
FROM node:24.19.0-bookworm-slim AS codex
ARG CODEX_VERSION=0.147.0
RUN npm install -g @openai/codex@${CODEX_VERSION} && npm cache clean --force
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 --create-home helper && mkdir -p /data && chown helper:helper /data
COPY --from=backend /out/codex-helper /usr/local/bin/codex-helper
COPY --from=codex /usr/local/bin/node /usr/local/bin/node
COPY --from=codex /usr/local/lib/node_modules/@openai/codex /usr/local/lib/node_modules/@openai/codex
RUN ln -s /usr/local/lib/node_modules/@openai/codex/bin/codex.js /usr/local/bin/codex
USER helper
ENV DATA_DIR=/data LISTEN_ADDR=:8080
VOLUME ["/data"]
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD ["/usr/local/bin/codex-helper","healthcheck"]
ENTRYPOINT ["/usr/local/bin/codex-helper"]
+38
View File
@@ -0,0 +1,38 @@
# Codex Helper
一个单容器运行的 Codex 账户用量仪表盘,支持每日 Token 历史、限额窗口、重置提醒、Telegram 查询菜单与 SMTP 邮件。
## 快速启动
```bash
docker compose up -d --build
```
打开 `http://localhost:8080` 创建管理员。初始化后在“设置中心”完成 Codex 设备码登录、Telegram 和 SMTP 配置。除监听端口与数据卷外,运行期设置均在前端管理。
> 首次初始化未设置安装码。不要在创建管理员前将端口暴露到不可信网络。
## 数据与备份
所有持久数据位于 Docker 卷的 `/data`。可在设置中心下载 SQLite 备份。完整恢复时停止容器,替换数据库及 `secret.key`、`codex/` 后再启动;三者应一起备份。
## 数据边界
Codex app server 当前提供套餐类型、限额窗口、重置时间、累计及每日总 Token。它不提供调用次数、输入 Token、输出 Token、订阅价格或账单续期日,因此界面会将这些字段标记为不可用。
## 开发
前端与后端分别位于 `frontend/` 和 `backend/`。启动两个开发进程:
```bash
cd frontend
npm install
npm run dev
```
```bash
cd backend
go run ./cmd/server
```
开发时将 `DATA_DIR` 指向可写目录,并确保 `codex` 在 `PATH` 中。生产环境建议使用 HTTPS 反向代理。
+41
View File
@@ -0,0 +1,41 @@
package main
import (
"log"
"net/http"
"os"
"os/signal"
"syscall"
"codex-helper/internal/app"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
addr = ":8080"
}
if addr[0] == ':' {
addr = "127.0.0.1" + addr
}
r, e := http.Get("http://" + addr + "/health/live")
if e != nil || r.StatusCode != http.StatusOK {
os.Exit(1)
}
return
}
a, err := app.New()
if err != nil {
log.Fatal(err)
}
defer a.Close()
go func() {
if err := a.Run(); err != nil {
log.Fatal(err)
}
}()
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
<-c
}
+20
View File
@@ -0,0 +1,20 @@
module codex-helper
go 1.26.0
require modernc.org/sqlite v1.39.1
require golang.org/x/crypto v0.41.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.36.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+51
View File
@@ -0,0 +1,51 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+304
View File
@@ -0,0 +1,304 @@
package app
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"codex-helper/internal/security"
)
func (a *App) api(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/api/v1/")
if p == "system/status" {
jsonOut(w, 200, map[string]any{"initialized": a.store.Initialized(), "version": "0.1.0", "appServer": a.codex.Connected()})
return
}
if p == "setup" && r.Method == "POST" {
a.setup(w, r)
return
}
if p == "auth/login" && r.Method == "POST" {
a.login(w, r)
return
}
if !a.require(w, r) {
return
}
switch {
case p == "auth/me":
var username string
_ = a.store.DB.QueryRow("SELECT username FROM admin WHERE id=1").Scan(&username)
jsonOut(w, 200, map[string]string{"username": username})
case p == "auth/logout" && r.Method == "POST":
a.logout(w, r)
case p == "dashboard":
a.mu.RLock()
d := a.dash
a.mu.RUnlock()
jsonOut(w, 200, d)
case p == "sync" && r.Method == "POST":
e := a.sync(r.Context())
if e != nil {
jsonOut(w, 502, map[string]string{"error": e.Error()})
} else {
jsonOut(w, 200, map[string]bool{"ok": true})
}
case p == "settings/general":
a.generalAPI(w, r)
case p == "settings/smtp":
a.smtpAPI(w, r)
case p == "settings/smtp/test" && r.Method == "POST":
a.smtpTest(w, r)
case p == "settings/telegram":
a.telegramAPI(w, r)
case p == "settings/telegram/test" && r.Method == "POST":
a.telegramTest(w, r)
case p == "settings/telegram/bind" && r.Method == "POST":
code := fmt.Sprintf("%06d", time.Now().UnixNano()%1000000)
_ = a.store.SetJSON("telegram_bind", map[string]any{"code": code, "expires": time.Now().Add(10 * time.Minute).Unix()})
jsonOut(w, 200, map[string]string{"code": code})
case p == "codex/login/device" && r.Method == "POST":
a.deviceLogin(w, r)
case p == "codex/logout" && r.Method == "POST":
var out any
e := a.codex.Call(r.Context(), "account/logout", map[string]any{}, &out)
if e != nil {
jsonOut(w, 502, map[string]string{"error": e.Error()})
} else {
jsonOut(w, 200, map[string]bool{"ok": true})
}
case p == "maintenance/cleanup" && r.Method == "POST":
n, e := a.store.Cleanup(a.general().RetentionDays)
if e != nil {
jsonOut(w, 500, map[string]string{"error": e.Error()})
} else {
jsonOut(w, 200, map[string]int64{"deleted": n})
}
case p == "maintenance/backup":
dir, e := os.MkdirTemp(a.dataDir, "backup-")
if e != nil {
jsonOut(w, 500, map[string]string{"error": e.Error()})
return
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "codex-helper.db")
if e = a.store.Backup(r.Context(), path); e != nil {
jsonOut(w, 500, map[string]string{"error": e.Error()})
return
}
w.Header().Set("Content-Disposition", `attachment; filename="codex-helper.db"`)
http.ServeFile(w, r, path)
default:
jsonOut(w, 404, map[string]string{"error": "接口不存在"})
}
}
func (a *App) setup(w http.ResponseWriter, r *http.Request) {
if a.store.Initialized() {
jsonOut(w, 409, map[string]string{"error": "系统已初始化"})
return
}
var in struct{ Username, Password, Timezone string }
if decode(r, &in) != nil || len(in.Username) < 3 || len(in.Password) < 10 {
jsonOut(w, 400, map[string]string{"error": "用户名至少3位,密码至少10位"})
return
}
tx, e := a.store.DB.Begin()
if e != nil {
jsonOut(w, 500, map[string]string{"error": e.Error()})
return
}
defer tx.Rollback()
_, e = tx.Exec("INSERT INTO admin(id,username,password_hash,created_at) VALUES(1,?,?,?)", in.Username, security.Password(in.Password), time.Now().Unix())
if e == nil {
g := defaults()
if in.Timezone != "" {
if _, z := time.LoadLocation(in.Timezone); z == nil {
g.Timezone = in.Timezone
}
}
b, _ := json.Marshal(g)
_, e = tx.Exec("INSERT INTO settings(key,value,updated_at) VALUES('general',?,?),('initialized','true',?)", string(b), time.Now().Unix(), time.Now().Unix())
}
if e == nil {
e = tx.Commit()
}
if e != nil {
jsonOut(w, 500, map[string]string{"error": e.Error()})
return
}
a.newSession(w, in.Username)
jsonOut(w, 201, map[string]bool{"ok": true})
}
func (a *App) login(w http.ResponseWriter, r *http.Request) {
if !a.store.Initialized() {
jsonOut(w, 409, map[string]string{"error": "请先初始化"})
return
}
ip := r.RemoteAddr
v, _ := a.loginAttempts.LoadOrStore(ip, []time.Time{})
xs := v.([]time.Time)
now := time.Now()
fresh := xs[:0]
for _, x := range xs {
if now.Sub(x) < 15*time.Minute {
fresh = append(fresh, x)
}
}
if len(fresh) >= 10 {
jsonOut(w, 429, map[string]string{"error": "尝试次数过多,请稍后再试"})
return
}
var in struct{ Username, Password string }
_ = decode(r, &in)
var user, hash string
e := a.store.DB.QueryRow("SELECT username,password_hash FROM admin WHERE id=1").Scan(&user, &hash)
if e != nil || user != in.Username || !security.VerifyPassword(hash, in.Password) {
a.loginAttempts.Store(ip, append(fresh, now))
jsonOut(w, 401, map[string]string{"error": "用户名或密码错误"})
return
}
a.loginAttempts.Delete(ip)
a.newSession(w, user)
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (a *App) newSession(w http.ResponseWriter, _ string) {
tok := security.Random(32)
_, _ = a.store.DB.Exec("DELETE FROM sessions WHERE expires_at<?", time.Now().Unix())
_, _ = a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken(tok), time.Now().Add(7*24*time.Hour).Unix(), time.Now().Unix())
http.SetCookie(w, &http.Cookie{Name: "session", Value: tok, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: false, MaxAge: 604800})
}
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
if c, e := r.Cookie("session"); e == nil {
_, _ = a.store.DB.Exec("DELETE FROM sessions WHERE token_hash=?", security.HashToken(c.Value))
}
http.SetCookie(w, &http.Cookie{Name: "session", Path: "/", MaxAge: -1, HttpOnly: true})
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (a *App) general() GeneralSettings {
g := defaults()
a.store.GetJSON("general", &g)
if g.SyncMinutes < 1 {
g.SyncMinutes = 5
}
if g.RetentionDays < 1 {
g.RetentionDays = 90
}
return g
}
func (a *App) generalAPI(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
jsonOut(w, 200, a.general())
return
}
var g GeneralSettings
if decode(r, &g) != nil || g.SyncMinutes < 1 || g.SyncMinutes > 60 || g.RetentionDays < 30 || g.RetentionDays > 365 || g.BeforeMinutes < 1 || g.BeforeMinutes > 1440 {
jsonOut(w, 400, map[string]string{"error": "设置值不合法"})
return
}
if _, e := time.LoadLocation(g.Timezone); e != nil {
jsonOut(w, 400, map[string]string{"error": "无效时区"})
return
}
_ = a.store.SetJSON("general", g)
jsonOut(w, 200, g)
}
func (a *App) deviceLogin(w http.ResponseWriter, r *http.Request) {
var out map[string]any
e := a.codex.Call(r.Context(), "account/login/start", map[string]any{"type": "chatgptDeviceCode"}, &out)
if e != nil {
jsonOut(w, 502, map[string]string{"error": e.Error()})
return
}
jsonOut(w, 200, out)
}
func (a *App) sync(ctx context.Context) error {
if !a.codex.Connected() {
return fmt.Errorf("app-server 未连接")
}
ctx, c := context.WithTimeout(ctx, 20*time.Second)
defer c()
var ar struct {
Account *struct {
Type string `json:"type"`
Email *string `json:"email"`
PlanType *string `json:"planType"`
} `json:"account"`
}
if e := a.codex.Call(ctx, "account/read", map[string]any{"refreshToken": false}, &ar); e != nil {
return e
}
d := Dashboard{FetchedAt: time.Now().Unix(), Account: AccountView{Connected: ar.Account != nil}, Limits: []LimitBucket{}, Usage: []UsagePoint{}}
if ar.Account != nil {
d.Account.Email = ar.Account.Email
d.Account.PlanType = ar.Account.PlanType
d.Account.AuthMode = &ar.Account.Type
}
var lr struct {
RateLimits *rawLimit `json:"rateLimits"`
By map[string]rawLimit `json:"rateLimitsByLimitId"`
}
if e := a.codex.Call(ctx, "account/rateLimits/read", map[string]any{}, &lr); e == nil {
if len(lr.By) > 0 {
for _, x := range lr.By {
d.Limits = append(d.Limits, flattenLimit(x)...)
}
} else if lr.RateLimits != nil {
d.Limits = flattenLimit(*lr.RateLimits)
}
}
var ur struct {
Summary UsageSummary `json:"summary"`
Daily []struct {
StartDate string `json:"startDate"`
Tokens int64 `json:"tokens"`
} `json:"dailyUsageBuckets"`
}
if e := a.codex.Call(ctx, "account/usage/read", map[string]any{}, &ur); e == nil {
d.Summary = ur.Summary
for _, x := range ur.Daily {
p := UsagePoint{Date: x.StartDate, TotalTokens: x.Tokens}
d.Usage = append(d.Usage, p)
_, _ = a.store.DB.Exec("INSERT INTO daily_usage(date,total_tokens,fetched_at) VALUES(?,?,?) ON CONFLICT(date) DO UPDATE SET total_tokens=excluded.total_tokens,fetched_at=excluded.fetched_at", x.StartDate, x.Tokens, d.FetchedAt)
}
}
for _, x := range d.Limits {
_, _ = a.store.DB.Exec("INSERT INTO limit_snapshots(limit_id,window_type,used_percent,duration_mins,resets_at,fetched_at) VALUES(?,?,?,?,?,?)", x.LimitID, x.WindowType, x.UsedPercent, x.WindowDurationMinutes, x.ResetsAt, d.FetchedAt)
}
a.mu.Lock()
a.dash = d
a.mu.Unlock()
return nil
}
type rawLimit struct {
LimitID string `json:"limitId"`
LimitName *string `json:"limitName"`
PlanType *string `json:"planType"`
Primary *LimitWindow `json:"primary"`
Secondary *LimitWindow `json:"secondary"`
}
func flattenLimit(x rawLimit) []LimitBucket {
out := []LimitBucket{}
if x.Primary != nil {
out = append(out, LimitBucket{x.LimitID, x.LimitName, "primary", x.Primary.UsedPercent, x.Primary.WindowDurationMins, x.Primary.ResetsAt, x.PlanType})
}
if x.Secondary != nil {
out = append(out, LimitBucket{x.LimitID, x.LimitName, "secondary", x.Secondary.UsedPercent, x.Secondary.WindowDurationMins, x.Secondary.ResetsAt, x.PlanType})
}
return out
}
var _ = context.Canceled
var _ = sql.ErrNoRows
var _ = strconv.Itoa
+214
View File
@@ -0,0 +1,214 @@
package app
import (
"context"
"crypto/tls"
"database/sql"
"embed"
"encoding/json"
"errors"
"io"
"io/fs"
"log"
"net/http"
"net/smtp"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"codex-helper/internal/codex"
"codex-helper/internal/security"
"codex-helper/internal/store"
webassets "codex-helper/internal/web"
)
type App struct {
dataDir string
store *store.Store
vault *security.Vault
codex *codex.Client
server *http.Server
ctx context.Context
cancel context.CancelFunc
mu sync.RWMutex
dash Dashboard
loginAttempts sync.Map
}
func New() (*App, error) {
dir := env("DATA_DIR", "/data")
s, e := store.Open(dir)
if e != nil {
return nil, e
}
v, e := security.OpenVault(dir)
if e != nil {
return nil, e
}
ctx, cancel := context.WithCancel(context.Background())
a := &App{dataDir: dir, store: s, vault: v, ctx: ctx, cancel: cancel, dash: Dashboard{Limits: []LimitBucket{}, Usage: []UsagePoint{}, Stale: true}}
a.codex = codex.New(filepath.Join(dir, "codex"), a.onCodexNotification)
a.server = &http.Server{Addr: env("LISTEN_ADDR", ":8080"), Handler: a.routes(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second}
return a, nil
}
func env(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func (a *App) Run() error {
if e := os.MkdirAll(filepath.Join(a.dataDir, "codex"), 0700); e != nil {
return e
}
go a.keepCodex()
go a.scheduler()
go a.telegramLoop()
log.Printf("codex-helper listening on %s", a.server.Addr)
e := a.server.ListenAndServe()
if errors.Is(e, http.ErrServerClosed) {
return nil
}
return e
}
func (a *App) Close() {
a.cancel()
ctx, c := context.WithTimeout(context.Background(), 5*time.Second)
defer c()
_ = a.server.Shutdown(ctx)
_ = a.codex.Close()
_ = a.store.DB.Close()
}
func (a *App) keepCodex() {
delay := time.Second
for {
select {
case <-a.ctx.Done():
return
default:
}
if !a.codex.Connected() {
if e := a.codex.Start(a.ctx); e == nil {
ctx, c := context.WithTimeout(a.ctx, 20*time.Second)
e = a.codex.Initialize(ctx)
c()
if e == nil {
delay = time.Second
_ = a.sync(context.Background())
} else {
log.Printf("app-server initialize: %v", e)
// A live process is not necessarily an initialized process. Tear it
// down so the next iteration starts a fresh protocol session.
_ = a.codex.Close()
}
}
if !a.codex.Connected() {
time.Sleep(delay)
if delay < 30*time.Second {
delay *= 2
}
}
}
time.Sleep(time.Second)
}
}
func (a *App) onCodexNotification(method string, _ json.RawMessage) {
if method == "account/updated" || method == "account/rateLimits/updated" {
go a.sync(context.Background())
}
}
func (a *App) scheduler() {
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-t.C:
g := a.general()
if time.Now().Unix()%(int64(g.SyncMinutes)*60) < 60 {
_ = a.sync(context.Background())
}
_, _ = a.store.Cleanup(g.RetentionDays)
go a.processReminders()
}
}
}
func (a *App) routes() http.Handler {
m := http.NewServeMux()
m.HandleFunc("/health/live", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]any{"status": "ok"}) })
m.HandleFunc("/health/ready", func(w http.ResponseWriter, r *http.Request) {
if e := a.store.Health(r.Context()); e != nil {
jsonOut(w, 503, map[string]string{"error": e.Error()})
return
}
jsonOut(w, 200, map[string]any{"status": "ok", "appServer": a.codex.Connected()})
})
m.HandleFunc("/api/v1/", a.api)
sub, _ := fs.Sub(webassets.Assets, "dist")
files := http.FileServer(http.FS(sub))
m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
if _, e := fs.Stat(sub, strings.TrimPrefix(r.URL.Path, "/")); e == nil {
files.ServeHTTP(w, r)
return
}
}
b, _ := fs.ReadFile(sub, "index.html")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(b)
})
return securityHeaders(m)
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "same-origin")
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'")
next.ServeHTTP(w, r)
})
}
func jsonOut(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func decode(r *http.Request, v any) error {
defer r.Body.Close()
d := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
d.DisallowUnknownFields()
return d.Decode(v)
}
func (a *App) authed(r *http.Request) bool {
c, e := r.Cookie("session")
if e != nil {
return false
}
var x int
return a.store.DB.QueryRow("SELECT 1 FROM sessions WHERE token_hash=? AND expires_at>?", security.HashToken(c.Value), time.Now().Unix()).Scan(&x) == nil
}
func (a *App) require(w http.ResponseWriter, r *http.Request) bool {
if !a.authed(r) {
jsonOut(w, 401, map[string]string{"error": "未登录"})
return false
}
if r.Method != "GET" && r.Method != "HEAD" {
if r.Header.Get("X-Requested-With") != "codex-helper" {
jsonOut(w, 403, map[string]string{"error": "请求来源校验失败"})
return false
}
}
return true
}
// Keep an explicit embed reference visible to tooling.
var _ embed.FS
var _ = sql.ErrNoRows
var _ = strconv.Itoa
var _ = tls.VersionTLS13
var _ = smtp.SendMail
+386
View File
@@ -0,0 +1,386 @@
package app
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"net/smtp"
"strconv"
"strings"
"time"
)
func (a *App) smtpSettings() SMTPSettings {
var s SMTPSettings
a.store.GetJSON("smtp", &s)
if s.Port == 0 {
s.Port = 587
}
if s.Security == "" {
s.Security = "starttls"
}
if enc, ok := a.store.Get("smtp_password"); ok {
if password, err := a.vault.Decrypt(enc); err == nil {
s.Configured = password != "" && s.Host != ""
}
}
s.Password = ""
return s
}
func (a *App) smtpSecret() (SMTPSettings, error) {
s := a.smtpSettings()
enc, _ := a.store.Get("smtp_password")
p, e := a.vault.Decrypt(enc)
s.Password = p
s.Configured = p != "" && s.Host != ""
return s, e
}
func (a *App) smtpAPI(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
jsonOut(w, 200, a.smtpSettings())
return
}
var in SMTPSettings
if decode(r, &in) != nil || in.Host == "" || in.Port < 1 || in.Port > 65535 || in.From == "" || in.To == "" {
jsonOut(w, 400, map[string]string{"error": "SMTP 配置不完整"})
return
}
old, _ := a.smtpSecret()
if in.Password == "" {
in.Password = old.Password
}
enc, e := a.vault.Encrypt(in.Password)
if e == nil {
safe := in
safe.Password = ""
safe.Configured = in.Password != ""
e = a.store.SetJSON("smtp", safe)
}
if e == nil {
e = a.store.Set("smtp_password", enc)
}
if e != nil {
jsonOut(w, 500, map[string]string{"error": e.Error()})
return
}
in.Password = ""
in.Configured = true
jsonOut(w, 200, in)
}
func (a *App) smtpTest(w http.ResponseWriter, r *http.Request) {
s, e := a.smtpSecret()
if e == nil {
e = sendSMTP(s, "Codex Helper 测试邮件", "SMTP 配置成功,后续用量重置提醒将发送到此邮箱。")
}
if e != nil {
jsonOut(w, 502, map[string]string{"error": e.Error()})
return
}
jsonOut(w, 200, map[string]bool{"ok": true})
}
func sendSMTP(s SMTPSettings, subject, body string) error {
addr := net.JoinHostPort(s.Host, strconv.Itoa(s.Port))
var c *smtp.Client
var e error
if s.Security == "tls" {
conn, x := tls.Dial("tcp", addr, &tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12})
if x != nil {
return x
}
c, e = smtp.NewClient(conn, s.Host)
} else {
c, e = smtp.Dial(addr)
}
if e != nil {
return e
}
defer c.Close()
if s.Security == "starttls" {
if e = c.StartTLS(&tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12}); e != nil {
return e
}
}
if s.Username != "" {
if e = c.Auth(smtp.PlainAuth("", s.Username, s.Password, s.Host)); e != nil {
return e
}
}
if e = c.Mail(s.From); e != nil {
return e
}
if e = c.Rcpt(s.To); e != nil {
return e
}
w, e := c.Data()
if e != nil {
return e
}
name := s.FromName
if name == "" {
name = "Codex Helper"
}
msg := fmt.Sprintf("From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", name, s.From, s.To, subject, body)
if _, e = w.Write([]byte(msg)); e != nil {
return e
}
return w.Close()
}
func (a *App) telegramSettings() TelegramSettings {
var t TelegramSettings
a.store.GetJSON("telegram", &t)
if _, ok := a.store.Get("telegram_token"); ok {
t.Configured = true
}
return t
}
func (a *App) telegramSecret() (TelegramSettings, error) {
t := a.telegramSettings()
enc, _ := a.store.Get("telegram_token")
tok, e := a.vault.Decrypt(enc)
t.Token = tok
t.Configured = tok != ""
return t, e
}
func (a *App) telegramAPI(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
jsonOut(w, 200, a.telegramSettings())
return
}
var in TelegramSettings
if decode(r, &in) != nil {
jsonOut(w, 400, map[string]string{"error": "配置格式错误"})
return
}
old, _ := a.telegramSecret()
if in.Token == "" {
in.Token = old.Token
}
if in.Token == "" {
jsonOut(w, 400, map[string]string{"error": "Bot Token 必填"})
return
}
var me struct {
OK bool `json:"ok"`
Result struct {
FirstName string `json:"first_name"`
Username string `json:"username"`
} `json:"result"`
}
if e := tgCall(in.Token, "getMe", map[string]any{}, &me); e != nil || !me.OK {
jsonOut(w, 502, map[string]string{"error": "无法验证 Bot Token"})
return
}
in.BotName = me.Result.FirstName + " @" + me.Result.Username
enc, _ := a.vault.Encrypt(in.Token)
safe := in
safe.Token = ""
safe.Configured = true
_ = a.store.Set("telegram_token", enc)
_ = a.store.SetJSON("telegram", safe)
jsonOut(w, 200, safe)
}
func (a *App) telegramTest(w http.ResponseWriter, r *http.Request) {
t, e := a.telegramSecret()
if e == nil {
if t.ChatID == 0 {
e = fmt.Errorf("请先绑定 Chat ID")
} else {
e = tgSend(t, "Codex Helper Telegram 配置成功。")
}
}
if e != nil {
jsonOut(w, 502, map[string]string{"error": e.Error()})
return
}
jsonOut(w, 200, map[string]bool{"ok": true})
}
func tgCall(token, method string, p any, out any) error {
b, _ := json.Marshal(p)
req, e := http.NewRequestWithContext(context.Background(), "POST", "https://api.telegram.org/bot"+token+"/"+method, bytes.NewReader(b))
if e != nil {
return e
}
req.Header.Set("Content-Type", "application/json")
c := &http.Client{Timeout: 35 * time.Second}
resp, e := c.Do(req)
if e != nil {
return e
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("Telegram HTTP %d", resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
}
func tgSend(t TelegramSettings, text string) error {
var out any
params := map[string]any{"chat_id": t.ChatID, "text": text}
if t.MenuEnabled {
params["reply_markup"] = map[string]any{"keyboard": [][]map[string]string{{{"text": "当前用量"}, {"text": "重置时间"}}, {{"text": "历史概览"}, {"text": "账户信息"}}, {{"text": "立即刷新"}}}, "resize_keyboard": true}
}
return tgCall(t.Token, "sendMessage", params, &out)
}
func (a *App) telegramLoop() {
for {
select {
case <-a.ctx.Done():
return
case <-time.After(3 * time.Second):
}
t, e := a.telegramSecret()
if e != nil || !t.Configured {
continue
}
var offset int64
_ = a.store.DB.QueryRow("SELECT offset FROM telegram_updates WHERE id=1").Scan(&offset)
var out struct {
OK bool `json:"ok"`
Result []struct {
UpdateID int64 `json:"update_id"`
Message *struct {
Chat struct {
ID int64 `json:"id"`
} `json:"chat"`
Text string `json:"text"`
} `json:"message"`
} `json:"result"`
}
if tgCall(t.Token, "getUpdates", map[string]any{"offset": offset, "timeout": 25, "allowed_updates": []string{"message"}}, &out) != nil {
continue
}
for _, u := range out.Result {
offset = u.UpdateID + 1
if u.Message != nil {
a.handleTG(t, u.Message.Chat.ID, u.Message.Text)
}
}
_, _ = a.store.DB.Exec("UPDATE telegram_updates SET offset=? WHERE id=1", offset)
}
}
func (a *App) handleTG(t TelegramSettings, chat int64, text string) {
if strings.HasPrefix(text, "/bind ") {
var b struct {
Code string `json:"code"`
Expires int64 `json:"expires"`
}
if a.store.GetJSON("telegram_bind", &b) && b.Expires > time.Now().Unix() && strings.TrimSpace(strings.TrimPrefix(text, "/bind ")) == b.Code {
t.ChatID = chat
safe := t
safe.Token = ""
_ = a.store.SetJSON("telegram", safe)
_ = a.store.Set("telegram_bind", "{}")
t.ChatID = chat
message := "绑定成功。Codex 用量提醒将发送到此会话。"
if t.MenuEnabled {
message = "绑定成功。现在可以使用菜单查询 Codex 用量。"
}
_ = tgSend(t, message)
}
return
}
if chat != t.ChatID {
return
}
if !t.MenuEnabled {
return
}
if text == "立即刷新" || text == "/refresh" {
_ = a.sync(context.Background())
}
a.mu.RLock()
d := a.dash
a.mu.RUnlock()
msg := "Codex 用量\n"
switch text {
case "重置时间", "/reset":
for _, x := range d.Limits {
msg += fmt.Sprintf("%s/%s:%s\n", x.LimitID, x.WindowType, time.Unix(x.ResetsAt, 0).Format(time.RFC3339))
}
case "账户信息", "/account":
msg += fmt.Sprintf("连接:%v\n", d.Account.Connected)
if d.Account.Email != nil {
msg += "账户:" + *d.Account.Email + "\n"
}
if d.Account.PlanType != nil {
msg += "套餐:" + *d.Account.PlanType
}
case "历史概览", "/usage":
msg += fmt.Sprintf("Lifetime tokens:%s\n历史天数:%d", num(d.Summary.LifetimeTokens), len(d.Usage))
default:
for _, x := range d.Limits {
msg += fmt.Sprintf("%s/%s:%.1f%%,重置 %s\n", x.LimitID, x.WindowType, x.UsedPercent, time.Unix(x.ResetsAt, 0).Format(time.RFC3339))
}
}
_ = tgSend(t, msg)
}
func num(n *int64) string {
if n == nil {
return "暂无"
}
return fmt.Sprintf("%d", *n)
}
func (a *App) processReminders() {
g := a.general()
a.mu.RLock()
d := a.dash
a.mu.RUnlock()
now := time.Now()
for _, x := range d.Limits {
for _, kind := range []string{"before", "after"} {
if kind == "before" && !g.NotifyBefore {
continue
}
if kind == "after" && !g.NotifyAfter {
continue
}
at := time.Unix(x.ResetsAt, 0)
if kind == "before" {
at = at.Add(-time.Duration(g.BeforeMinutes) * time.Minute)
}
if now.Before(at) || now.Sub(at) > 6*time.Hour {
continue
}
key := fmt.Sprintf("%s:%s:%d:%s", x.LimitID, x.WindowType, x.ResetsAt, kind)
var exists int
if a.store.DB.QueryRow("SELECT 1 FROM notifications WHERE dedupe_key=? AND status='sent'", key).Scan(&exists) == nil {
continue
}
body := fmt.Sprintf("Codex %s/%s 当前用量 %.1f%%,重置时间 %s。", x.LimitID, x.WindowType, x.UsedPercent, time.Unix(x.ResetsAt, 0).Format(time.RFC3339))
ok := true
errs := []string{}
if t, e := a.telegramSecret(); e == nil && t.Enabled && t.ChatID != 0 {
if e = tgSend(t, body); e != nil {
ok = false
errs = append(errs, e.Error())
}
}
if s, e := a.smtpSecret(); e == nil && s.Enabled {
if e = sendSMTP(s, "Codex 用量重置提醒", body); e != nil {
ok = false
errs = append(errs, e.Error())
}
}
status := "sent"
var sent any = time.Now().Unix()
if !ok {
status = "failed"
sent = nil
}
_, _ = a.store.DB.Exec(`INSERT INTO notifications(dedupe_key,channel,kind,status,attempts,last_error,scheduled_at,sent_at)
VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(dedupe_key) DO UPDATE SET
status=excluded.status,
attempts=notifications.attempts+1,
last_error=excluded.last_error,
sent_at=excluded.sent_at`, key, "configured", kind, status, 1, strings.Join(errs, "; "), at.Unix(), sent)
}
}
}
var _ = bufio.ErrInvalidUnreadByte
+81
View File
@@ -0,0 +1,81 @@
package app
type GeneralSettings struct {
Timezone string `json:"timezone"`
Theme string `json:"theme"`
SyncMinutes int `json:"syncMinutes"`
RetentionDays int `json:"retentionDays"`
BeforeMinutes int `json:"beforeMinutes"`
NotifyBefore bool `json:"notifyBefore"`
NotifyAfter bool `json:"notifyAfter"`
}
type SMTPSettings struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
From string `json:"from"`
FromName string `json:"fromName"`
To string `json:"to"`
Security string `json:"security"`
Enabled bool `json:"enabled"`
Configured bool `json:"configured"`
}
type TelegramSettings struct {
Token string `json:"token,omitempty"`
ChatID int64 `json:"chatId"`
Enabled bool `json:"enabled"`
MenuEnabled bool `json:"menuEnabled"`
Configured bool `json:"configured"`
BotName string `json:"botName,omitempty"`
}
type AccountView struct {
Email *string `json:"email"`
AuthMode *string `json:"authMode"`
PlanType *string `json:"planType"`
Connected bool `json:"connected"`
}
type LimitWindow struct {
UsedPercent float64 `json:"usedPercent"`
WindowDurationMins int `json:"windowDurationMinutes"`
ResetsAt int64 `json:"resetsAt"`
}
type LimitBucket struct {
LimitID string `json:"limitId"`
LimitName *string `json:"limitName"`
WindowType string `json:"windowType"`
UsedPercent float64 `json:"usedPercent"`
WindowDurationMinutes int `json:"windowDurationMinutes"`
ResetsAt int64 `json:"resetsAt"`
PlanType *string `json:"planType"`
}
type UsageSummary struct {
LifetimeTokens *int64 `json:"lifetimeTokens"`
PeakDailyTokens *int64 `json:"peakDailyTokens"`
LongestRunningTurnSec *int64 `json:"longestRunningTurnSec"`
CurrentStreakDays *int `json:"currentStreakDays"`
LongestStreakDays *int `json:"longestStreakDays"`
CallCount *int64 `json:"callCount"`
InputTokens *int64 `json:"inputTokens"`
OutputTokens *int64 `json:"outputTokens"`
}
type UsagePoint struct {
Date string `json:"date"`
TotalTokens int64 `json:"totalTokens"`
CallCount *int64 `json:"callCount"`
InputTokens *int64 `json:"inputTokens"`
OutputTokens *int64 `json:"outputTokens"`
}
type Dashboard struct {
Account AccountView `json:"account"`
Limits []LimitBucket `json:"limits"`
Summary UsageSummary `json:"summary"`
Usage []UsagePoint `json:"usage"`
FetchedAt int64 `json:"fetchedAt"`
Stale bool `json:"stale"`
LastError string `json:"lastError,omitempty"`
}
func defaults() GeneralSettings {
return GeneralSettings{Timezone: "UTC", Theme: "system", SyncMinutes: 5, RetentionDays: 90, BeforeMinutes: 30, NotifyBefore: true, NotifyAfter: true}
}
+165
View File
@@ -0,0 +1,165 @@
package codex
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"sync"
"sync/atomic"
"time"
)
type Client struct {
mu sync.Mutex
cmd *exec.Cmd
in io.WriteCloser
pending map[int64]chan envelope
id atomic.Int64
connected bool
configDir string
notify func(string, json.RawMessage)
}
type envelope struct {
ID *int64 `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error any `json:"error,omitempty"`
}
func New(configDir string, notify func(string, json.RawMessage)) *Client {
return &Client{pending: map[int64]chan envelope{}, configDir: configDir, notify: notify}
}
func (c *Client) Start(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.connected {
return nil
}
cmd := exec.CommandContext(ctx, "codex", "app-server")
cmd.Env = append(os.Environ(), "CODEX_HOME="+c.configDir)
out, e := cmd.StdoutPipe()
if e != nil {
return e
}
in, e := cmd.StdinPipe()
if e != nil {
return e
}
cmd.Stderr = os.Stderr
if e = cmd.Start(); e != nil {
return e
}
c.cmd, c.in, c.connected = cmd, in, true
go c.read(cmd, out)
go func() { _ = cmd.Wait(); c.failAll(cmd) }()
return nil
}
func (c *Client) Initialize(ctx context.Context) error {
var out any
if e := c.Call(ctx, "initialize", map[string]any{"clientInfo": map[string]any{"name": "codex-helper", "title": "Codex Helper", "version": "0.1.0"}, "capabilities": map[string]any{}}, &out); e != nil {
return e
}
return c.send(map[string]any{"method": "initialized", "params": map[string]any{}})
}
func (c *Client) read(cmd *exec.Cmd, r io.Reader) {
s := bufio.NewScanner(r)
s.Buffer(make([]byte, 64*1024), 8*1024*1024)
for s.Scan() {
var e envelope
if json.Unmarshal(s.Bytes(), &e) != nil {
continue
}
if e.ID != nil {
c.mu.Lock()
ch := c.pending[*e.ID]
delete(c.pending, *e.ID)
c.mu.Unlock()
if ch != nil {
ch <- e
}
} else if e.Method != "" && c.notify != nil {
go c.notify(e.Method, e.Params)
}
}
c.failAll(cmd)
}
func (c *Client) failAll(cmd *exec.Cmd) {
c.mu.Lock()
defer c.mu.Unlock()
// A previous process may finish after its replacement has started. It must
// not mark the new connection as disconnected or fail its pending calls.
if c.cmd != cmd {
return
}
c.connected = false
c.cmd = nil
c.in = nil
for id, ch := range c.pending {
ch <- envelope{Error: "app-server disconnected"}
delete(c.pending, id)
}
}
func (c *Client) send(v any) error {
c.mu.Lock()
defer c.mu.Unlock()
if !c.connected {
return errors.New("app-server unavailable")
}
b, _ := json.Marshal(v)
b = append(b, '\n')
_, e := c.in.Write(b)
return e
}
func (c *Client) Call(ctx context.Context, method string, params any, out any) error {
id := c.id.Add(1)
ch := make(chan envelope, 1)
c.mu.Lock()
c.pending[id] = ch
c.mu.Unlock()
if e := c.send(map[string]any{"id": id, "method": method, "params": params}); e != nil {
return e
}
select {
case e := <-ch:
if e.Error != nil {
return fmt.Errorf("app-server %s: %v", method, e.Error)
}
if out != nil {
return json.Unmarshal(e.Result, out)
}
return nil
case <-ctx.Done():
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
return ctx.Err()
case <-time.After(20 * time.Second):
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
return errors.New("app-server timeout")
}
}
func (c *Client) Close() error {
c.mu.Lock()
cmd := c.cmd
c.cmd = nil
c.in = nil
c.connected = false
for id, ch := range c.pending {
ch <- envelope{Error: "app-server disconnected"}
delete(c.pending, id)
}
c.mu.Unlock()
if cmd != nil && cmd.Process != nil {
return cmd.Process.Kill()
}
return nil
}
func (c *Client) Connected() bool { c.mu.Lock(); defer c.mu.Unlock(); return c.connected }
+105
View File
@@ -0,0 +1,105 @@
package security
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"os"
"path/filepath"
"golang.org/x/crypto/argon2"
)
func Random(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func HashToken(v string) string { h := sha256.Sum256([]byte(v)); return fmt.Sprintf("%x", h[:]) }
func Password(p string) string {
salt := make([]byte, 16)
_, _ = rand.Read(salt)
h := argon2.IDKey([]byte(p), salt, 3, 64*1024, 2, 32)
return base64.RawStdEncoding.EncodeToString(salt) + "." + base64.RawStdEncoding.EncodeToString(h)
}
func VerifyPassword(encoded, p string) bool {
parts := split(encoded, '.')
if len(parts) != 2 {
return false
}
salt, e1 := base64.RawStdEncoding.DecodeString(parts[0])
want, e2 := base64.RawStdEncoding.DecodeString(parts[1])
if e1 != nil || e2 != nil {
return false
}
got := argon2.IDKey([]byte(p), salt, 3, 64*1024, 2, 32)
return subtle.ConstantTimeCompare(got, want) == 1
}
func split(s string, sep byte) []string {
for i := range s {
if s[i] == sep {
return []string{s[:i], s[i+1:]}
}
}
return nil
}
type Vault struct{ key []byte }
func OpenVault(dir string) (*Vault, error) {
p := filepath.Join(dir, "secret.key")
b, e := os.ReadFile(p)
if errors.Is(e, os.ErrNotExist) {
b = make([]byte, 32)
if _, e = rand.Read(b); e == nil {
e = os.WriteFile(p, b, 0600)
}
}
if e != nil {
return nil, e
}
if len(b) != 32 {
return nil, errors.New("invalid secret key")
}
return &Vault{b}, nil
}
func (v *Vault) Encrypt(s string) (string, error) {
if s == "" {
return "", nil
}
b, e := aes.NewCipher(v.key)
if e != nil {
return "", e
}
g, e := cipher.NewGCM(b)
if e != nil {
return "", e
}
n := make([]byte, g.NonceSize())
if _, e = rand.Read(n); e != nil {
return "", e
}
out := g.Seal(n, n, []byte(s), nil)
return base64.RawStdEncoding.EncodeToString(out), nil
}
func (v *Vault) Decrypt(s string) (string, error) {
if s == "" {
return "", nil
}
raw, e := base64.RawStdEncoding.DecodeString(s)
if e != nil {
return "", e
}
b, _ := aes.NewCipher(v.key)
g, _ := cipher.NewGCM(b)
if len(raw) < g.NonceSize() {
return "", errors.New("invalid ciphertext")
}
p, e := g.Open(nil, raw[:g.NonceSize()], raw[g.NonceSize():], nil)
return string(p), e
}
@@ -0,0 +1,27 @@
package security
import "testing"
func TestPassword(t *testing.T) {
h := Password("a sufficiently long password")
if !VerifyPassword(h, "a sufficiently long password") {
t.Fatal("valid password rejected")
}
if VerifyPassword(h, "wrong password") {
t.Fatal("wrong password accepted")
}
}
func TestVault(t *testing.T) {
v, e := OpenVault(t.TempDir())
if e != nil {
t.Fatal(e)
}
x, e := v.Encrypt("secret")
if e != nil {
t.Fatal(e)
}
got, e := v.Decrypt(x)
if e != nil || got != "secret" {
t.Fatalf("round trip: %q %v", got, e)
}
}
+99
View File
@@ -0,0 +1,99 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
_ "modernc.org/sqlite"
)
type Store struct{ DB *sql.DB }
func Open(dir string) (*Store, error) {
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", filepath.Join(dir, "codex-helper.db")+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
if err != nil {
return nil, err
}
s := &Store{DB: db}
if err = s.migrate(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *Store) migrate() error {
_, err := s.DB.Exec(`
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS admin (id INTEGER PRIMARY KEY CHECK(id=1), username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS daily_usage (date TEXT PRIMARY KEY, total_tokens INTEGER NOT NULL, fetched_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS limit_snapshots (id INTEGER PRIMARY KEY AUTOINCREMENT, limit_id TEXT NOT NULL, window_type TEXT NOT NULL, used_percent REAL NOT NULL, duration_mins INTEGER NOT NULL, resets_at INTEGER NOT NULL, fetched_at INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS idx_limits_time ON limit_snapshots(fetched_at);
CREATE TABLE IF NOT EXISTS notifications (dedupe_key TEXT PRIMARY KEY, channel TEXT NOT NULL, kind TEXT NOT NULL, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT, scheduled_at INTEGER NOT NULL, sent_at INTEGER);
CREATE TABLE IF NOT EXISTS telegram_updates (id INTEGER PRIMARY KEY CHECK(id=1), offset INTEGER NOT NULL DEFAULT 0);
INSERT OR IGNORE INTO telegram_updates(id,offset) VALUES(1,0);
`)
return err
}
func (s *Store) Get(key string) (string, bool) {
var v string
err := s.DB.QueryRow("SELECT value FROM settings WHERE key=?", key).Scan(&v)
return v, err == nil
}
func (s *Store) Set(key, value string) error {
_, err := s.DB.Exec("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", key, value, time.Now().Unix())
return err
}
func (s *Store) SetJSON(key string, v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
return s.Set(key, string(b))
}
func (s *Store) GetJSON(key string, v any) bool {
raw, ok := s.Get(key)
return ok && json.Unmarshal([]byte(raw), v) == nil
}
func (s *Store) Initialized() bool { _, ok := s.Get("initialized"); return ok }
func (s *Store) Cleanup(days int) (int64, error) {
cutoff := time.Now().AddDate(0, 0, -days).Unix()
var n int64
for _, q := range []string{"DELETE FROM limit_snapshots WHERE fetched_at < ?", "DELETE FROM notifications WHERE scheduled_at < ?"} {
r, e := s.DB.Exec(q, cutoff)
if e != nil {
return n, e
}
x, _ := r.RowsAffected()
n += x
}
r, e := s.DB.Exec("DELETE FROM daily_usage WHERE date < ?", time.Now().AddDate(0, 0, -days).Format("2006-01-02"))
if e == nil {
x, _ := r.RowsAffected()
n += x
}
return n, e
}
func (s *Store) Health(ctx context.Context) error {
if err := s.DB.PingContext(ctx); err != nil {
return fmt.Errorf("sqlite: %w", err)
}
return nil
}
// Backup creates a transactionally consistent standalone SQLite snapshot.
// VACUUM INTO includes committed WAL contents without interrupting writers.
func (s *Store) Backup(ctx context.Context, path string) error {
_, err := s.DB.ExecContext(ctx, "VACUUM INTO ?", path)
return err
}
+57
View File
@@ -0,0 +1,57 @@
package store
import (
"context"
"database/sql"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
func TestSettings(t *testing.T) {
s, e := Open(t.TempDir())
if e != nil {
t.Fatal(e)
}
defer s.DB.Close()
if s.Initialized() {
t.Fatal("fresh DB initialized")
}
if e = s.Set("initialized", "true"); e != nil {
t.Fatal(e)
}
if !s.Initialized() {
t.Fatal("setting not persisted")
}
}
func TestBackupIncludesCommittedWALData(t *testing.T) {
dir := t.TempDir()
s, err := Open(dir)
if err != nil {
t.Fatal(err)
}
defer s.DB.Close()
if err = s.Set("latest", "committed-in-wal"); err != nil {
t.Fatal(err)
}
backupPath := filepath.Join(dir, "snapshot.db")
if err = s.Backup(context.Background(), backupPath); err != nil {
t.Fatal(err)
}
snapshot, err := sql.Open("sqlite", backupPath)
if err != nil {
t.Fatal(err)
}
defer snapshot.Close()
var value string
if err = snapshot.QueryRow("SELECT value FROM settings WHERE key='latest'").Scan(&value); err != nil {
t.Fatal(err)
}
if value != "committed-in-wal" {
t.Fatalf("backup value = %q", value)
}
}
+3
View File
@@ -0,0 +1,3 @@
<script type="module" crossorigin src="/assets/index-Bcayo4C9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D4ZT533H.css">
<div id="root"></div>
+6
View File
@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed dist/*
var Assets embed.FS
+13
View File
@@ -0,0 +1,13 @@
services:
codex-helper:
build: .
image: codex-helper:latest
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- codex-helper-data:/data
environment:
LISTEN_ADDR: ":8080"
volumes:
codex-helper-data:
+1
View File
@@ -0,0 +1 @@
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
+3217
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
{"name":"codex-helper-ui","private":true,"version":"0.1.0","type":"module","scripts":{"dev":"vite","build":"tsc -b && vite build","test":"vitest run"},"dependencies":{"@vitejs/plugin-react":"latest","vite":"8.2.1","typescript":"7.0.2","react":"19.2.7","react-dom":"19.2.7","react-router-dom":"7.18.2","recharts":"3.1.2","lucide-react":"0.539.0"},"devDependencies":{"@types/react":"19.1.10","@types/react-dom":"19.1.7","vitest":"3.2.4"}}
+3
View File
@@ -0,0 +1,3 @@
export async function api<T>(path:string,init:RequestInit={}):Promise<T>{const r=await fetch('/api/v1/'+path,{credentials:'same-origin',...init,headers:{'Content-Type':'application/json','X-Requested-With':'codex-helper',...(init.headers||{})}});const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||`HTTP ${r.status}`);return j}
export const post=<T>(p:string,v:unknown={})=>api<T>(p,{method:'POST',body:JSON.stringify(v)})
export const put=<T>(p:string,v:unknown)=>api<T>(p,{method:'PUT',body:JSON.stringify(v)})
+28
View File
@@ -0,0 +1,28 @@
import React,{useEffect,useState} from 'react'
import{createRoot}from'react-dom/client'
import{BrowserRouter,Navigate,Route,Routes,useNavigate}from'react-router-dom'
import{Activity,Bell,ChartNoAxesCombined,Database,LogOut,Moon,RefreshCw,Settings,Sun,Zap}from'lucide-react'
import{Area,AreaChart,CartesianGrid,ResponsiveContainer,Tooltip,XAxis,YAxis}from'recharts'
import{api,post}from'./api'
import'./styles.css'
type Status={initialized:boolean;appServer:boolean;version:string};type Limit={limitId:string;limitName?:string;windowType:string;usedPercent:number;windowDurationMinutes:number;resetsAt:number};type Point={date:string;totalTokens:number};type Dash={account:{email?:string;planType?:string;connected:boolean};limits:Limit[];summary:{lifetimeTokens?:number;peakDailyTokens?:number;currentStreakDays?:number;callCount?:number};usage:Point[];fetchedAt:number;stale:boolean;lastError?:string};
function App(){const[s,setS]=useState<Status|null>(null);const[auth,setAuth]=useState<boolean|null>(null);useEffect(()=>{api<Status>('system/status').then(x=>{setS(x);if(x.initialized)api('auth/me').then(()=>setAuth(true)).catch(()=>setAuth(false));else setAuth(false)})},[]);if(!s||auth===null)return <Splash/>;if(!s.initialized)return <Setup onDone={()=>{setS({...s,initialized:true});setAuth(true)}}/>;return <BrowserRouter><Routes>{auth?<Route path="*" element={<Shell logout={()=>setAuth(false)}/>}/>:<Route path="*" element={<Login done={()=>setAuth(true)}/>}/>}</Routes></BrowserRouter>}
const Splash=()=> <main className="center"><div className="logo"><Zap/> Codex Helper</div><div className="spinner"/></main>;
function Setup({onDone}:{onDone:()=>void}){const[form,set]=useState({username:'admin',password:'',timezone:Intl.DateTimeFormat().resolvedOptions().timeZone});const[err,setErr]=useState('');async function submit(e:React.FormEvent){e.preventDefault();try{await post('setup',form);onDone()}catch(x){setErr((x as Error).message)}}return <main className="setup"><section className="hero"><div className="logo"><Zap/> Codex Helper</div><h1>连接你的 Codex<br/><span>看见每一次增长</span></h1><p>一个容器内完成用量洞察、重置提醒与账户管理。</p></section><form className="panel form" onSubmit={submit}><small>首次初始化 · 1 / 1</small><h2>创建管理员</h2><label>用户名<input value={form.username} onChange={e=>set({...form,username:e.target.value})}/></label><label>密码<input type="password" minLength={10} value={form.password} onChange={e=>set({...form,password:e.target.value})} placeholder="至少 10 位"/></label><label>时区<input value={form.timezone} onChange={e=>set({...form,timezone:e.target.value})}/></label>{err&&<p className="error">{err}</p>}<button>完成初始化</button><p className="hint">Codex、Telegram 与 SMTP 可在进入系统后通过设置中心完成。</p></form></main>}
function Login({done}:{done:()=>void}){const[u,setU]=useState('admin'),[p,setP]=useState(''),[e,setE]=useState('');return <main className="center"><form className="panel form login" onSubmit={async x=>{x.preventDefault();try{await post('auth/login',{username:u,password:p});done()}catch(q){setE((q as Error).message)}}}><div className="logo"><Zap/> Codex Helper</div><h2>欢迎回来</h2><label>用户名<input value={u} onChange={x=>setU(x.target.value)}/></label><label>密码<input type="password" value={p} onChange={x=>setP(x.target.value)}/></label>{e&&<p className="error">{e}</p>}<button>登录</button></form></main>}
function Shell({logout}:{logout:()=>void}){const nav=useNavigate();const[theme,setTheme]=useState(localStorage.theme||'system');useEffect(()=>{document.documentElement.dataset.theme=theme==='system'?(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'):theme;localStorage.theme=theme},[theme]);return <div className="app"><aside><div className="logo"><Zap/> Codex Helper</div><nav><button onClick={()=>nav('/')}><Activity/>总览</button><button onClick={()=>nav('/history')}><ChartNoAxesCombined/>历史用量</button><button onClick={()=>nav('/settings')}><Settings/>设置中心</button></nav><div className="aside-bottom"><button onClick={()=>setTheme(theme==='dark'?'light':'dark')}>{theme==='dark'?<Sun/>:<Moon/>}切换主题</button><button onClick={async()=>{await post('auth/logout');logout()}}><LogOut/>退出</button></div></aside><section className="content"><Routes><Route path="/" element={<Dashboard/>}/><Route path="/history" element={<History/>}/><Route path="/settings" element={<SettingsPage/>}/><Route path="*" element={<Navigate to="/"/>}/></Routes></section></div>}
function useDash(){const[d,setD]=useState<Dash|null>(null),[e,setE]=useState('');const load=()=>api<Dash>('dashboard').then(setD).catch(x=>setE(x.message));useEffect(()=>{load();const t=setInterval(load,30000);return()=>clearInterval(t)},[]);return{d,e,load}}
function Dashboard(){const{d,e,load}=useDash();if(!d)return <Splash/>;return <><Header title="用量总览" sub="实时掌握 Codex 账户限额与重置周期"><button className="secondary" onClick={async()=>{await post('sync');load()}}><RefreshCw/>立即刷新</button></Header>{e&&<div className="banner">{e}</div>}<div className="account panel"><span className={'dot '+(d.account.connected?'ok':'')}/><div><small>CODEx ACCOUNT</small><b>{d.account.email||'尚未连接'}</b></div><span className="badge">{d.account.planType||'未识别套餐'}</span><span className="fresh">更新于 {new Date(d.fetchedAt*1000).toLocaleString()}</span></div><div className="limits">{d.limits.map((x,i)=><LimitCard key={i} x={x}/>)}</div><div className="stats"><Stat label="累计 Tokens" v={num(d.summary.lifetimeTokens)}/><Stat label="单日峰值" v={num(d.summary.peakDailyTokens)}/><Stat label="连续使用" v={d.summary.currentStreakDays==null?'暂无':d.summary.currentStreakDays+' 天'}/><Stat label="调用次数" v="暂不提供" muted/></div><Chart data={d.usage}/></>}
function LimitCard({x}:{x:Limit}){const left=Math.max(0,100-x.usedPercent);return <div className="panel limit"><div><small>{x.limitName||x.limitId} · {x.windowType}</small><strong>{x.usedPercent.toFixed(1)}<i>%</i></strong><span>已使用</span></div><div className="ring" style={{background:`conic-gradient(var(--accent) ${x.usedPercent}%,var(--track) 0)`}}><div>{left.toFixed(0)}%<small>剩余</small></div></div><footer><span>{x.windowDurationMinutes} 分钟窗口</span><span>重置 {new Date(x.resetsAt*1000).toLocaleString()}</span></footer></div>}
const Stat=({label,v,muted=false}:{label:string,v:string,muted?:boolean})=><div className="panel stat"><small>{label}</small><b className={muted?'muted':''}>{v}</b></div>;
const Chart=({data}:{data:Point[]})=><div className="panel chart"><div><h3>每日 Token 趋势</h3><small>Codex app server 提供的每日总量</small></div><ResponsiveContainer width="100%" height={280}><AreaChart data={data}><defs><linearGradient id="a" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#5ee7f7" stopOpacity={.35}/><stop offset="1" stopColor="#5ee7f7" stopOpacity={0}/></linearGradient></defs><CartesianGrid stroke="var(--grid)" vertical={false}/><XAxis dataKey="date" stroke="var(--muted)"/><YAxis stroke="var(--muted)"/><Tooltip contentStyle={{background:'var(--panel)',border:'1px solid var(--border)',borderRadius:12}}/><Area type="monotone" dataKey="totalTokens" stroke="#4dd8ed" fill="url(#a)" strokeWidth={2}/></AreaChart></ResponsiveContainer></div>;
function History(){const{d}=useDash();return <><Header title="历史用量" sub="最多展示本地保留期内的每日总 Token"/>{d?<><div className="notice"><Database/> 调用次数、输入 Token 和输出 Token 暂未由 Codex app server 提供。</div><Chart data={d.usage}/></>:<Splash/>}</>}
function SettingsPage(){const[tab,setTab]=useState('general');return <><Header title="设置中心" sub="所有运行期配置均可在这里完成"/><div className="settings"><div className="tabs"><button className={tab==='general'?'active':''} onClick={()=>setTab('general')}><Settings/>通用</button><button className={tab==='codex'?'active':''} onClick={()=>setTab('codex')}><Zap/>Codex</button><button className={tab==='telegram'?'active':''} onClick={()=>setTab('telegram')}><Bell/>Telegram</button><button className={tab==='smtp'?'active':''} onClick={()=>setTab('smtp')}><Bell/>SMTP 邮件</button></div>{tab==='general'?<General/>:tab==='codex'?<CodexSettings/>:tab==='telegram'?<Telegram/>:<SMTP/>}</div></>}
function General(){const[v,setV]=useState<any>(null),[msg,setMsg]=useState('');useEffect(()=>{api('settings/general').then(setV)},[]);if(!v)return <Splash/>;return <form className="panel form wide" onSubmit={async e=>{e.preventDefault();try{await api('settings/general',{method:'PUT',body:JSON.stringify(v)});setMsg('设置已保存')}catch(x){setMsg((x as Error).message)}}}><h2>通用设置</h2><div className="grid2"><label>时区<input value={v.timezone} onChange={e=>setV({...v,timezone:e.target.value})}/></label><label>主题<select value={v.theme} onChange={e=>setV({...v,theme:e.target.value})}><option value="system">跟随系统</option><option value="dark">深色</option><option value="light">浅色</option></select></label><label>同步间隔(分钟)<input type="number" min="1" max="60" value={v.syncMinutes} onChange={e=>setV({...v,syncMinutes:+e.target.value})}/></label><label>历史保留(天)<select value={v.retentionDays} onChange={e=>setV({...v,retentionDays:+e.target.value})}>{[30,60,90,180,365].map(x=><option key={x}>{x}</option>)}</select></label><label>提前提醒(分钟)<input type="number" min="1" max="1440" value={v.beforeMinutes} onChange={e=>setV({...v,beforeMinutes:+e.target.value})}/></label><label className="checks"><input type="checkbox" checked={v.notifyBefore} onChange={e=>setV({...v,notifyBefore:e.target.checked})}/>重置前提醒 <input type="checkbox" checked={v.notifyAfter} onChange={e=>setV({...v,notifyAfter:e.target.checked})}/>重置后确认</label></div><button>保存设置</button>{msg&&<p className="hint">{msg}</p>}</form>}
function CodexSettings(){const[result,setResult]=useState<any>(null),[err,setErr]=useState('');return <div className="panel form wide"><h2>Codex 账户</h2><p>使用设备码登录,适合 Docker 与远程部署。Codex app server 将安全管理登录凭据。</p><button onClick={async()=>{try{setResult(await post('codex/login/device'));setErr('')}catch(x){setErr((x as Error).message)}}}>生成设备码</button>{result&&<div className="codebox"><span>访问 <a href={result.verificationUrl} target="_blank">{result.verificationUrl}</a></span><strong>{result.userCode}</strong></div>}{err&&<p className="error">{err}</p>}<button className="danger" onClick={()=>post('codex/logout')}>退出 Codex 账户</button></div>}
function Telegram(){const[v,setV]=useState<any>(null),[msg,setMsg]=useState(''),[code,setCode]=useState('');useEffect(()=>{api('settings/telegram').then(setV)},[]);if(!v)return <Splash/>;return <form className="panel form wide" onSubmit={async e=>{e.preventDefault();try{const x:any=await api('settings/telegram',{method:'PUT',body:JSON.stringify(v)});setV(x);setMsg('Bot 已验证并保存')}catch(x){setMsg((x as Error).message)}}}><h2>Telegram Bot</h2><label>Bot Token<input type="password" value={v.token||''} onChange={e=>setV({...v,token:e.target.value})} placeholder={v.configured?'已配置,留空表示不修改':'从 BotFather 获取'}/></label><div className="checks"><label><input type="checkbox" checked={v.enabled} onChange={e=>setV({...v,enabled:e.target.checked})}/>启用提醒</label><label><input type="checkbox" checked={v.menuEnabled} onChange={e=>setV({...v,menuEnabled:e.target.checked})}/>启用查询菜单</label></div><button>验证并保存</button><div className="actions"><button type="button" className="secondary" onClick={async()=>{const x:any=await post('settings/telegram/bind');setCode(x.code)}}>生成绑定码</button><button type="button" className="secondary" onClick={async()=>{try{await post('settings/telegram/test');setMsg('测试消息已发送')}catch(x){setMsg((x as Error).message)}}}>发送测试</button></div>{code&&<div className="codebox"><span>向 Bot 发送</span><strong>/bind {code}</strong></div>}{msg&&<p className="hint">{msg}</p>}</form>}
function SMTP(){const[v,setV]=useState<any>(null),[msg,setMsg]=useState('');useEffect(()=>{api('settings/smtp').then(setV)},[]);if(!v)return <Splash/>;const save=async(e:React.FormEvent)=>{e.preventDefault();try{setV(await api('settings/smtp',{method:'PUT',body:JSON.stringify(v)}));setMsg('SMTP 设置已保存')}catch(x){setMsg((x as Error).message)}};return <form className="panel form wide" onSubmit={save}><h2>SMTP 邮件</h2><div className="grid2"><label>服务器<input value={v.host} onChange={e=>setV({...v,host:e.target.value})} placeholder="smtp.example.com"/></label><label>端口<input type="number" value={v.port} onChange={e=>setV({...v,port:+e.target.value})}/></label><label>用户名<input value={v.username} onChange={e=>setV({...v,username:e.target.value})}/></label><label>密码<input type="password" value={v.password||''} onChange={e=>setV({...v,password:e.target.value})} placeholder={v.configured?'已配置,留空不修改':''}/></label><label>加密<select value={v.security} onChange={e=>setV({...v,security:e.target.value})}><option value="starttls">STARTTLS</option><option value="tls">隐式 TLS</option><option value="none">无加密</option></select></label><label>发件名称<input value={v.fromName} onChange={e=>setV({...v,fromName:e.target.value})}/></label><label>发件地址<input type="email" value={v.from} onChange={e=>setV({...v,from:e.target.value})}/></label><label>收件地址<input type="email" value={v.to} onChange={e=>setV({...v,to:e.target.value})}/></label></div><label className="checks"><input type="checkbox" checked={v.enabled} onChange={e=>setV({...v,enabled:e.target.checked})}/>启用邮件提醒</label><div className="actions"><button>保存</button><button type="button" className="secondary" onClick={async()=>{try{await post('settings/smtp/test');setMsg('测试邮件已发送')}catch(x){setMsg((x as Error).message)}}}>发送测试邮件</button></div>{msg&&<p className="hint">{msg}</p>}</form>}
function Header({title,sub,children}:{title:string,sub:string,children?:React.ReactNode}){return <header><div><h1>{title}</h1><p>{sub}</p></div>{children}</header>}
const num=(v?:number)=>v==null?'暂无':new Intl.NumberFormat('zh-CN',{notation:'compact',maximumFractionDigits:1}).format(v);
createRoot(document.getElementById('root')!).render(<React.StrictMode><App/></React.StrictMode>)
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+1
View File
@@ -0,0 +1 @@
{"compilerOptions":{"target":"ES2022","useDefineForClassFields":true,"lib":["ES2022","DOM","DOM.Iterable"],"allowJs":false,"skipLibCheck":true,"esModuleInterop":true,"allowSyntheticDefaultImports":true,"strict":true,"forceConsistentCasingInFileNames":true,"module":"ESNext","moduleResolution":"Bundler","resolveJsonModule":true,"isolatedModules":true,"noEmit":true,"jsx":"react-jsx"},"include":["src"],"references":[]}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: { outDir: '../backend/internal/web/dist', emptyOutDir: true },
server: { proxy: { '/api': 'http://localhost:8080', '/health': 'http://localhost:8080' } },
})