feat: 添加用户注册登录功能,实现数据和配置的用户隔离
- 新增 users 和 user_settings 表,repositories/analyses/review_notes 添加 user_id 列 - 实现基于邮箱+密码的注册登录,密码使用 bcrypt 哈希 - 使用 gin-contrib/sessions cookie-based session 管理 - 所有仓库、分析记录、review notes 按用户隔离 - 用户设置(LLM 配置、review 参数、缓存配置)独立存储 - 新增登录/注册页面,导航栏显示用户邮箱和退出按钮 - 前端 fetch 请求统一添加 credentials: 'same-origin' - 支持 SESSION_SECRET 环境变量配置会话密钥
This commit is contained in:
+16
-6
@@ -1,21 +1,31 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
GinMode string
|
||||
DataDir string
|
||||
Port string
|
||||
GinMode string
|
||||
DataDir string
|
||||
SessionSecret string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
cfg := &Config{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
GinMode: getEnv("GIN_MODE", "debug"),
|
||||
DataDir: getEnv("DATA_DIR", "data"),
|
||||
Port: getEnv("PORT", "8080"),
|
||||
GinMode: getEnv("GIN_MODE", "debug"),
|
||||
DataDir: getEnv("DATA_DIR", "data"),
|
||||
SessionSecret: getEnv("SESSION_SECRET", ""),
|
||||
}
|
||||
// Generate random session secret if not provided
|
||||
if cfg.SessionSecret == "" {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
cfg.SessionSecret = hex.EncodeToString(b)
|
||||
}
|
||||
// Ensure data directories exist
|
||||
os.MkdirAll(cfg.DataDir, 0o755)
|
||||
|
||||
+64
-1
@@ -42,9 +42,23 @@ func (db *DB) migrate() error {
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, key)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS repositories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
local_path TEXT NOT NULL,
|
||||
size_bytes INTEGER DEFAULT 0,
|
||||
cloned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -52,6 +66,7 @@ func (db *DB) migrate() error {
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS analyses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
repo_id INTEGER REFERENCES repositories(id),
|
||||
type TEXT NOT NULL,
|
||||
base_ref TEXT NOT NULL,
|
||||
@@ -61,6 +76,7 @@ func (db *DB) migrate() error {
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS review_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
analysis_id INTEGER REFERENCES analyses(id),
|
||||
scope TEXT NOT NULL,
|
||||
scope_key TEXT NOT NULL,
|
||||
@@ -74,6 +90,53 @@ func (db *DB) migrate() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Run incremental migrations for existing databases
|
||||
if err := db.migrateV2(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV2 adds user_id columns to existing tables and removes the old UNIQUE constraint on url.
|
||||
func (db *DB) migrateV2() error {
|
||||
migrations := []string{
|
||||
`ALTER TABLE repositories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE`,
|
||||
`ALTER TABLE analyses ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE`,
|
||||
`ALTER TABLE review_notes ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE`,
|
||||
}
|
||||
for _, m := range migrations {
|
||||
// ALTER TABLE ADD COLUMN will fail if column already exists; ignore the error
|
||||
db.conn.Exec(m)
|
||||
}
|
||||
// Recreate repositories table without the UNIQUE constraint on url (same url can be cloned by different users)
|
||||
// SQLite doesn't support DROP CONSTRAINT, so we use a workaround
|
||||
var hasOldUnique int
|
||||
db.conn.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='repositories_old'`).Scan(&hasOldUnique)
|
||||
if hasOldUnique == 0 {
|
||||
// Check if the UNIQUE constraint exists by trying to detect the old schema
|
||||
// We'll just do the migration idempotently
|
||||
db.conn.Exec(`CREATE TABLE IF NOT EXISTS repositories_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
local_path TEXT NOT NULL,
|
||||
size_bytes INTEGER DEFAULT 0,
|
||||
cloned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
db.conn.Exec(`INSERT OR IGNORE INTO repositories_new (id, user_id, url, local_path, size_bytes, cloned_at, last_used)
|
||||
SELECT id, user_id, url, local_path, size_bytes, cloned_at, last_used FROM repositories`)
|
||||
// Only swap if the new table has data or old table is empty
|
||||
var newCount, oldCount int
|
||||
db.conn.QueryRow(`SELECT COUNT(*) FROM repositories_new`).Scan(&newCount)
|
||||
db.conn.QueryRow(`SELECT COUNT(*) FROM repositories`).Scan(&oldCount)
|
||||
if newCount >= oldCount {
|
||||
db.conn.Exec(`DROP TABLE repositories`)
|
||||
db.conn.Exec(`ALTER TABLE repositories_new RENAME TO repositories`)
|
||||
} else {
|
||||
db.conn.Exec(`DROP TABLE repositories_new`)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -5,36 +5,42 @@ go 1.25.0
|
||||
require (
|
||||
github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb
|
||||
github.com/chromedp/chromedp v0.11.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/gin-contrib/sessions v1.1.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-git/go-git/v5 v5.19.1
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/sashabaranov/go-openai v1.41.2
|
||||
golang.org/x/crypto v0.50.0
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.9.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.4.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
@@ -45,19 +51,20 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -9,10 +9,12 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb h1:noKVm2SsG4v0Yd0lHNtFYc9EUxIVvrr4kJ6hM8wvIYU=
|
||||
github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
|
||||
github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0=
|
||||
@@ -21,10 +23,8 @@ github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipw
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -34,12 +34,14 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo=
|
||||
github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
@@ -56,21 +58,31 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
@@ -79,10 +91,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -109,14 +119,18 @@ github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
|
||||
@@ -133,23 +147,24 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
@@ -174,8 +189,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
@@ -187,5 +202,3 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewAuthHandler(db *sql.DB) *AuthHandler {
|
||||
return &AuthHandler{db: db}
|
||||
}
|
||||
|
||||
// Login renders the login page.
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "pages/login.html", gin.H{})
|
||||
}
|
||||
|
||||
// Register renders the registration page.
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "pages/register.html", gin.H{})
|
||||
}
|
||||
|
||||
// HandleLogin processes POST /api/auth/login.
|
||||
func (h *AuthHandler) HandleLogin(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱和密码为必填项"})
|
||||
return
|
||||
}
|
||||
|
||||
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
||||
|
||||
var user struct {
|
||||
ID int64
|
||||
PasswordHash string
|
||||
}
|
||||
err := h.db.QueryRow(`SELECT id, password_hash FROM users WHERE email = ?`, req.Email).
|
||||
Scan(&user.ID, &user.PasswordHash)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// Set session
|
||||
session := sessions.Default(c)
|
||||
session.Set("user_id", user.ID)
|
||||
if err := session.Save(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "会话保存失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// HandleRegister processes POST /api/auth/register.
|
||||
func (h *AuthHandler) HandleRegister(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱和密码为必填项"})
|
||||
return
|
||||
}
|
||||
|
||||
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
||||
|
||||
// Validate email format (basic check)
|
||||
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式不正确"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if len(req.Password) < 6 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "密码长度至少为 6 位"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
var exists int
|
||||
h.db.QueryRow(`SELECT COUNT(*) FROM users WHERE email = ?`, req.Email).Scan(&exists)
|
||||
if exists > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "该邮箱已被注册"})
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// Insert user
|
||||
now := time.Now().UTC()
|
||||
result, err := h.db.Exec(`INSERT INTO users (email, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?)`,
|
||||
req.Email, string(hash), now, now)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "注册失败"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := result.LastInsertId()
|
||||
|
||||
// Seed default settings for the user
|
||||
for key, val := range defaultUserSettings {
|
||||
h.db.Exec(`INSERT OR IGNORE INTO user_settings (user_id, key, value) VALUES (?, ?, ?)`, userID, key, val)
|
||||
}
|
||||
|
||||
// Auto-login: set session
|
||||
session := sessions.Default(c)
|
||||
session.Set("user_id", userID)
|
||||
if err := session.Save(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "会话保存失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// HandleLogout processes POST /api/auth/logout.
|
||||
func (h *AuthHandler) HandleLogout(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Clear()
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
}
|
||||
|
||||
// defaultUserSettings mirrors models.DefaultSettings for seeding new users.
|
||||
var defaultUserSettings = map[string]string{
|
||||
"llm.endpoint": "https://api.openai.com/v1",
|
||||
"llm.api_key": "",
|
||||
"llm.model": "gpt-4o",
|
||||
"review.top_n": "20",
|
||||
"review.concurrency": "5",
|
||||
"cache.max_age_days": "7",
|
||||
"cache.max_size_mb": "5000",
|
||||
}
|
||||
+13
-7
@@ -20,11 +20,17 @@ func NewGenerateHandler(db *sql.DB) *GenerateHandler {
|
||||
|
||||
// Generate handles POST /api/repos/:id/generate — SSE streaming PR description generation.
|
||||
func (h *GenerateHandler) Generate(c *gin.Context) {
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
|
||||
// Get repo info
|
||||
// Get repo info (scoped to user)
|
||||
var localPath string
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ?`, id).Scan(&localPath)
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ? AND user_id = ?`, id, user.ID).Scan(&localPath)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"})
|
||||
return
|
||||
@@ -69,21 +75,21 @@ func (h *GenerateHandler) Generate(c *gin.Context) {
|
||||
// Update last_used
|
||||
h.db.Exec(`UPDATE repositories SET last_used = datetime('now') WHERE id = ?`, id)
|
||||
|
||||
// Generate PR description
|
||||
pr, err := services.GeneratePR(h.db, localPath, req.Base, req.Head, sendEvent)
|
||||
// Generate PR description (pass user ID for per-user LLM config)
|
||||
pr, err := services.GeneratePR(h.db, localPath, req.Base, req.Head, user.ID, sendEvent)
|
||||
if err != nil {
|
||||
sendEvent("error", map[string]interface{}{"message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save analysis to DB
|
||||
// Save analysis to DB with user_id
|
||||
resultJSON, err := json.Marshal(pr)
|
||||
if err != nil {
|
||||
sendEvent("error", map[string]interface{}{"message": "marshal result: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := h.db.Exec(`INSERT INTO analyses (repo_id, type, base_ref, head_ref, result) VALUES (?, 'pr_description', ?, ?, ?)`,
|
||||
id, req.Base, req.Head, string(resultJSON)); err != nil {
|
||||
if _, err := h.db.Exec(`INSERT INTO analyses (user_id, repo_id, type, base_ref, head_ref, result) VALUES (?, ?, 'pr_description', ?, ?, ?)`,
|
||||
user.ID, id, req.Base, req.Head, string(resultJSON)); err != nil {
|
||||
sendEvent("error", map[string]interface{}{"message": "save analysis: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/HoHD/PR-Helper/models"
|
||||
)
|
||||
|
||||
// AuthRequired returns middleware that requires a logged-in user.
|
||||
// Page requests are redirected to /login; API requests get 401 JSON.
|
||||
func AuthRequired(db *sql.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get("user_id")
|
||||
if userID == nil {
|
||||
// Check if it's an API request
|
||||
if len(c.Request.URL.Path) > 4 && c.Request.URL.Path[:5] == "/api/" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
} else {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
c.Abort()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Load user from DB and store in context
|
||||
var user models.User
|
||||
err := db.QueryRow(`SELECT id, email, password_hash, created_at, updated_at FROM users WHERE id = ?`,
|
||||
userID).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.CreatedAt, &user.UpdatedAt)
|
||||
if err != nil {
|
||||
// User no longer exists, clear session
|
||||
session.Clear()
|
||||
session.Save()
|
||||
if len(c.Request.URL.Path) > 4 && c.Request.URL.Path[:5] == "/api/" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
} else {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
c.Abort()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user", &user)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentUser extracts the authenticated user from the Gin context.
|
||||
func GetCurrentUser(c *gin.Context) *models.User {
|
||||
if user, exists := c.Get("user"); exists {
|
||||
return user.(*models.User)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+55
-29
@@ -17,59 +17,85 @@ func NewPageHandler(db *sql.DB) *PageHandler {
|
||||
return &PageHandler{db: db}
|
||||
}
|
||||
|
||||
// userData returns a gin.H with the current user info for template rendering.
|
||||
func userData(c *gin.Context) gin.H {
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
return gin.H{}
|
||||
}
|
||||
return gin.H{"User": user}
|
||||
}
|
||||
|
||||
func (h *PageHandler) Index(c *gin.Context) {
|
||||
repos, _ := h.listRepos()
|
||||
c.HTML(http.StatusOK, "pages/index.html", gin.H{
|
||||
"Repos": repos,
|
||||
})
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
repos, _ := h.listRepos(user.ID)
|
||||
data := userData(c)
|
||||
data["Repos"] = repos
|
||||
c.HTML(http.StatusOK, "pages/index.html", data)
|
||||
}
|
||||
|
||||
func (h *PageHandler) Repo(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "pages/repo.html", gin.H{
|
||||
"ID": c.Param("id"),
|
||||
})
|
||||
data := userData(c)
|
||||
data["ID"] = c.Param("id")
|
||||
c.HTML(http.StatusOK, "pages/repo.html", data)
|
||||
}
|
||||
|
||||
func (h *PageHandler) Generate(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "pages/generate.html", gin.H{
|
||||
"ID": c.Param("id"),
|
||||
})
|
||||
data := userData(c)
|
||||
data["ID"] = c.Param("id")
|
||||
c.HTML(http.StatusOK, "pages/generate.html", data)
|
||||
}
|
||||
|
||||
func (h *PageHandler) Review(c *gin.Context) {
|
||||
user := GetCurrentUser(c)
|
||||
topN := "20"
|
||||
h.db.QueryRow(`SELECT value FROM settings WHERE key = 'review.top_n'`).Scan(&topN)
|
||||
concurrency := "5"
|
||||
h.db.QueryRow(`SELECT value FROM settings WHERE key = 'review.concurrency'`).Scan(&concurrency)
|
||||
c.HTML(http.StatusOK, "pages/review.html", gin.H{
|
||||
"ID": c.Param("id"),
|
||||
"TopN": topN,
|
||||
"Concurrency": concurrency,
|
||||
})
|
||||
if user != nil {
|
||||
h.db.QueryRow(`SELECT value FROM user_settings WHERE user_id = ? AND key = 'review.top_n'`, user.ID).Scan(&topN)
|
||||
h.db.QueryRow(`SELECT value FROM user_settings WHERE user_id = ? AND key = 'review.concurrency'`, user.ID).Scan(&concurrency)
|
||||
}
|
||||
if topN == "" {
|
||||
topN = "20"
|
||||
}
|
||||
if concurrency == "" {
|
||||
concurrency = "5"
|
||||
}
|
||||
data := userData(c)
|
||||
data["ID"] = c.Param("id")
|
||||
data["TopN"] = topN
|
||||
data["Concurrency"] = concurrency
|
||||
c.HTML(http.StatusOK, "pages/review.html", data)
|
||||
}
|
||||
|
||||
func (h *PageHandler) Settings(c *gin.Context) {
|
||||
user := GetCurrentUser(c)
|
||||
settings := make(map[string]string)
|
||||
for key := range models.DefaultSettings {
|
||||
settings[key] = ""
|
||||
}
|
||||
rows, err := h.db.Query(`SELECT key, value FROM settings`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if rows.Scan(&k, &v) == nil {
|
||||
settings[k] = v
|
||||
if user != nil {
|
||||
rows, err := h.db.Query(`SELECT key, value FROM user_settings WHERE user_id = ?`, user.ID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if rows.Scan(&k, &v) == nil {
|
||||
settings[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c.HTML(http.StatusOK, "pages/settings.html", gin.H{
|
||||
"Settings": settings,
|
||||
})
|
||||
data := userData(c)
|
||||
data["Settings"] = settings
|
||||
c.HTML(http.StatusOK, "pages/settings.html", data)
|
||||
}
|
||||
|
||||
func (h *PageHandler) listRepos() ([]models.Repository, error) {
|
||||
rows, err := h.db.Query(`SELECT id, url, local_path, size_bytes, cloned_at, last_used FROM repositories ORDER BY last_used DESC`)
|
||||
func (h *PageHandler) listRepos(userID int64) ([]models.Repository, error) {
|
||||
rows, err := h.db.Query(`SELECT id, url, local_path, size_bytes, cloned_at, last_used FROM repositories WHERE user_id = ? ORDER BY last_used DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+63
-15
@@ -24,7 +24,13 @@ func NewReposHandler(db *sql.DB, reposDir string) *ReposHandler {
|
||||
}
|
||||
|
||||
func (h *ReposHandler) ListRepos(c *gin.Context) {
|
||||
rows, err := h.db.Query(`SELECT id, url, local_path, size_bytes, cloned_at, last_used FROM repositories ORDER BY last_used DESC`)
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.db.Query(`SELECT id, url, local_path, size_bytes, cloned_at, last_used FROM repositories WHERE user_id = ? ORDER BY last_used DESC`, user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -54,9 +60,15 @@ func (h *ReposHandler) ListRepos(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *ReposHandler) DeleteRepo(c *gin.Context) {
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
var localPath string
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ?`, id).Scan(&localPath)
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ? AND user_id = ?`, id, user.ID).Scan(&localPath)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"})
|
||||
return
|
||||
@@ -69,11 +81,11 @@ func (h *ReposHandler) DeleteRepo(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "remove repo dir: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := h.db.Exec(`DELETE FROM analyses WHERE repo_id = ?`, id); err != nil {
|
||||
if _, err := h.db.Exec(`DELETE FROM analyses WHERE repo_id = ? AND user_id = ?`, id, user.ID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete analyses: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := h.db.Exec(`DELETE FROM repositories WHERE id = ?`, id); err != nil {
|
||||
if _, err := h.db.Exec(`DELETE FROM repositories WHERE id = ? AND user_id = ?`, id, user.ID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete repository: " + err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -81,12 +93,18 @@ func (h *ReposHandler) DeleteRepo(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *ReposHandler) CleanupRepos(c *gin.Context) {
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var maxAgeDays string
|
||||
h.db.QueryRow(`SELECT value FROM settings WHERE key = 'cache.max_age_days'`).Scan(&maxAgeDays)
|
||||
h.db.QueryRow(`SELECT value FROM user_settings WHERE user_id = ? AND key = 'cache.max_age_days'`, user.ID).Scan(&maxAgeDays)
|
||||
if maxAgeDays == "" {
|
||||
maxAgeDays = "7"
|
||||
}
|
||||
rows, err := h.db.Query(`SELECT id, local_path FROM repositories WHERE last_used < datetime('now', '-' || ? || ' days')`, maxAgeDays)
|
||||
rows, err := h.db.Query(`SELECT id, local_path FROM repositories WHERE user_id = ? AND last_used < datetime('now', '-' || ? || ' days')`, user.ID, maxAgeDays)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -102,10 +120,10 @@ func (h *ReposHandler) CleanupRepos(c *gin.Context) {
|
||||
errs = append(errs, fmt.Sprintf("remove %d: %s", id, err.Error()))
|
||||
continue
|
||||
}
|
||||
if _, err := h.db.Exec(`DELETE FROM analyses WHERE repo_id = ?`, id); err != nil {
|
||||
if _, err := h.db.Exec(`DELETE FROM analyses WHERE repo_id = ? AND user_id = ?`, id, user.ID); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("delete analyses %d: %s", id, err.Error()))
|
||||
}
|
||||
if _, err := h.db.Exec(`DELETE FROM repositories WHERE id = ?`, id); err != nil {
|
||||
if _, err := h.db.Exec(`DELETE FROM repositories WHERE id = ? AND user_id = ?`, id, user.ID); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("delete repo %d: %s", id, err.Error()))
|
||||
}
|
||||
cleaned = append(cleaned, id)
|
||||
@@ -124,6 +142,12 @@ func (h *ReposHandler) CleanupRepos(c *gin.Context) {
|
||||
|
||||
// CloneRepo handles POST /api/repos with SSE progress events.
|
||||
func (h *ReposHandler) CloneRepo(c *gin.Context) {
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
Username string `json:"username"`
|
||||
@@ -179,10 +203,10 @@ func (h *ReposHandler) CloneRepo(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Save to database
|
||||
// Save to database with user_id
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
res, err := h.db.Exec(`INSERT INTO repositories (url, local_path, size_bytes, cloned_at, last_used) VALUES (?, ?, ?, ?, ?)`,
|
||||
req.URL, repoDir, result.SizeBytes, now, now)
|
||||
res, err := h.db.Exec(`INSERT INTO repositories (user_id, url, local_path, size_bytes, cloned_at, last_used) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
user.ID, req.URL, repoDir, result.SizeBytes, now, now)
|
||||
if err != nil {
|
||||
sendEvent("error", map[string]interface{}{"message": "save to db: " + err.Error()})
|
||||
return
|
||||
@@ -201,9 +225,15 @@ func (h *ReposHandler) CloneRepo(c *gin.Context) {
|
||||
|
||||
// GetGraph handles GET /api/repos/:id/graph — returns D3.js-compatible data.
|
||||
func (h *ReposHandler) GetGraph(c *gin.Context) {
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
var localPath string
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ?`, id).Scan(&localPath)
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ? AND user_id = ?`, id, user.ID).Scan(&localPath)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"})
|
||||
return
|
||||
@@ -240,6 +270,12 @@ func (h *ReposHandler) GetGraph(c *gin.Context) {
|
||||
|
||||
// GetDiff handles GET /api/repos/:id/diff — returns unified diff or per-file diffs.
|
||||
func (h *ReposHandler) GetDiff(c *gin.Context) {
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
base := c.Query("base")
|
||||
head := c.Query("head")
|
||||
@@ -250,7 +286,7 @@ func (h *ReposHandler) GetDiff(c *gin.Context) {
|
||||
}
|
||||
|
||||
var localPath string
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ?`, id).Scan(&localPath)
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ? AND user_id = ?`, id, user.ID).Scan(&localPath)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"})
|
||||
return
|
||||
@@ -291,9 +327,15 @@ func (h *ReposHandler) GetDiff(c *gin.Context) {
|
||||
|
||||
// GetRefs handles GET /api/repos/:id/refs — returns branches and tags.
|
||||
func (h *ReposHandler) GetRefs(c *gin.Context) {
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
var localPath string
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ?`, id).Scan(&localPath)
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ? AND user_id = ?`, id, user.ID).Scan(&localPath)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"})
|
||||
return
|
||||
@@ -320,6 +362,12 @@ func (h *ReposHandler) GetRefs(c *gin.Context) {
|
||||
|
||||
// GetCommits handles GET /api/repos/:id/commits — returns commit log for a ref.
|
||||
func (h *ReposHandler) GetCommits(c *gin.Context) {
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
refName := c.Query("ref")
|
||||
if refName == "" {
|
||||
@@ -333,7 +381,7 @@ func (h *ReposHandler) GetCommits(c *gin.Context) {
|
||||
}
|
||||
|
||||
var localPath string
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ?`, id).Scan(&localPath)
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ? AND user_id = ?`, id, user.ID).Scan(&localPath)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"})
|
||||
return
|
||||
|
||||
+82
-22
@@ -22,11 +22,17 @@ func NewReviewHandler(db *sql.DB) *ReviewHandler {
|
||||
|
||||
// Review handles POST /api/repos/:id/review — SSE streaming AI code review.
|
||||
func (h *ReviewHandler) Review(c *gin.Context) {
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
|
||||
// Get repo info
|
||||
// Get repo info (scoped to user)
|
||||
var localPath string
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ?`, id).Scan(&localPath)
|
||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ? AND user_id = ?`, id, user.ID).Scan(&localPath)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"})
|
||||
return
|
||||
@@ -54,7 +60,7 @@ func (h *ReviewHandler) Review(c *gin.Context) {
|
||||
topN = *req.TopN
|
||||
} else {
|
||||
var topNStr string
|
||||
h.db.QueryRow(`SELECT value FROM settings WHERE key = 'review.top_n'`).Scan(&topNStr)
|
||||
h.db.QueryRow(`SELECT value FROM user_settings WHERE user_id = ? AND key = 'review.top_n'`, user.ID).Scan(&topNStr)
|
||||
if topNStr != "" {
|
||||
if n, err := strconv.Atoi(topNStr); err == nil && n > 0 {
|
||||
topN = n
|
||||
@@ -68,7 +74,7 @@ func (h *ReviewHandler) Review(c *gin.Context) {
|
||||
concurrency = *req.Concurrency
|
||||
} else {
|
||||
var concStr string
|
||||
h.db.QueryRow(`SELECT value FROM settings WHERE key = 'review.concurrency'`).Scan(&concStr)
|
||||
h.db.QueryRow(`SELECT value FROM user_settings WHERE user_id = ? AND key = 'review.concurrency'`, user.ID).Scan(&concStr)
|
||||
if concStr != "" {
|
||||
if n, err := strconv.Atoi(concStr); err == nil && n > 0 {
|
||||
concurrency = n
|
||||
@@ -101,21 +107,21 @@ func (h *ReviewHandler) Review(c *gin.Context) {
|
||||
// Update last_used
|
||||
h.db.Exec(`UPDATE repositories SET last_used = datetime('now') WHERE id = ?`, id)
|
||||
|
||||
// Run AI review
|
||||
reviewResult, err := services.GenerateReview(h.db, localPath, req.Base, req.Head, topN, concurrency, sendEvent)
|
||||
// Run AI review (pass user ID for per-user LLM config)
|
||||
reviewResult, err := services.GenerateReview(h.db, localPath, req.Base, req.Head, topN, concurrency, user.ID, sendEvent)
|
||||
if err != nil {
|
||||
sendEvent("error", map[string]interface{}{"message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save analysis to DB with full review result
|
||||
// Save analysis to DB with user_id
|
||||
resultJSON, err := json.Marshal(reviewResult)
|
||||
if err != nil {
|
||||
sendEvent("error", map[string]interface{}{"message": "marshal result: " + err.Error()})
|
||||
return
|
||||
}
|
||||
res, err := h.db.Exec(`INSERT INTO analyses (repo_id, type, base_ref, head_ref, result) VALUES (?, 'code_review', ?, ?, ?)`,
|
||||
id, req.Base, req.Head, string(resultJSON))
|
||||
res, err := h.db.Exec(`INSERT INTO analyses (user_id, repo_id, type, base_ref, head_ref, result) VALUES (?, ?, 'code_review', ?, ?, ?)`,
|
||||
user.ID, id, req.Base, req.Head, string(resultJSON))
|
||||
if err != nil {
|
||||
sendEvent("error", map[string]interface{}{"message": "save analysis: " + err.Error()})
|
||||
return
|
||||
@@ -132,6 +138,12 @@ func (h *ReviewHandler) Review(c *gin.Context) {
|
||||
|
||||
// SaveNotes handles POST /api/repos/:id/review/notes — upsert a review note.
|
||||
func (h *ReviewHandler) SaveNotes(c *gin.Context) {
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
AnalysisID int64 `json:"analysis_id" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
@@ -152,6 +164,18 @@ func (h *ReviewHandler) SaveNotes(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify analysis belongs to user
|
||||
var analysisOwnerID int64
|
||||
err := h.db.QueryRow(`SELECT user_id FROM analyses WHERE id = ?`, req.AnalysisID).Scan(&analysisOwnerID)
|
||||
if err == sql.ErrNoRows || analysisOwnerID != user.ID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "analysis not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
note, err := services.SaveNote(h.db, req.AnalysisID, req.Scope, req.ScopeKey, req.Content)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -163,6 +187,12 @@ func (h *ReviewHandler) SaveNotes(c *gin.Context) {
|
||||
|
||||
// GetNotes handles GET /api/repos/:id/review/notes — list review notes for an analysis.
|
||||
func (h *ReviewHandler) GetNotes(c *gin.Context) {
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
analysisIDStr := c.Query("analysis_id")
|
||||
if analysisIDStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "analysis_id query parameter is required"})
|
||||
@@ -175,6 +205,18 @@ func (h *ReviewHandler) GetNotes(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify analysis belongs to user
|
||||
var analysisOwnerID int64
|
||||
err = h.db.QueryRow(`SELECT user_id FROM analyses WHERE id = ?`, analysisID).Scan(&analysisOwnerID)
|
||||
if err == sql.ErrNoRows || analysisOwnerID != user.ID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "analysis not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
scope := c.Query("scope") // optional filter
|
||||
|
||||
notes, err := services.GetNotes(h.db, analysisID, scope)
|
||||
@@ -191,9 +233,15 @@ func (h *ReviewHandler) GetNotes(c *gin.Context) {
|
||||
|
||||
// ListReviews handles GET /api/repos/:id/review/analyses — list past code review analyses.
|
||||
func (h *ReviewHandler) ListReviews(c *gin.Context) {
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
|
||||
rows, err := h.db.Query(`SELECT id, base_ref, head_ref, result, created_at FROM analyses WHERE repo_id = ? AND type = 'code_review' ORDER BY created_at DESC`, id)
|
||||
rows, err := h.db.Query(`SELECT id, base_ref, head_ref, result, created_at FROM analyses WHERE repo_id = ? AND user_id = ? AND type = 'code_review' ORDER BY created_at DESC`, id, user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -228,10 +276,16 @@ func (h *ReviewHandler) ListReviews(c *gin.Context) {
|
||||
|
||||
// GetReview handles GET /api/repos/:id/review/analyses/:aid — get a single review with full result.
|
||||
func (h *ReviewHandler) GetReview(c *gin.Context) {
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
aid := c.Param("aid")
|
||||
|
||||
var result, baseRef, headRef, createdAt string
|
||||
err := h.db.QueryRow(`SELECT result, base_ref, head_ref, created_at FROM analyses WHERE id = ? AND type = 'code_review'`, aid).Scan(&result, &baseRef, &headRef, &createdAt)
|
||||
err := h.db.QueryRow(`SELECT result, base_ref, head_ref, created_at FROM analyses WHERE id = ? AND user_id = ? AND type = 'code_review'`, aid, user.ID).Scan(&result, &baseRef, &headRef, &createdAt)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "analysis not found"})
|
||||
return
|
||||
@@ -258,11 +312,17 @@ func (h *ReviewHandler) GetReview(c *gin.Context) {
|
||||
|
||||
// GeneratePDF handles POST /api/repos/:id/review/pdf — generate and download a PDF report.
|
||||
func (h *ReviewHandler) GeneratePDF(c *gin.Context) {
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
|
||||
// Get repo info
|
||||
// Get repo info (scoped to user)
|
||||
var repoURL string
|
||||
err := h.db.QueryRow(`SELECT url FROM repositories WHERE id = ?`, id).Scan(&repoURL)
|
||||
err := h.db.QueryRow(`SELECT url FROM repositories WHERE id = ? AND user_id = ?`, id, user.ID).Scan(&repoURL)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"})
|
||||
return
|
||||
@@ -283,10 +343,10 @@ func (h *ReviewHandler) GeneratePDF(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Load the analysis result
|
||||
// Verify analysis belongs to user
|
||||
var analysisResult, baseRef, headRef string
|
||||
var createdAt string
|
||||
err = h.db.QueryRow(`SELECT result, base_ref, head_ref, created_at FROM analyses WHERE id = ?`, req.AnalysisID).Scan(&analysisResult, &baseRef, &headRef, &createdAt)
|
||||
err = h.db.QueryRow(`SELECT result, base_ref, head_ref, created_at FROM analyses WHERE id = ? AND user_id = ?`, req.AnalysisID, user.ID).Scan(&analysisResult, &baseRef, &headRef, &createdAt)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "analysis not found"})
|
||||
return
|
||||
@@ -305,13 +365,13 @@ func (h *ReviewHandler) GeneratePDF(c *gin.Context) {
|
||||
|
||||
// Build report data
|
||||
reportData := services.ReportData{
|
||||
RepoURL: repoURL,
|
||||
BaseRef: baseRef,
|
||||
HeadRef: headRef,
|
||||
ReviewedAt: createdAt,
|
||||
AnalysisID: req.AnalysisID,
|
||||
Result: analysisResult,
|
||||
Notes: notes,
|
||||
RepoURL: repoURL,
|
||||
BaseRef: baseRef,
|
||||
HeadRef: headRef,
|
||||
ReviewedAt: createdAt,
|
||||
AnalysisID: req.AnalysisID,
|
||||
Result: analysisResult,
|
||||
Notes: notes,
|
||||
}
|
||||
|
||||
// Generate PDF
|
||||
|
||||
+14
-2
@@ -16,8 +16,14 @@ func NewSettingsHandler(db *sql.DB) *SettingsHandler {
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) GetSettings(c *gin.Context) {
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
settings := make(map[string]string)
|
||||
rows, err := h.db.Query(`SELECT key, value FROM settings`)
|
||||
rows, err := h.db.Query(`SELECT key, value FROM user_settings WHERE user_id = ?`, user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -33,6 +39,12 @@ func (h *SettingsHandler) GetSettings(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
|
||||
user := GetCurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var body map[string]string
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
@@ -44,7 +56,7 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
for key, val := range body {
|
||||
_, err := tx.Exec(`INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)`, key, val)
|
||||
_, err := tx.Exec(`INSERT OR REPLACE INTO user_settings (user_id, key, value) VALUES (?, ?, ?)`, user.ID, key, val)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/HoHD/PR-Helper/config"
|
||||
@@ -32,6 +34,15 @@ func main() {
|
||||
gin.SetMode(cfg.GinMode)
|
||||
r := gin.Default()
|
||||
|
||||
// Session middleware
|
||||
store := cookie.NewStore([]byte(cfg.SessionSecret))
|
||||
store.Options(sessions.Options{
|
||||
MaxAge: 86400 * 7, // 7 days
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
})
|
||||
r.Use(sessions.Sessions("pr_session", store))
|
||||
|
||||
// Custom template functions
|
||||
funcMap := template.FuncMap{
|
||||
"add": func(a, b int) int { return a + b },
|
||||
@@ -72,37 +83,48 @@ func main() {
|
||||
r.Static("/static", "./static")
|
||||
|
||||
// Handlers
|
||||
auth := handlers.NewAuthHandler(db.Conn())
|
||||
pages := handlers.NewPageHandler(db.Conn())
|
||||
settings := handlers.NewSettingsHandler(db.Conn())
|
||||
repos := handlers.NewReposHandler(db.Conn(), cfg.ReposDir())
|
||||
generate := handlers.NewGenerateHandler(db.Conn())
|
||||
review := handlers.NewReviewHandler(db.Conn())
|
||||
|
||||
// Public routes (no auth required)
|
||||
r.GET("/login", auth.Login)
|
||||
r.GET("/register", auth.Register)
|
||||
r.POST("/api/auth/login", auth.HandleLogin)
|
||||
r.POST("/api/auth/register", auth.HandleRegister)
|
||||
r.POST("/api/auth/logout", auth.HandleLogout)
|
||||
|
||||
// Protected routes (auth required)
|
||||
authMw := handlers.AuthRequired(db.Conn())
|
||||
|
||||
// Page routes
|
||||
r.GET("/", pages.Index)
|
||||
r.GET("/repo/:id", pages.Repo)
|
||||
r.GET("/repo/:id/generate", pages.Generate)
|
||||
r.GET("/repo/:id/review", pages.Review)
|
||||
r.GET("/settings", pages.Settings)
|
||||
r.GET("/", authMw, pages.Index)
|
||||
r.GET("/repo/:id", authMw, pages.Repo)
|
||||
r.GET("/repo/:id/generate", authMw, pages.Generate)
|
||||
r.GET("/repo/:id/review", authMw, pages.Review)
|
||||
r.GET("/settings", authMw, pages.Settings)
|
||||
|
||||
// API routes
|
||||
r.GET("/api/settings", settings.GetSettings)
|
||||
r.PUT("/api/settings", settings.UpdateSettings)
|
||||
r.GET("/api/repos", repos.ListRepos)
|
||||
r.POST("/api/repos", repos.CloneRepo)
|
||||
r.DELETE("/api/repos/:id", repos.DeleteRepo)
|
||||
r.POST("/api/repos/:id/cleanup", repos.CleanupRepos)
|
||||
r.GET("/api/repos/:id/graph", repos.GetGraph)
|
||||
r.GET("/api/repos/:id/refs", repos.GetRefs)
|
||||
r.GET("/api/repos/:id/commits", repos.GetCommits)
|
||||
r.GET("/api/repos/:id/diff", repos.GetDiff)
|
||||
r.POST("/api/repos/:id/generate", generate.Generate)
|
||||
r.POST("/api/repos/:id/review", review.Review)
|
||||
r.GET("/api/repos/:id/review/analyses", review.ListReviews)
|
||||
r.GET("/api/repos/:id/review/analyses/:aid", review.GetReview)
|
||||
r.POST("/api/repos/:id/review/notes", review.SaveNotes)
|
||||
r.GET("/api/repos/:id/review/notes", review.GetNotes)
|
||||
r.POST("/api/repos/:id/review/pdf", review.GeneratePDF)
|
||||
r.GET("/api/settings", authMw, settings.GetSettings)
|
||||
r.PUT("/api/settings", authMw, settings.UpdateSettings)
|
||||
r.GET("/api/repos", authMw, repos.ListRepos)
|
||||
r.POST("/api/repos", authMw, repos.CloneRepo)
|
||||
r.DELETE("/api/repos/:id", authMw, repos.DeleteRepo)
|
||||
r.POST("/api/repos/:id/cleanup", authMw, repos.CleanupRepos)
|
||||
r.GET("/api/repos/:id/graph", authMw, repos.GetGraph)
|
||||
r.GET("/api/repos/:id/refs", authMw, repos.GetRefs)
|
||||
r.GET("/api/repos/:id/commits", authMw, repos.GetCommits)
|
||||
r.GET("/api/repos/:id/diff", authMw, repos.GetDiff)
|
||||
r.POST("/api/repos/:id/generate", authMw, generate.Generate)
|
||||
r.POST("/api/repos/:id/review", authMw, review.Review)
|
||||
r.GET("/api/repos/:id/review/analyses", authMw, review.ListReviews)
|
||||
r.GET("/api/repos/:id/review/analyses/:aid", authMw, review.GetReview)
|
||||
r.POST("/api/repos/:id/review/notes", authMw, review.SaveNotes)
|
||||
r.GET("/api/repos/:id/review/notes", authMw, review.GetNotes)
|
||||
r.POST("/api/repos/:id/review/pdf", authMw, review.GeneratePDF)
|
||||
|
||||
// Graceful shutdown with signal handling
|
||||
srv := &http.Server{
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// User represents a registered user.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -22,9 +22,9 @@ type PRDescription struct {
|
||||
|
||||
// GeneratePR generates a structured PR description from commit history and diff.
|
||||
// It streams progress via the callback and returns the parsed PR description.
|
||||
func GeneratePR(db *sql.DB, repoPath, base, head string, callback StreamCallback) (*PRDescription, error) {
|
||||
// Read LLM config
|
||||
config, err := GetLLMConfig(db)
|
||||
func GeneratePR(db *sql.DB, repoPath, base, head string, userID int64, callback StreamCallback) (*PRDescription, error) {
|
||||
// Read LLM config (per-user)
|
||||
config, err := GetLLMConfig(db, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+3
-3
@@ -17,11 +17,11 @@ type LLMConfig struct {
|
||||
Model string
|
||||
}
|
||||
|
||||
// GetLLMConfig reads LLM settings from the database.
|
||||
func GetLLMConfig(db *sql.DB) (LLMConfig, error) {
|
||||
// GetLLMConfig reads LLM settings from the user_settings table for the given user.
|
||||
func GetLLMConfig(db *sql.DB, userID int64) (LLMConfig, error) {
|
||||
config := LLMConfig{}
|
||||
|
||||
rows, err := db.Query(`SELECT key, value FROM settings WHERE key IN ('llm.endpoint', 'llm.api_key', 'llm.model')`)
|
||||
rows, err := db.Query(`SELECT key, value FROM user_settings WHERE user_id = ? AND key IN ('llm.endpoint', 'llm.api_key', 'llm.model')`, userID)
|
||||
if err != nil {
|
||||
return config, fmt.Errorf("read settings: %w", err)
|
||||
}
|
||||
|
||||
+3
-3
@@ -58,9 +58,9 @@ func countDiffLines(patch string) int {
|
||||
// GenerateReview performs AI code review on diff files with Top-N strategy.
|
||||
// It streams events (file_start, suggestion, file_end, summary, done) via callback
|
||||
// and returns the complete ReviewResult for persistence.
|
||||
func GenerateReview(db *sql.DB, repoPath, base, head string, topN, concurrency int, callback StreamCallback) (*ReviewResult, error) {
|
||||
// Read LLM config
|
||||
config, err := GetLLMConfig(db)
|
||||
func GenerateReview(db *sql.DB, repoPath, base, head string, topN, concurrency int, userID int64, callback StreamCallback) (*ReviewResult, error) {
|
||||
// Read LLM config (per-user)
|
||||
config, err := GetLLMConfig(db, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -668,7 +668,7 @@ const DiffViewer = {
|
||||
|
||||
try {
|
||||
const url = `/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}`;
|
||||
const resp = await fetch(url);
|
||||
const resp = await fetch(url, { credentials: 'same-origin' });
|
||||
if (!resp.ok) throw new Error('Failed to load diff');
|
||||
|
||||
const data = await resp.json();
|
||||
@@ -690,7 +690,7 @@ const DiffViewer = {
|
||||
|
||||
try {
|
||||
const url = `/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}&per_file=true`;
|
||||
const resp = await fetch(url);
|
||||
const resp = await fetch(url, { credentials: 'same-origin' });
|
||||
if (!resp.ok) throw new Error('Failed to load diff');
|
||||
|
||||
const files = await resp.json();
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ const GitGraph = {
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const resp = await fetch(`/api/repos/${this.repoId}/graph`);
|
||||
const resp = await fetch(`/api/repos/${this.repoId}/graph`, { credentials: 'same-origin' });
|
||||
if (!resp.ok) throw new Error('Failed to load graph');
|
||||
const data = await resp.json();
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ const NoteEditor = {
|
||||
if (!this.analysisId) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/repos/${this.repoId}/review/notes?analysis_id=${this.analysisId}`);
|
||||
const resp = await fetch(`/api/repos/${this.repoId}/review/notes?analysis_id=${this.analysisId}`, { credentials: 'same-origin' });
|
||||
if (!resp.ok) return;
|
||||
const notes = await resp.json();
|
||||
|
||||
@@ -67,6 +67,7 @@ const NoteEditor = {
|
||||
const resp = await fetch(`/api/repos/${this.repoId}/review/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
analysis_id: this.analysisId,
|
||||
scope: scope,
|
||||
@@ -166,6 +167,7 @@ const NoteEditor = {
|
||||
const resp = await fetch(`/api/repos/${this.repoId}/review/pdf`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
analysis_id: this.analysisId,
|
||||
base: document.getElementById('base-ref')?.value || '',
|
||||
|
||||
@@ -20,6 +20,7 @@ const SSE = {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -60,6 +60,14 @@
|
||||
<a href="/" class="text-sm text-gray-600 hover:text-gray-900">首页</a>
|
||||
<a href="/settings" class="text-sm text-gray-600 hover:text-gray-900">设置</a>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
{{if .User}}
|
||||
<span class="text-sm text-gray-500">{{.User.Email}}</span>
|
||||
<form method="POST" action="/api/auth/logout" class="inline">
|
||||
<button type="submit" class="text-sm text-gray-500 hover:text-gray-700">退出</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -91,14 +91,14 @@
|
||||
// Load refs on page load
|
||||
async function loadRefs() {
|
||||
try {
|
||||
const resp = await fetch(`/api/repos/${repoId}/refs`);
|
||||
const resp = await fetch(`/api/repos/${repoId}/refs`, { credentials: 'same-origin' });
|
||||
if (!resp.ok) throw new Error('Failed to load refs');
|
||||
const refs = await resp.json();
|
||||
|
||||
// Fetch recent commits for the "Recent Commits" group
|
||||
let commits = [];
|
||||
try {
|
||||
const graphResp = await fetch(`/api/repos/${repoId}/graph?max_commits=20`);
|
||||
const graphResp = await fetch(`/api/repos/${repoId}/graph?max_commits=20`, { credentials: 'same-origin' });
|
||||
if (graphResp.ok) {
|
||||
const gd = await graphResp.json();
|
||||
commits = gd.commits || [];
|
||||
|
||||
@@ -88,6 +88,7 @@ document.getElementById('clone-form').addEventListener('submit', async function(
|
||||
const resp = await fetch('/api/repos', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({url: url})
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" class="h-full">
|
||||
{{template "head" .}}
|
||||
<body class="h-full bg-gray-50 text-gray-900">
|
||||
<div class="min-h-full flex flex-col">
|
||||
{{template "nav" .}}
|
||||
<main class="flex-1 flex items-center justify-center">
|
||||
<div class="w-full max-w-md px-4">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
|
||||
<h1 class="text-2xl font-bold text-center mb-6">登录</h1>
|
||||
<form id="login-form" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
|
||||
<input type="email" name="email" required
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="your@email.com">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
|
||||
<input type="password" name="password" required
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="••••••">
|
||||
</div>
|
||||
<div id="login-error" class="text-sm text-red-600 hidden"></div>
|
||||
<button type="submit"
|
||||
class="w-full bg-indigo-600 text-white px-5 py-2 rounded-md text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
||||
登录
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-4 text-center text-sm text-gray-500">
|
||||
还没有账号?<a href="/register" class="text-indigo-600 hover:text-indigo-700">注册</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('login-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const form = new FormData(this);
|
||||
const errorEl = document.getElementById('login-error');
|
||||
errorEl.classList.add('hidden');
|
||||
try {
|
||||
const resp = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
email: form.get('email'),
|
||||
password: form.get('password')
|
||||
})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
errorEl.textContent = data.error || '登录失败';
|
||||
errorEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
errorEl.textContent = '网络错误,请重试';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" class="h-full">
|
||||
{{template "head" .}}
|
||||
<body class="h-full bg-gray-50 text-gray-900">
|
||||
<div class="min-h-full flex flex-col">
|
||||
{{template "nav" .}}
|
||||
<main class="flex-1 flex items-center justify-center">
|
||||
<div class="w-full max-w-md px-4">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
|
||||
<h1 class="text-2xl font-bold text-center mb-6">注册</h1>
|
||||
<form id="register-form" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
|
||||
<input type="email" name="email" required
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="your@email.com">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
|
||||
<input type="password" name="password" required minlength="6"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="至少 6 位">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">确认密码</label>
|
||||
<input type="password" name="confirm_password" required
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="再次输入密码">
|
||||
</div>
|
||||
<div id="register-error" class="text-sm text-red-600 hidden"></div>
|
||||
<button type="submit"
|
||||
class="w-full bg-indigo-600 text-white px-5 py-2 rounded-md text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
||||
注册
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-4 text-center text-sm text-gray-500">
|
||||
已有账号?<a href="/login" class="text-indigo-600 hover:text-indigo-700">登录</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('register-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const form = new FormData(this);
|
||||
const errorEl = document.getElementById('register-error');
|
||||
errorEl.classList.add('hidden');
|
||||
|
||||
const password = form.get('password');
|
||||
const confirmPassword = form.get('confirm_password');
|
||||
if (password !== confirmPassword) {
|
||||
errorEl.textContent = '两次输入的密码不一致';
|
||||
errorEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
email: form.get('email'),
|
||||
password: password
|
||||
})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
errorEl.textContent = data.error || '注册失败';
|
||||
errorEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
errorEl.textContent = '网络错误,请重试';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -177,7 +177,7 @@
|
||||
async function init() {
|
||||
try {
|
||||
// Load repo info
|
||||
const reposResp = await fetch('/api/repos');
|
||||
const reposResp = await fetch('/api/repos', { credentials: 'same-origin' });
|
||||
if (reposResp.ok) {
|
||||
const repos = await reposResp.json();
|
||||
const repo = repos.find(r => r.id == repoId || r.id === parseInt(repoId));
|
||||
@@ -192,14 +192,14 @@
|
||||
}
|
||||
|
||||
// Load refs
|
||||
const refsResp = await fetch(`/api/repos/${repoId}/refs`);
|
||||
const refsResp = await fetch(`/api/repos/${repoId}/refs`, { credentials: 'same-origin' });
|
||||
if (refsResp.ok) {
|
||||
currentRefs = await refsResp.json();
|
||||
|
||||
// Fetch recent commits independently
|
||||
let commits = [];
|
||||
try {
|
||||
const graphResp = await fetch(`/api/repos/${repoId}/graph?max_commits=20`);
|
||||
const graphResp = await fetch(`/api/repos/${repoId}/graph?max_commits=20`, { credentials: 'same-origin' });
|
||||
if (graphResp.ok) { const gd = await graphResp.json(); commits = gd.commits || []; }
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
|
||||
@@ -129,14 +129,14 @@
|
||||
// Load refs on page load
|
||||
async function loadRefs() {
|
||||
try {
|
||||
const resp = await fetch(`/api/repos/${repoId}/refs`);
|
||||
const resp = await fetch(`/api/repos/${repoId}/refs`, { credentials: 'same-origin' });
|
||||
if (!resp.ok) throw new Error('Failed to load refs');
|
||||
const refs = await resp.json();
|
||||
|
||||
// Fetch recent commits for the "Recent Commits" group
|
||||
let commits = [];
|
||||
try {
|
||||
const graphResp = await fetch(`/api/repos/${repoId}/graph?max_commits=20`);
|
||||
const graphResp = await fetch(`/api/repos/${repoId}/graph?max_commits=20`, { credentials: 'same-origin' });
|
||||
if (graphResp.ok) {
|
||||
const gd = await graphResp.json();
|
||||
commits = gd.commits || [];
|
||||
@@ -437,7 +437,7 @@
|
||||
|
||||
async function loadHistoryList() {
|
||||
try {
|
||||
const resp = await fetch(`/api/repos/${repoId}/review/analyses`);
|
||||
const resp = await fetch(`/api/repos/${repoId}/review/analyses`, { credentials: 'same-origin' });
|
||||
if (!resp.ok) return;
|
||||
const analyses = await resp.json();
|
||||
|
||||
@@ -465,7 +465,7 @@
|
||||
|
||||
try {
|
||||
// Fetch the review result
|
||||
const resp = await fetch(`/api/repos/${repoId}/review/analyses/${analysisId}`);
|
||||
const resp = await fetch(`/api/repos/${repoId}/review/analyses/${analysisId}`, { credentials: 'same-origin' });
|
||||
if (!resp.ok) throw new Error('Failed to load review');
|
||||
const data = await resp.json();
|
||||
|
||||
@@ -518,7 +518,7 @@
|
||||
const head = data.head_ref;
|
||||
|
||||
// Load diff from API
|
||||
const diffResp = await fetch(`/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}`);
|
||||
const diffResp = await fetch(`/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}`, { credentials: 'same-origin' });
|
||||
if (diffResp.ok) {
|
||||
const diffData = await diffResp.json();
|
||||
const diffString = diffData.diff || '';
|
||||
|
||||
@@ -87,6 +87,7 @@ document.getElementById('settings-form').addEventListener('submit', async functi
|
||||
const resp = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!resp.ok) throw new Error('保存失败');
|
||||
|
||||
Reference in New Issue
Block a user