登录
+ ++ 还没有账号?注册 +
+diff --git a/config/config.go b/config/config.go index f3b9e28..d4601c2 100644 --- a/config/config.go +++ b/config/config.go @@ -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) diff --git a/database/db.go b/database/db.go index f0af3c0..5f2f8da 100644 --- a/database/db.go +++ b/database/db.go @@ -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 } diff --git a/go.mod b/go.mod index b279545..29a15a6 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 4fdf6bb..1f39b69 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/handlers/auth.go b/handlers/auth.go new file mode 100644 index 0000000..b5304eb --- /dev/null +++ b/handlers/auth.go @@ -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", +} diff --git a/handlers/generate.go b/handlers/generate.go index 9c30e4f..c85fd00 100644 --- a/handlers/generate.go +++ b/handlers/generate.go @@ -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 } diff --git a/handlers/middleware.go b/handlers/middleware.go new file mode 100644 index 0000000..2576c4b --- /dev/null +++ b/handlers/middleware.go @@ -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 +} diff --git a/handlers/pages.go b/handlers/pages.go index 0337143..9e0885e 100644 --- a/handlers/pages.go +++ b/handlers/pages.go @@ -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 } diff --git a/handlers/repos.go b/handlers/repos.go index 2f49675..f34458b 100644 --- a/handlers/repos.go +++ b/handlers/repos.go @@ -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 diff --git a/handlers/review.go b/handlers/review.go index 157403f..9a1fe2b 100644 --- a/handlers/review.go +++ b/handlers/review.go @@ -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 diff --git a/handlers/settings.go b/handlers/settings.go index ab37684..224b090 100644 --- a/handlers/settings.go +++ b/handlers/settings.go @@ -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()}) diff --git a/main.go b/main.go index ac45528..1d33145 100644 --- a/main.go +++ b/main.go @@ -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{ diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..fd2aeb8 --- /dev/null +++ b/models/user.go @@ -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"` +} diff --git a/services/generate.go b/services/generate.go index d103f39..d597edc 100644 --- a/services/generate.go +++ b/services/generate.go @@ -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 } diff --git a/services/llm.go b/services/llm.go index 91e8cce..7a9791e 100644 --- a/services/llm.go +++ b/services/llm.go @@ -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) } diff --git a/services/review.go b/services/review.go index ca1d852..8063075 100644 --- a/services/review.go +++ b/services/review.go @@ -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 } diff --git a/static/js/diff-viewer.js b/static/js/diff-viewer.js index 5a3f4fe..43e6bd0 100644 --- a/static/js/diff-viewer.js +++ b/static/js/diff-viewer.js @@ -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(); diff --git a/static/js/graph.js b/static/js/graph.js index 24662ac..21e8427 100644 --- a/static/js/graph.js +++ b/static/js/graph.js @@ -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(); diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 6105491..b72efad 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -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 || '', diff --git a/static/js/sse.js b/static/js/sse.js index 7039c98..017d9e4 100644 --- a/static/js/sse.js +++ b/static/js/sse.js @@ -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, }); diff --git a/templates/layouts/base.html b/templates/layouts/base.html index 5f0c623..8f21669 100644 --- a/templates/layouts/base.html +++ b/templates/layouts/base.html @@ -60,6 +60,14 @@ 首页 设置 +
+ 还没有账号?注册 +
++ 已有账号?登录 +
+