Initial implementation of Codex Helper
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed dist/*
|
||||
var Assets embed.FS
|
||||
Reference in New Issue
Block a user