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:
2026-06-20 21:57:46 +08:00
parent 953c83d9aa
commit 9c843b79ba
28 changed files with 837 additions and 176 deletions
+16 -6
View File
@@ -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
View File
@@ -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
}
+22 -15
View File
@@ -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
)
+47 -34
View File
@@ -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=
+160
View File
@@ -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
View File
@@ -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
}
+58
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()})
+44 -22
View File
@@ -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{
+12
View File
@@ -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"`
}
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+2 -2
View File
@@ -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
View File
@@ -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();
+3 -1
View File
@@ -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 || '',
+1
View File
@@ -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,
});
+8
View File
@@ -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>
+2 -2
View File
@@ -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 || [];
+1
View File
@@ -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})
});
+68
View File
@@ -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>
+83
View File
@@ -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>
+3 -3
View File
@@ -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 */ }
+5 -5
View File
@@ -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 || '';
+1
View File
@@ -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('保存失败');