Compare commits
3 Commits
ac9ac4cbc7
...
fab83b3e68
| Author | SHA1 | Date | |
|---|---|---|---|
| fab83b3e68 | |||
| babb07fb51 | |||
| e46ccc9c6e |
@@ -272,6 +272,7 @@ func main() {
|
||||
RpmMeta: rpmMeta,
|
||||
DockerBase: dockerBase,
|
||||
Uploads: uploadStore,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
var graphqlHandler http.Handler
|
||||
@@ -286,12 +287,37 @@ func main() {
|
||||
var user models.User
|
||||
var hash string
|
||||
var err error
|
||||
var key string
|
||||
user, hash, err = store.GetUserByUsername(username)
|
||||
if err != nil || hash == "" {
|
||||
if password != "" {
|
||||
key = password
|
||||
user, err = store.GetUserByAPIKeyHash(util.HashToken(key))
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if password == "" && username != "" {
|
||||
key = username
|
||||
user, err = store.GetUserByAPIKeyHash(util.HashToken(key))
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
err = auth.ComparePassword(hash, password)
|
||||
if err != nil {
|
||||
if password != "" {
|
||||
key = password
|
||||
user, err = store.GetUserByAPIKeyHash(util.HashToken(key))
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if user.Disabled {
|
||||
return false, nil
|
||||
}
|
||||
return user.ID != "", nil
|
||||
@@ -310,12 +336,24 @@ func main() {
|
||||
router.Handle("GET", "/api/health", api.Health)
|
||||
router.Handle("POST", "/api/login", api.Login)
|
||||
router.Handle("POST", "/api/logout", api.Logout)
|
||||
router.Handle("GET", "/api/auth/oidc/enabled", api.OIDCEnabled)
|
||||
router.Handle("GET", "/api/auth/oidc/login", api.OIDCLogin)
|
||||
router.Handle("GET", "/api/auth/oidc/callback", api.OIDCCallback)
|
||||
router.Handle("GET", "/api/me", api.Me)
|
||||
router.Handle("PATCH", "/api/me", api.UpdateMe)
|
||||
|
||||
router.Handle("GET", "/api/users", api.ListUsers)
|
||||
router.Handle("POST", "/api/users", api.CreateUser)
|
||||
router.Handle("PATCH", "/api/users/:id", api.UpdateUser)
|
||||
router.Handle("DELETE", "/api/users/:id", api.DeleteUser)
|
||||
router.Handle("POST", "/api/users/:id/disable", api.DisableUser)
|
||||
router.Handle("POST", "/api/users/:id/enable", api.EnableUser)
|
||||
router.Handle("GET", "/api/admin/auth", api.GetAuthSettings)
|
||||
router.Handle("PATCH", "/api/admin/auth", api.UpdateAuthSettings)
|
||||
router.Handle("POST", "/api/admin/auth/test", api.TestLDAPSettings)
|
||||
router.Handle("GET", "/api/admin/auth/ldap", api.GetAuthSettings)
|
||||
router.Handle("PATCH", "/api/admin/auth/ldap", api.UpdateAuthSettings)
|
||||
router.Handle("POST", "/api/admin/auth/ldap/test", api.TestLDAPSettings)
|
||||
|
||||
router.Handle("GET", "/api/projects", api.ListProjects)
|
||||
router.Handle("POST", "/api/projects", api.CreateProject)
|
||||
@@ -324,6 +362,7 @@ func main() {
|
||||
router.Handle("DELETE", "/api/projects/:id", api.DeleteProject)
|
||||
|
||||
router.Handle("GET", "/api/projects/:projectId/members", api.ListProjectMembers)
|
||||
router.Handle("GET", "/api/projects/:projectId/member-candidates", api.ListProjectMemberCandidates)
|
||||
router.Handle("POST", "/api/projects/:projectId/members", api.AddProjectMember)
|
||||
router.Handle("PATCH", "/api/projects/:projectId/members", api.UpdateProjectMember)
|
||||
router.Handle("DELETE", "/api/projects/:projectId/members/:userId", api.RemoveProjectMember)
|
||||
@@ -368,6 +407,17 @@ func main() {
|
||||
router.Handle("GET", "/api/repos/:id/docker/manifest", api.RepoDockerManifest)
|
||||
router.Handle("DELETE", "/api/repos/:id/docker/tag", api.RepoDockerDeleteTag)
|
||||
router.Handle("DELETE", "/api/repos/:id/docker/image", api.RepoDockerDeleteImage)
|
||||
router.Handle("POST", "/api/repos/:id/docker/tag/rename", api.RepoDockerRenameTag)
|
||||
router.Handle("POST", "/api/repos/:id/docker/image/rename", api.RepoDockerRenameImage)
|
||||
router.Handle("GET", "/api/me/keys", api.ListAPIKeys)
|
||||
router.Handle("POST", "/api/me/keys", api.CreateAPIKey)
|
||||
router.Handle("DELETE", "/api/me/keys/:id", api.DeleteAPIKey)
|
||||
router.Handle("POST", "/api/me/keys/:id/disable", api.DisableAPIKey)
|
||||
router.Handle("POST", "/api/me/keys/:id/enable", api.EnableAPIKey)
|
||||
router.Handle("GET", "/api/admin/api-keys", api.ListAdminAPIKeys)
|
||||
router.Handle("DELETE", "/api/admin/api-keys/:id", api.DeleteAdminAPIKey)
|
||||
router.Handle("POST", "/api/admin/api-keys/:id/disable", api.DisableAdminAPIKey)
|
||||
router.Handle("POST", "/api/admin/api-keys/:id/enable", api.EnableAdminAPIKey)
|
||||
|
||||
router.Handle("GET", "/api/projects/:projectId/issues", api.ListIssues)
|
||||
router.Handle("POST", "/api/projects/:projectId/issues", api.CreateIssue)
|
||||
@@ -398,6 +448,9 @@ func main() {
|
||||
mux.Handle("/api/", middleware.WithUser(store, middleware.AccessLog(logger, middleware.RequireAuth(router))))
|
||||
mux.Handle("/api/login", middleware.WithUser(store, middleware.AccessLog(logger, router)))
|
||||
mux.Handle("/api/logout", middleware.WithUser(store, middleware.AccessLog(logger, router)))
|
||||
mux.Handle("/api/auth/oidc/enabled", middleware.WithUser(store, middleware.AccessLog(logger, router)))
|
||||
mux.Handle("/api/auth/oidc/login", middleware.WithUser(store, middleware.AccessLog(logger, router)))
|
||||
mux.Handle("/api/auth/oidc/callback", middleware.WithUser(store, middleware.AccessLog(logger, router)))
|
||||
mux.Handle("/api/health", middleware.AccessLog(logger, router))
|
||||
mux.Handle("/", middleware.WithUser(store, spaHandler(filepath.Join("..", "frontend", "dist"))))
|
||||
|
||||
@@ -413,16 +466,29 @@ func main() {
|
||||
}
|
||||
|
||||
func spaHandler(root string) http.HandlerFunc {
|
||||
var fileServer http.Handler
|
||||
fileServer = http.FileServer(http.Dir(root))
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var path string
|
||||
path = filepath.Join(root, r.URL.Path)
|
||||
var reqPath string
|
||||
var cleaned string
|
||||
reqPath = strings.TrimPrefix(r.URL.Path, "/")
|
||||
cleaned = filepath.Clean(reqPath)
|
||||
if cleaned == "." {
|
||||
cleaned = ""
|
||||
}
|
||||
if strings.HasPrefix(cleaned, "..") {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
path = filepath.Join(root, cleaned)
|
||||
var info os.FileInfo
|
||||
var err error
|
||||
info, err = os.Stat(path)
|
||||
if err == nil && !info.IsDir() {
|
||||
fileServer.ServeHTTP(w, r)
|
||||
http.ServeFile(w, r, path)
|
||||
return
|
||||
}
|
||||
if filepath.Ext(cleaned) != "" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var indexPath string
|
||||
@@ -444,7 +510,7 @@ func bootstrapAdmin(store *db.Store) error {
|
||||
var hash string
|
||||
var err error
|
||||
|
||||
bootstrap = os.Getenv("BUN_BOOTSTRAP_ADMIN")
|
||||
bootstrap = os.Getenv("CODIT_BOOTSTRAP_ADMIN")
|
||||
if bootstrap == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
58
backend/internal/auth/auth_test.go
Normal file
58
backend/internal/auth/auth_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package auth
|
||||
|
||||
import "strings"
|
||||
import "testing"
|
||||
import "time"
|
||||
|
||||
import "codit/internal/config"
|
||||
|
||||
func TestHashAndComparePassword(t *testing.T) {
|
||||
var hash string
|
||||
var err error
|
||||
hash, err = HashPassword("pw-123")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword() error: %v", err)
|
||||
}
|
||||
err = ComparePassword(hash, "pw-123")
|
||||
if err != nil {
|
||||
t.Fatalf("ComparePassword() failed for correct password: %v", err)
|
||||
}
|
||||
err = ComparePassword(hash, "wrong")
|
||||
if err == nil {
|
||||
t.Fatalf("ComparePassword() must fail for wrong password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSessionToken(t *testing.T) {
|
||||
var a string
|
||||
var b string
|
||||
var err error
|
||||
a, err = NewSessionToken()
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionToken() error: %v", err)
|
||||
}
|
||||
b, err = NewSessionToken()
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionToken() error for second token: %v", err)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatalf("session tokens must differ")
|
||||
}
|
||||
if strings.Contains(a, "=") {
|
||||
t.Fatalf("token must be raw base64 without padding: %s", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionExpiry(t *testing.T) {
|
||||
var cfg config.Config
|
||||
var before time.Time
|
||||
var after time.Time
|
||||
var exp time.Time
|
||||
before = time.Now().UTC()
|
||||
cfg.SessionTTL = config.Duration(2 * time.Hour)
|
||||
exp = SessionExpiry(cfg)
|
||||
after = time.Now().UTC()
|
||||
if exp.Before(before.Add(2*time.Hour-time.Second)) || exp.After(after.Add(2*time.Hour+time.Second)) {
|
||||
t.Fatalf("unexpected session expiry: %v", exp)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package auth
|
||||
|
||||
import "context"
|
||||
import "fmt"
|
||||
import "net"
|
||||
import "strings"
|
||||
import "time"
|
||||
import "crypto/tls"
|
||||
|
||||
import "codit/internal/config"
|
||||
import "github.com/go-ldap/ldap/v3"
|
||||
@@ -12,8 +16,15 @@ type LDAPUser struct {
|
||||
Email string
|
||||
}
|
||||
|
||||
const LDAPOperationTimeout time.Duration = 8 * time.Second
|
||||
|
||||
func LDAPAuthenticate(cfg config.Config, username, password string) (LDAPUser, error) {
|
||||
return LDAPAuthenticateContext(context.Background(), cfg, username, password)
|
||||
}
|
||||
|
||||
func LDAPAuthenticateContext(ctx context.Context, cfg config.Config, username, password string) (LDAPUser, error) {
|
||||
var conn *ldap.Conn
|
||||
var cleanup func()
|
||||
var err error
|
||||
var filter string
|
||||
var search *ldap.SearchRequest
|
||||
@@ -21,15 +32,18 @@ func LDAPAuthenticate(cfg config.Config, username, password string) (LDAPUser, e
|
||||
var entry *ldap.Entry
|
||||
var userDN string
|
||||
var user LDAPUser
|
||||
conn, err = ldap.DialURL(cfg.LDAPURL)
|
||||
conn, cleanup, err = ldapConnWithContext(ctx, cfg)
|
||||
if err != nil {
|
||||
return LDAPUser{}, err
|
||||
}
|
||||
defer conn.Close()
|
||||
defer cleanup()
|
||||
|
||||
if cfg.LDAPBindDN != "" {
|
||||
err = conn.Bind(cfg.LDAPBindDN, cfg.LDAPBindPassword)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return LDAPUser{}, ctx.Err()
|
||||
}
|
||||
return LDAPUser{}, err
|
||||
}
|
||||
}
|
||||
@@ -45,6 +59,9 @@ func LDAPAuthenticate(cfg config.Config, username, password string) (LDAPUser, e
|
||||
|
||||
res, err = conn.Search(search)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return LDAPUser{}, ctx.Err()
|
||||
}
|
||||
return LDAPUser{}, err
|
||||
}
|
||||
if len(res.Entries) == 0 {
|
||||
@@ -54,6 +71,9 @@ func LDAPAuthenticate(cfg config.Config, username, password string) (LDAPUser, e
|
||||
userDN = entry.DN
|
||||
err = conn.Bind(userDN, password)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return LDAPUser{}, ctx.Err()
|
||||
}
|
||||
return LDAPUser{}, err
|
||||
}
|
||||
|
||||
@@ -67,3 +87,64 @@ func LDAPAuthenticate(cfg config.Config, username, password string) (LDAPUser, e
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func LDAPTestConnection(cfg config.Config) error {
|
||||
return LDAPTestConnectionContext(context.Background(), cfg)
|
||||
}
|
||||
|
||||
func LDAPTestConnectionContext(ctx context.Context, cfg config.Config) error {
|
||||
var conn *ldap.Conn
|
||||
var cleanup func()
|
||||
var err error
|
||||
conn, cleanup, err = ldapConnWithContext(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
if cfg.LDAPBindDN != "" {
|
||||
err = conn.Bind(cfg.LDAPBindDN, cfg.LDAPBindPassword)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ldapConnWithContext(ctx context.Context, cfg config.Config) (*ldap.Conn, func(), error) {
|
||||
var opCtx context.Context
|
||||
var cancel context.CancelFunc
|
||||
var conn *ldap.Conn
|
||||
var err error
|
||||
var dialer *net.Dialer
|
||||
var tlsConfig *tls.Config
|
||||
var opts []ldap.DialOpt
|
||||
var done chan struct{}
|
||||
opCtx, cancel = context.WithTimeout(ctx, LDAPOperationTimeout)
|
||||
dialer = &net.Dialer{Timeout: LDAPOperationTimeout}
|
||||
opts = make([]ldap.DialOpt, 0, 2)
|
||||
opts = append(opts, ldap.DialWithDialer(dialer))
|
||||
tlsConfig = &tls.Config{InsecureSkipVerify: cfg.LDAPTLSInsecureSkipVerify}
|
||||
opts = append(opts, ldap.DialWithTLSConfig(tlsConfig))
|
||||
conn, err = ldap.DialURL(cfg.LDAPURL, opts...)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, nil, err
|
||||
}
|
||||
conn.SetTimeout(LDAPOperationTimeout)
|
||||
done = make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-opCtx.Done():
|
||||
_ = conn.Close()
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
return conn, func() {
|
||||
close(done)
|
||||
cancel()
|
||||
_ = conn.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import "errors"
|
||||
import "os"
|
||||
import "strings"
|
||||
import "time"
|
||||
import "strconv"
|
||||
|
||||
type Config struct {
|
||||
HTTPAddr string `json:"http_addr"`
|
||||
@@ -19,6 +20,16 @@ type Config struct {
|
||||
LDAPBindPassword string `json:"ldap_bind_password"`
|
||||
LDAPUserBaseDN string `json:"ldap_user_base_dn"`
|
||||
LDAPUserFilter string `json:"ldap_user_filter"`
|
||||
LDAPTLSInsecureSkipVerify bool `json:"ldap_tls_insecure_skip_verify"`
|
||||
OIDCClientID string `json:"oidc_client_id"`
|
||||
OIDCClientSecret string `json:"oidc_client_secret"`
|
||||
OIDCAuthorizeURL string `json:"oidc_authorize_url"`
|
||||
OIDCTokenURL string `json:"oidc_token_url"`
|
||||
OIDCUserInfoURL string `json:"oidc_userinfo_url"`
|
||||
OIDCRedirectURL string `json:"oidc_redirect_url"`
|
||||
OIDCScopes string `json:"oidc_scopes"`
|
||||
OIDCEnabled bool `json:"oidc_enabled"`
|
||||
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
|
||||
GitHTTPPrefix string `json:"git_http_prefix"`
|
||||
RPMHTTPPrefix string `json:"rpm_http_prefix"`
|
||||
}
|
||||
@@ -35,6 +46,7 @@ func Load(path string) (Config, error) {
|
||||
SessionTTL: Duration(24 * time.Hour),
|
||||
AuthMode: "db",
|
||||
LDAPUserFilter: "(uid={username})",
|
||||
OIDCScopes: "openid profile email",
|
||||
GitHTTPPrefix: "/git",
|
||||
RPMHTTPPrefix: "/rpm",
|
||||
}
|
||||
@@ -58,55 +70,95 @@ func Load(path string) (Config, error) {
|
||||
|
||||
func override(cfg *Config) {
|
||||
var v string
|
||||
v = os.Getenv("BUN_HTTP_ADDR")
|
||||
v = os.Getenv("CODIT_HTTP_ADDR")
|
||||
if v != "" {
|
||||
cfg.HTTPAddr = v
|
||||
}
|
||||
v = os.Getenv("BUN_PUBLIC_BASE_URL")
|
||||
v = os.Getenv("CODIT_PUBLIC_BASE_URL")
|
||||
if v != "" {
|
||||
cfg.PublicBaseURL = v
|
||||
}
|
||||
v = os.Getenv("BUN_DATA_DIR")
|
||||
v = os.Getenv("CODIT_DATA_DIR")
|
||||
if v != "" {
|
||||
cfg.DataDir = v
|
||||
}
|
||||
v = os.Getenv("BUN_DB_DRIVER")
|
||||
v = os.Getenv("CODIT_DB_DRIVER")
|
||||
if v != "" {
|
||||
cfg.DBDriver = v
|
||||
}
|
||||
v = os.Getenv("BUN_DB_DSN")
|
||||
v = os.Getenv("CODIT_DB_DSN")
|
||||
if v != "" {
|
||||
cfg.DBDSN = v
|
||||
}
|
||||
v = os.Getenv("BUN_AUTH_MODE")
|
||||
v = os.Getenv("CODIT_AUTH_MODE")
|
||||
if v != "" {
|
||||
cfg.AuthMode = v
|
||||
}
|
||||
v = os.Getenv("BUN_LDAP_URL")
|
||||
v = os.Getenv("CODIT_LDAP_URL")
|
||||
if v != "" {
|
||||
cfg.LDAPURL = v
|
||||
}
|
||||
v = os.Getenv("BUN_LDAP_BIND_DN")
|
||||
v = os.Getenv("CODIT_LDAP_BIND_DN")
|
||||
if v != "" {
|
||||
cfg.LDAPBindDN = v
|
||||
}
|
||||
v = os.Getenv("BUN_LDAP_BIND_PASSWORD")
|
||||
v = os.Getenv("CODIT_LDAP_BIND_PASSWORD")
|
||||
if v != "" {
|
||||
cfg.LDAPBindPassword = v
|
||||
}
|
||||
v = os.Getenv("BUN_LDAP_USER_BASE_DN")
|
||||
v = os.Getenv("CODIT_LDAP_USER_BASE_DN")
|
||||
if v != "" {
|
||||
cfg.LDAPUserBaseDN = v
|
||||
}
|
||||
v = os.Getenv("BUN_LDAP_USER_FILTER")
|
||||
v = os.Getenv("CODIT_LDAP_USER_FILTER")
|
||||
if v != "" {
|
||||
cfg.LDAPUserFilter = v
|
||||
}
|
||||
v = os.Getenv("BUN_GIT_HTTP_PREFIX")
|
||||
v = os.Getenv("CODIT_LDAP_TLS_INSECURE_SKIP_VERIFY")
|
||||
if v != "" {
|
||||
cfg.LDAPTLSInsecureSkipVerify = parseEnvBool(v)
|
||||
}
|
||||
v = os.Getenv("CODIT_OIDC_CLIENT_ID")
|
||||
if v != "" {
|
||||
cfg.OIDCClientID = v
|
||||
}
|
||||
v = os.Getenv("CODIT_OIDC_CLIENT_SECRET")
|
||||
if v != "" {
|
||||
cfg.OIDCClientSecret = v
|
||||
}
|
||||
v = os.Getenv("CODIT_OIDC_AUTHORIZE_URL")
|
||||
if v != "" {
|
||||
cfg.OIDCAuthorizeURL = v
|
||||
}
|
||||
v = os.Getenv("CODIT_OIDC_TOKEN_URL")
|
||||
if v != "" {
|
||||
cfg.OIDCTokenURL = v
|
||||
}
|
||||
v = os.Getenv("CODIT_OIDC_USERINFO_URL")
|
||||
if v != "" {
|
||||
cfg.OIDCUserInfoURL = v
|
||||
}
|
||||
v = os.Getenv("CODIT_OIDC_REDIRECT_URL")
|
||||
if v != "" {
|
||||
cfg.OIDCRedirectURL = v
|
||||
}
|
||||
v = os.Getenv("CODIT_OIDC_SCOPES")
|
||||
if v != "" {
|
||||
cfg.OIDCScopes = v
|
||||
}
|
||||
v = os.Getenv("CODIT_OIDC_ENABLED")
|
||||
if v != "" {
|
||||
cfg.OIDCEnabled = parseEnvBool(v)
|
||||
}
|
||||
v = os.Getenv("CODIT_OIDC_TLS_INSECURE_SKIP_VERIFY")
|
||||
if v != "" {
|
||||
cfg.OIDCTLSInsecureSkipVerify = parseEnvBool(v)
|
||||
}
|
||||
v = os.Getenv("CODIT_GIT_HTTP_PREFIX")
|
||||
if v != "" {
|
||||
cfg.GitHTTPPrefix = v
|
||||
}
|
||||
v = os.Getenv("BUN_RPM_HTTP_PREFIX")
|
||||
v = os.Getenv("CODIT_RPM_HTTP_PREFIX")
|
||||
if v != "" {
|
||||
cfg.RPMHTTPPrefix = v
|
||||
}
|
||||
@@ -139,3 +191,21 @@ func (d *Duration) UnmarshalJSON(b []byte) error {
|
||||
}
|
||||
return errors.New("invalid duration format")
|
||||
}
|
||||
|
||||
func parseEnvBool(v string) bool {
|
||||
var lowered string
|
||||
var parsed bool
|
||||
var err error
|
||||
lowered = strings.ToLower(strings.TrimSpace(v))
|
||||
if lowered == "true" || lowered == "yes" || lowered == "y" || lowered == "on" {
|
||||
return true
|
||||
}
|
||||
if lowered == "false" || lowered == "no" || lowered == "n" || lowered == "off" {
|
||||
return false
|
||||
}
|
||||
parsed, err = strconv.ParseBool(lowered)
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
66
backend/internal/config/config_test.go
Normal file
66
backend/internal/config/config_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package config
|
||||
|
||||
import "os"
|
||||
import "path/filepath"
|
||||
import "testing"
|
||||
import "time"
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
var cfg Config
|
||||
var err error
|
||||
cfg, err = Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if cfg.DBDriver == "" || cfg.DBDSN == "" {
|
||||
t.Fatalf("defaults not populated: driver=%q dsn=%q", cfg.DBDriver, cfg.DBDSN)
|
||||
}
|
||||
if cfg.GitHTTPPrefix != "/git" {
|
||||
t.Fatalf("unexpected git prefix default: %s", cfg.GitHTTPPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromJSONAndEnvOverride(t *testing.T) {
|
||||
var dir string
|
||||
var path string
|
||||
var data string
|
||||
var err error
|
||||
var cfg Config
|
||||
dir = t.TempDir()
|
||||
path = filepath.Join(dir, "config.json")
|
||||
data = `{"db_driver":"sqlite","db_dsn":"file:test.db","auth_mode":"HyBrId","git_http_prefix":"/g"}`
|
||||
err = os.WriteFile(path, []byte(data), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("write config file: %v", err)
|
||||
}
|
||||
t.Setenv("CODIT_DB_DSN", "file:override.db")
|
||||
cfg, err = Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if cfg.DBDSN != "file:override.db" {
|
||||
t.Fatalf("env override failed: %s", cfg.DBDSN)
|
||||
}
|
||||
if cfg.AuthMode != "hybrid" {
|
||||
t.Fatalf("auth_mode normalization failed: %s", cfg.AuthMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationUnmarshalJSON(t *testing.T) {
|
||||
var d Duration
|
||||
var err error
|
||||
err = d.UnmarshalJSON([]byte(`"90m"`))
|
||||
if err != nil {
|
||||
t.Fatalf("UnmarshalJSON() string duration error: %v", err)
|
||||
}
|
||||
if d.Duration() != 90*time.Minute {
|
||||
t.Fatalf("unexpected duration: %v", d.Duration())
|
||||
}
|
||||
err = d.UnmarshalJSON([]byte(`60000000000`))
|
||||
if err != nil {
|
||||
t.Fatalf("UnmarshalJSON() numeric duration error: %v", err)
|
||||
}
|
||||
if d.Duration() != time.Duration(60000000000) {
|
||||
t.Fatalf("unexpected numeric duration: %v", d.Duration())
|
||||
}
|
||||
}
|
||||
23
backend/internal/db/db_test.go
Normal file
23
backend/internal/db/db_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package db
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDriverNameNormalization(t *testing.T) {
|
||||
var got string
|
||||
got = driverName("sqlite3")
|
||||
if got != "sqlite" {
|
||||
t.Fatalf("sqlite3 normalize failed: %s", got)
|
||||
}
|
||||
got = driverName(" PostgreSQL ")
|
||||
if got != "postgres" {
|
||||
t.Fatalf("postgres normalize failed: %s", got)
|
||||
}
|
||||
got = driverName("mysql")
|
||||
if got != "mysql" {
|
||||
t.Fatalf("mysql normalize failed: %s", got)
|
||||
}
|
||||
got = driverName("custom")
|
||||
if got != "custom" {
|
||||
t.Fatalf("custom driver should pass through: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,9 @@ func (s *Store) CreateUser(user models.User, passwordHash string) (models.User,
|
||||
user.AuthSource = "db"
|
||||
}
|
||||
_, err = s.DB.Exec(`
|
||||
INSERT INTO users (id, username, display_name, email, password_hash, is_admin, auth_source, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, user.ID, user.Username, user.DisplayName, user.Email, passwordHash, user.IsAdmin, user.AuthSource, now, now)
|
||||
INSERT INTO users (id, username, display_name, email, password_hash, is_admin, disabled, auth_source, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, user.ID, user.Username, user.DisplayName, user.Email, passwordHash, user.IsAdmin, user.Disabled, user.AuthSource, now, now)
|
||||
return user, err
|
||||
}
|
||||
|
||||
@@ -41,11 +41,36 @@ func (s *Store) UpdateUser(user models.User) error {
|
||||
now = time.Now().UTC()
|
||||
nowUnix = now.Unix()
|
||||
user.UpdatedAt = nowUnix
|
||||
_, err = s.DB.Exec(`UPDATE users SET display_name = ?, email = ?, is_admin = ?, updated_at = ? WHERE id = ?`,
|
||||
user.DisplayName, user.Email, user.IsAdmin, now, user.ID)
|
||||
_, err = s.DB.Exec(`UPDATE users SET display_name = ?, email = ?, is_admin = ?, disabled = ?, updated_at = ? WHERE id = ?`,
|
||||
user.DisplayName, user.Email, user.IsAdmin, user.Disabled, now, user.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUserWithPassword(user models.User, passwordHash string) error {
|
||||
var err error
|
||||
var now time.Time
|
||||
var nowUnix int64
|
||||
var tx *sql.Tx
|
||||
now = time.Now().UTC()
|
||||
nowUnix = now.Unix()
|
||||
user.UpdatedAt = nowUnix
|
||||
tx, err = s.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`UPDATE users SET display_name = ?, email = ?, is_admin = ?, disabled = ?, password_hash = ?, updated_at = ? WHERE id = ?`,
|
||||
user.DisplayName, user.Email, user.IsAdmin, user.Disabled, passwordHash, now, user.ID)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetUserPassword(userID, passwordHash string) error {
|
||||
var err error
|
||||
_, err = s.DB.Exec(`UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, time.Now().UTC(), userID)
|
||||
@@ -58,8 +83,8 @@ func (s *Store) GetUserByID(id string) (models.User, error) {
|
||||
var created time.Time
|
||||
var updated time.Time
|
||||
var err error
|
||||
row = s.DB.QueryRow(`SELECT id, username, display_name, email, is_admin, auth_source, created_at, updated_at FROM users WHERE id = ?`, id)
|
||||
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.AuthSource, &created, &updated)
|
||||
row = s.DB.QueryRow(`SELECT id, username, display_name, email, is_admin, disabled, auth_source, created_at, updated_at FROM users WHERE id = ?`, id)
|
||||
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &created, &updated)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
@@ -75,8 +100,8 @@ func (s *Store) GetUserByUsername(username string) (models.User, string, error)
|
||||
var err error
|
||||
var created time.Time
|
||||
var updated time.Time
|
||||
row = s.DB.QueryRow(`SELECT id, username, display_name, email, is_admin, auth_source, password_hash, created_at, updated_at FROM users WHERE username = ?`, username)
|
||||
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.AuthSource, &passwordHash, &created, &updated)
|
||||
row = s.DB.QueryRow(`SELECT id, username, display_name, email, is_admin, disabled, auth_source, password_hash, created_at, updated_at FROM users WHERE username = ?`, username)
|
||||
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &passwordHash, &created, &updated)
|
||||
if err != nil {
|
||||
return user, passwordHash.String, err
|
||||
}
|
||||
@@ -92,13 +117,13 @@ func (s *Store) ListUsers() ([]models.User, error) {
|
||||
var u models.User
|
||||
var created time.Time
|
||||
var updated time.Time
|
||||
rows, err = s.DB.Query(`SELECT id, username, display_name, email, is_admin, auth_source, created_at, updated_at FROM users ORDER BY username`)
|
||||
rows, err = s.DB.Query(`SELECT id, username, display_name, email, is_admin, disabled, auth_source, created_at, updated_at FROM users ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
err = rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.IsAdmin, &u.AuthSource, &created, &updated)
|
||||
err = rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.IsAdmin, &u.Disabled, &u.AuthSource, &created, &updated)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -115,6 +140,417 @@ func (s *Store) DeleteUser(id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetAuthSettings() (models.AuthSettings, error) {
|
||||
var settings models.AuthSettings
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
var key string
|
||||
var value string
|
||||
rows, err = s.DB.Query(`SELECT key, value FROM app_settings WHERE key IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"auth.mode",
|
||||
"auth.oidc.enabled",
|
||||
"auth.ldap.url",
|
||||
"auth.ldap.bind_dn",
|
||||
"auth.ldap.bind_password",
|
||||
"auth.ldap.user_base_dn",
|
||||
"auth.ldap.user_filter",
|
||||
"auth.ldap.tls_insecure_skip_verify",
|
||||
"auth.oidc.client_id",
|
||||
"auth.oidc.client_secret",
|
||||
"auth.oidc.authorize_url",
|
||||
"auth.oidc.token_url",
|
||||
"auth.oidc.userinfo_url",
|
||||
"auth.oidc.redirect_url",
|
||||
"auth.oidc.scopes",
|
||||
"auth.oidc.tls_insecure_skip_verify")
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
err = rows.Scan(&key, &value)
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
switch key {
|
||||
case "auth.mode":
|
||||
settings.AuthMode = value
|
||||
case "auth.oidc.enabled":
|
||||
settings.OIDCEnabled = value == "1"
|
||||
case "auth.ldap.url":
|
||||
settings.LDAPURL = value
|
||||
case "auth.ldap.bind_dn":
|
||||
settings.LDAPBindDN = value
|
||||
case "auth.ldap.bind_password":
|
||||
settings.LDAPBindPassword = value
|
||||
case "auth.ldap.user_base_dn":
|
||||
settings.LDAPUserBaseDN = value
|
||||
case "auth.ldap.user_filter":
|
||||
settings.LDAPUserFilter = value
|
||||
case "auth.ldap.tls_insecure_skip_verify":
|
||||
settings.LDAPTLSInsecureSkipVerify = value == "1"
|
||||
case "auth.oidc.client_id":
|
||||
settings.OIDCClientID = value
|
||||
case "auth.oidc.client_secret":
|
||||
settings.OIDCClientSecret = value
|
||||
case "auth.oidc.authorize_url":
|
||||
settings.OIDCAuthorizeURL = value
|
||||
case "auth.oidc.token_url":
|
||||
settings.OIDCTokenURL = value
|
||||
case "auth.oidc.userinfo_url":
|
||||
settings.OIDCUserInfoURL = value
|
||||
case "auth.oidc.redirect_url":
|
||||
settings.OIDCRedirectURL = value
|
||||
case "auth.oidc.scopes":
|
||||
settings.OIDCScopes = value
|
||||
case "auth.oidc.tls_insecure_skip_verify":
|
||||
settings.OIDCTLSInsecureSkipVerify = value == "1"
|
||||
}
|
||||
}
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetAuthSettings(settings models.AuthSettings) error {
|
||||
var tx *sql.Tx
|
||||
var err error
|
||||
var now int64
|
||||
var tlsInsecure string
|
||||
tx, err = s.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now = time.Now().UTC().Unix()
|
||||
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||
"auth.mode", settings.AuthMode, now)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if settings.OIDCEnabled {
|
||||
tlsInsecure = "1"
|
||||
} else {
|
||||
tlsInsecure = "0"
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||
"auth.oidc.enabled", tlsInsecure, now)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||
"auth.ldap.url", settings.LDAPURL, now)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||
"auth.ldap.bind_dn", settings.LDAPBindDN, now)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||
"auth.ldap.bind_password", settings.LDAPBindPassword, now)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||
"auth.ldap.user_base_dn", settings.LDAPUserBaseDN, now)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||
"auth.ldap.user_filter", settings.LDAPUserFilter, now)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if settings.LDAPTLSInsecureSkipVerify {
|
||||
tlsInsecure = "1"
|
||||
} else {
|
||||
tlsInsecure = "0"
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||
"auth.ldap.tls_insecure_skip_verify", tlsInsecure, now)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||
"auth.oidc.client_id", settings.OIDCClientID, now)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||
"auth.oidc.client_secret", settings.OIDCClientSecret, now)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||
"auth.oidc.authorize_url", settings.OIDCAuthorizeURL, now)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||
"auth.oidc.token_url", settings.OIDCTokenURL, now)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||
"auth.oidc.userinfo_url", settings.OIDCUserInfoURL, now)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||
"auth.oidc.redirect_url", settings.OIDCRedirectURL, now)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||
"auth.oidc.scopes", settings.OIDCScopes, now)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if settings.OIDCTLSInsecureSkipVerify {
|
||||
tlsInsecure = "1"
|
||||
} else {
|
||||
tlsInsecure = "0"
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
|
||||
"auth.oidc.tls_insecure_skip_verify", tlsInsecure, now)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetUserDisabled(id string, disabled bool) error {
|
||||
var err error
|
||||
var now time.Time
|
||||
now = time.Now().UTC()
|
||||
_, err = s.DB.Exec(`UPDATE users SET disabled = ?, updated_at = ? WHERE id = ?`, disabled, now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CreateAPIKey(userID string, name string, tokenHash string, prefix string, expiresAt int64) (models.APIKey, error) {
|
||||
var key models.APIKey
|
||||
var err error
|
||||
var now time.Time
|
||||
var nowUnix int64
|
||||
var id string
|
||||
id, err = util.NewID()
|
||||
if err != nil {
|
||||
return key, err
|
||||
}
|
||||
now = time.Now().UTC()
|
||||
nowUnix = now.Unix()
|
||||
key = models.APIKey{
|
||||
ID: id,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
Prefix: prefix,
|
||||
CreatedAt: nowUnix,
|
||||
LastUsedAt: 0,
|
||||
ExpiresAt: expiresAt,
|
||||
Disabled: false,
|
||||
}
|
||||
_, err = s.DB.Exec(`INSERT INTO api_keys (id, user_id, name, token_hash, token_prefix, created_at, last_used_at, expires_at, disabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
key.ID, key.UserID, key.Name, tokenHash, key.Prefix, key.CreatedAt, key.LastUsedAt, key.ExpiresAt, key.Disabled)
|
||||
return key, err
|
||||
}
|
||||
|
||||
func (s *Store) ListAPIKeys(userID string) ([]models.APIKey, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
var keys []models.APIKey
|
||||
var key models.APIKey
|
||||
rows, err = s.DB.Query(`SELECT id, user_id, name, token_prefix, created_at, last_used_at, expires_at, disabled FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
err = rows.Scan(&key.ID, &key.UserID, &key.Name, &key.Prefix, &key.CreatedAt, &key.LastUsedAt, &key.ExpiresAt, &key.Disabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAPIKey(userID string, id string) error {
|
||||
var err error
|
||||
_, err = s.DB.Exec(`DELETE FROM api_keys WHERE id = ? AND user_id = ?`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetAPIKeyDisabled(userID string, id string, disabled bool) error {
|
||||
var err error
|
||||
_, err = s.DB.Exec(`UPDATE api_keys SET disabled = ? WHERE id = ? AND user_id = ?`, disabled, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAPIKeyByID(id string) error {
|
||||
var err error
|
||||
_, err = s.DB.Exec(`DELETE FROM api_keys WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetAPIKeyDisabledByID(id string, disabled bool) error {
|
||||
var err error
|
||||
_, err = s.DB.Exec(`UPDATE api_keys SET disabled = ? WHERE id = ?`, disabled, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListAPIKeysAdmin(userID string, query string) ([]models.AdminAPIKey, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
var keys []models.AdminAPIKey
|
||||
var key models.AdminAPIKey
|
||||
var sqlQuery string
|
||||
var like string
|
||||
query = strings.TrimSpace(query)
|
||||
like = "%" + query + "%"
|
||||
if userID != "" && query != "" {
|
||||
sqlQuery = `
|
||||
SELECT
|
||||
k.id, k.user_id, u.username, u.display_name, u.email,
|
||||
k.name, k.token_prefix, k.created_at, k.last_used_at, k.expires_at, k.disabled
|
||||
FROM api_keys k
|
||||
JOIN users u ON u.id = k.user_id
|
||||
WHERE k.user_id = ?
|
||||
AND (k.name LIKE ? OR k.token_prefix LIKE ? OR u.username LIKE ? OR u.display_name LIKE ? OR u.email LIKE ?)
|
||||
ORDER BY k.created_at DESC
|
||||
`
|
||||
rows, err = s.DB.Query(sqlQuery, userID, like, like, like, like, like)
|
||||
} else if userID != "" {
|
||||
sqlQuery = `
|
||||
SELECT
|
||||
k.id, k.user_id, u.username, u.display_name, u.email,
|
||||
k.name, k.token_prefix, k.created_at, k.last_used_at, k.expires_at, k.disabled
|
||||
FROM api_keys k
|
||||
JOIN users u ON u.id = k.user_id
|
||||
WHERE k.user_id = ?
|
||||
ORDER BY k.created_at DESC
|
||||
`
|
||||
rows, err = s.DB.Query(sqlQuery, userID)
|
||||
} else if query != "" {
|
||||
sqlQuery = `
|
||||
SELECT
|
||||
k.id, k.user_id, u.username, u.display_name, u.email,
|
||||
k.name, k.token_prefix, k.created_at, k.last_used_at, k.expires_at, k.disabled
|
||||
FROM api_keys k
|
||||
JOIN users u ON u.id = k.user_id
|
||||
WHERE k.name LIKE ? OR k.token_prefix LIKE ? OR u.username LIKE ? OR u.display_name LIKE ? OR u.email LIKE ?
|
||||
ORDER BY k.created_at DESC
|
||||
`
|
||||
rows, err = s.DB.Query(sqlQuery, like, like, like, like, like)
|
||||
} else {
|
||||
sqlQuery = `
|
||||
SELECT
|
||||
k.id, k.user_id, u.username, u.display_name, u.email,
|
||||
k.name, k.token_prefix, k.created_at, k.last_used_at, k.expires_at, k.disabled
|
||||
FROM api_keys k
|
||||
JOIN users u ON u.id = k.user_id
|
||||
ORDER BY k.created_at DESC
|
||||
`
|
||||
rows, err = s.DB.Query(sqlQuery)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
err = rows.Scan(
|
||||
&key.ID,
|
||||
&key.UserID,
|
||||
&key.Username,
|
||||
&key.DisplayName,
|
||||
&key.Email,
|
||||
&key.Name,
|
||||
&key.Prefix,
|
||||
&key.CreatedAt,
|
||||
&key.LastUsedAt,
|
||||
&key.ExpiresAt,
|
||||
&key.Disabled,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByAPIKeyHash(tokenHash string) (models.User, error) {
|
||||
var user models.User
|
||||
var row *sql.Row
|
||||
var created time.Time
|
||||
var updated time.Time
|
||||
var keyID string
|
||||
var now time.Time
|
||||
var nowUnix int64
|
||||
var err error
|
||||
var currentUnix int64
|
||||
now = time.Now().UTC()
|
||||
currentUnix = now.Unix()
|
||||
row = s.DB.QueryRow(`
|
||||
SELECT u.id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, u.created_at, u.updated_at, k.id
|
||||
FROM api_keys k
|
||||
JOIN users u ON u.id = k.user_id
|
||||
WHERE k.token_hash = ?
|
||||
AND u.disabled = 0
|
||||
AND k.disabled = 0
|
||||
AND (k.expires_at = 0 OR k.expires_at > ?)
|
||||
LIMIT 1
|
||||
`, tokenHash, currentUnix)
|
||||
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &created, &updated, &keyID)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
user.CreatedAt = created.Unix()
|
||||
user.UpdatedAt = updated.Unix()
|
||||
now = time.Now().UTC()
|
||||
nowUnix = now.Unix()
|
||||
_, _ = s.DB.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, nowUnix, keyID)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(userID, token string, expiresAt time.Time) error {
|
||||
var err error
|
||||
_, err = s.DB.Exec(`INSERT INTO sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
@@ -136,11 +572,11 @@ func (s *Store) GetSessionUser(token string) (models.User, time.Time, error) {
|
||||
var created time.Time
|
||||
var updated time.Time
|
||||
row = s.DB.QueryRow(`
|
||||
SELECT u.id, u.username, u.display_name, u.email, u.is_admin, u.auth_source, u.created_at, u.updated_at, s.expires_at
|
||||
SELECT u.id, u.username, u.display_name, u.email, u.is_admin, u.disabled, u.auth_source, u.created_at, u.updated_at, s.expires_at
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token = ?
|
||||
WHERE s.token = ? AND u.disabled = 0
|
||||
`, token)
|
||||
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.AuthSource, &created, &updated, &expires)
|
||||
err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &created, &updated, &expires)
|
||||
if err != nil {
|
||||
return user, time.Time{}, err
|
||||
}
|
||||
@@ -173,12 +609,16 @@ func (s *Store) CreateProject(project models.Project) (models.Project, error) {
|
||||
if err != nil {
|
||||
return project, err
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO projects (id, slug, name, description, created_by, updated_by, created_at, updated_at, created_at_unix, updated_at_unix)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
if project.HomePage == "" {
|
||||
project.HomePage = "info"
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO projects (id, slug, name, description, home_page, created_by, updated_by, created_at, updated_at, created_at_unix, updated_at_unix)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
project.ID,
|
||||
project.Slug,
|
||||
project.Name,
|
||||
project.Description,
|
||||
project.HomePage,
|
||||
project.CreatedBy,
|
||||
project.UpdatedBy,
|
||||
now,
|
||||
@@ -206,10 +646,14 @@ func (s *Store) CreateProject(project models.Project) (models.Project, error) {
|
||||
|
||||
func (s *Store) UpdateProject(project models.Project) error {
|
||||
var err error
|
||||
_, err = s.DB.Exec(`UPDATE projects SET slug = ?, name = ?, description = ?, updated_at = ?, updated_by = ?, updated_at_unix = ? WHERE id = ?`,
|
||||
if project.HomePage == "" {
|
||||
project.HomePage = "info"
|
||||
}
|
||||
_, err = s.DB.Exec(`UPDATE projects SET slug = ?, name = ?, description = ?, home_page = ?, updated_at = ?, updated_by = ?, updated_at_unix = ? WHERE id = ?`,
|
||||
project.Slug,
|
||||
project.Name,
|
||||
project.Description,
|
||||
project.HomePage,
|
||||
time.Unix(project.UpdatedAt, 0).UTC(),
|
||||
project.UpdatedBy,
|
||||
project.UpdatedAt,
|
||||
@@ -222,7 +666,7 @@ func (s *Store) GetProject(id string) (models.Project, error) {
|
||||
var project models.Project
|
||||
var row *sql.Row
|
||||
row = s.DB.QueryRow(`
|
||||
SELECT p.id, p.slug, p.name, p.description,
|
||||
SELECT p.id, p.slug, p.name, p.description, p.home_page,
|
||||
p.created_by, p.updated_by,
|
||||
COALESCE(c.username, ''), COALESCE(u.username, ''),
|
||||
p.created_at_unix, p.updated_at_unix
|
||||
@@ -236,6 +680,7 @@ func (s *Store) GetProject(id string) (models.Project, error) {
|
||||
&project.Slug,
|
||||
&project.Name,
|
||||
&project.Description,
|
||||
&project.HomePage,
|
||||
&project.CreatedBy,
|
||||
&project.UpdatedBy,
|
||||
&project.CreatedByName,
|
||||
@@ -250,7 +695,7 @@ func (s *Store) GetProjectBySlug(slug string) (models.Project, error) {
|
||||
var row *sql.Row
|
||||
var err error
|
||||
row = s.DB.QueryRow(`
|
||||
SELECT p.id, p.slug, p.name, p.description,
|
||||
SELECT p.id, p.slug, p.name, p.description, p.home_page,
|
||||
p.created_by, p.updated_by,
|
||||
COALESCE(c.username, ''), COALESCE(u.username, ''),
|
||||
p.created_at_unix, p.updated_at_unix
|
||||
@@ -264,6 +709,7 @@ func (s *Store) GetProjectBySlug(slug string) (models.Project, error) {
|
||||
&project.Slug,
|
||||
&project.Name,
|
||||
&project.Description,
|
||||
&project.HomePage,
|
||||
&project.CreatedBy,
|
||||
&project.UpdatedBy,
|
||||
&project.CreatedByName,
|
||||
@@ -283,7 +729,7 @@ func (s *Store) ListProjects() ([]models.Project, error) {
|
||||
var projects []models.Project
|
||||
var p models.Project
|
||||
rows, err = s.DB.Query(`
|
||||
SELECT p.id, p.slug, p.name, p.description,
|
||||
SELECT p.id, p.slug, p.name, p.description, p.home_page,
|
||||
p.created_by, p.updated_by,
|
||||
COALESCE(c.username, ''), COALESCE(u.username, ''),
|
||||
p.created_at_unix, p.updated_at_unix
|
||||
@@ -302,6 +748,50 @@ func (s *Store) ListProjects() ([]models.Project, error) {
|
||||
&p.Slug,
|
||||
&p.Name,
|
||||
&p.Description,
|
||||
&p.HomePage,
|
||||
&p.CreatedBy,
|
||||
&p.UpdatedBy,
|
||||
&p.CreatedByName,
|
||||
&p.UpdatedByName,
|
||||
&p.CreatedAt,
|
||||
&p.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
projects = append(projects, p)
|
||||
}
|
||||
return projects, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListProjectsForUser(userID string) ([]models.Project, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
var projects []models.Project
|
||||
var p models.Project
|
||||
rows, err = s.DB.Query(`
|
||||
SELECT p.id, p.slug, p.name, p.description, p.home_page,
|
||||
p.created_by, p.updated_by,
|
||||
COALESCE(c.username, ''), COALESCE(u.username, ''),
|
||||
p.created_at_unix, p.updated_at_unix
|
||||
FROM projects p
|
||||
JOIN project_members m ON m.project_id = p.id
|
||||
LEFT JOIN users c ON c.id = p.created_by
|
||||
LEFT JOIN users u ON u.id = p.updated_by
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY p.name
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
err = rows.Scan(
|
||||
&p.ID,
|
||||
&p.Slug,
|
||||
&p.Name,
|
||||
&p.Description,
|
||||
&p.HomePage,
|
||||
&p.CreatedBy,
|
||||
&p.UpdatedBy,
|
||||
&p.CreatedByName,
|
||||
@@ -330,10 +820,10 @@ func (s *Store) ListProjectsFiltered(limit int, offset int, query string) ([]mod
|
||||
}
|
||||
if query == "" {
|
||||
rows, err = s.DB.Query(
|
||||
`SELECT p.id, p.slug, p.name, p.description,
|
||||
p.created_by, p.updated_by,
|
||||
COALESCE(c.username, ''), COALESCE(u.username, ''),
|
||||
p.created_at_unix, p.updated_at_unix
|
||||
`SELECT p.id, p.slug, p.name, p.description, p.home_page,
|
||||
p.created_by, p.updated_by,
|
||||
COALESCE(c.username, ''), COALESCE(u.username, ''),
|
||||
p.created_at_unix, p.updated_at_unix
|
||||
FROM projects p
|
||||
LEFT JOIN users c ON c.id = p.created_by
|
||||
LEFT JOIN users u ON u.id = p.updated_by
|
||||
@@ -343,10 +833,10 @@ func (s *Store) ListProjectsFiltered(limit int, offset int, query string) ([]mod
|
||||
)
|
||||
} else {
|
||||
rows, err = s.DB.Query(
|
||||
`SELECT p.id, p.slug, p.name, p.description,
|
||||
p.created_by, p.updated_by,
|
||||
COALESCE(c.username, ''), COALESCE(u.username, ''),
|
||||
p.created_at_unix, p.updated_at_unix
|
||||
`SELECT p.id, p.slug, p.name, p.description, p.home_page,
|
||||
p.created_by, p.updated_by,
|
||||
COALESCE(c.username, ''), COALESCE(u.username, ''),
|
||||
p.created_at_unix, p.updated_at_unix
|
||||
FROM projects p
|
||||
LEFT JOIN users c ON c.id = p.created_by
|
||||
LEFT JOIN users u ON u.id = p.updated_by
|
||||
@@ -368,6 +858,79 @@ func (s *Store) ListProjectsFiltered(limit int, offset int, query string) ([]mod
|
||||
&p.Slug,
|
||||
&p.Name,
|
||||
&p.Description,
|
||||
&p.HomePage,
|
||||
&p.CreatedBy,
|
||||
&p.UpdatedBy,
|
||||
&p.CreatedByName,
|
||||
&p.UpdatedByName,
|
||||
&p.CreatedAt,
|
||||
&p.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
projects = append(projects, p)
|
||||
}
|
||||
return projects, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListProjectsFilteredForUser(userID string, limit int, offset int, query string) ([]models.Project, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
var projects []models.Project
|
||||
var p models.Project
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if query == "" {
|
||||
rows, err = s.DB.Query(
|
||||
`SELECT p.id, p.slug, p.name, p.description, p.home_page,
|
||||
p.created_by, p.updated_by,
|
||||
COALESCE(c.username, ''), COALESCE(u.username, ''),
|
||||
p.created_at_unix, p.updated_at_unix
|
||||
FROM projects p
|
||||
JOIN project_members m ON m.project_id = p.id
|
||||
LEFT JOIN users c ON c.id = p.created_by
|
||||
LEFT JOIN users u ON u.id = p.updated_by
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY p.name LIMIT ? OFFSET ?`,
|
||||
userID,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.DB.Query(
|
||||
`SELECT p.id, p.slug, p.name, p.description, p.home_page,
|
||||
p.created_by, p.updated_by,
|
||||
COALESCE(c.username, ''), COALESCE(u.username, ''),
|
||||
p.created_at_unix, p.updated_at_unix
|
||||
FROM projects p
|
||||
JOIN project_members m ON m.project_id = p.id
|
||||
LEFT JOIN users c ON c.id = p.created_by
|
||||
LEFT JOIN users u ON u.id = p.updated_by
|
||||
WHERE m.user_id = ? AND (p.name LIKE ? OR p.slug LIKE ?)
|
||||
ORDER BY p.name LIMIT ? OFFSET ?`,
|
||||
userID,
|
||||
"%"+query+"%",
|
||||
"%"+query+"%",
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
err = rows.Scan(
|
||||
&p.ID,
|
||||
&p.Slug,
|
||||
&p.Name,
|
||||
&p.Description,
|
||||
&p.HomePage,
|
||||
&p.CreatedBy,
|
||||
&p.UpdatedBy,
|
||||
&p.CreatedByName,
|
||||
|
||||
287
backend/internal/docker/browse.go
Normal file
287
backend/internal/docker/browse.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package docker
|
||||
|
||||
import "encoding/json"
|
||||
import "errors"
|
||||
import "io/fs"
|
||||
import "os"
|
||||
import "path/filepath"
|
||||
import "strings"
|
||||
|
||||
type TagInfo struct {
|
||||
Tag string `json:"tag"`
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
MediaType string `json:"media_type"`
|
||||
}
|
||||
|
||||
type ManifestDetail struct {
|
||||
Reference string `json:"reference"`
|
||||
Digest string `json:"digest"`
|
||||
MediaType string `json:"media_type"`
|
||||
Size int64 `json:"size"`
|
||||
Config ImageConfig `json:"config"`
|
||||
Layers []ociDescriptor `json:"layers"`
|
||||
}
|
||||
|
||||
type ImageConfig struct {
|
||||
Created string `json:"created"`
|
||||
Architecture string `json:"architecture"`
|
||||
OS string `json:"os"`
|
||||
}
|
||||
|
||||
type manifestPayload struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
MediaType string `json:"mediaType"`
|
||||
Config ociDescriptor `json:"config"`
|
||||
Layers []ociDescriptor `json:"layers"`
|
||||
}
|
||||
|
||||
func ListTags(repoPath string) ([]TagInfo, error) {
|
||||
var tags []string
|
||||
var err error
|
||||
var list []TagInfo
|
||||
var i int
|
||||
var tag string
|
||||
var desc ociDescriptor
|
||||
tags, err = listTags(repoPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = make([]TagInfo, 0, len(tags))
|
||||
for i = 0; i < len(tags); i++ {
|
||||
tag = tags[i]
|
||||
desc, err = resolveTag(repoPath, tag)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
list = append(list, TagInfo{
|
||||
Tag: tag,
|
||||
Digest: desc.Digest,
|
||||
Size: desc.Size,
|
||||
MediaType: desc.MediaType,
|
||||
})
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func DeleteTag(repoPath string, tag string) error {
|
||||
var idx ociIndex
|
||||
var err error
|
||||
var updated []ociDescriptor
|
||||
var i int
|
||||
var desc ociDescriptor
|
||||
var keep bool
|
||||
idx, err = loadIndex(repoPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated = make([]ociDescriptor, 0, len(idx.Manifests))
|
||||
for i = 0; i < len(idx.Manifests); i++ {
|
||||
desc = idx.Manifests[i]
|
||||
keep = true
|
||||
if desc.Annotations != nil && desc.Annotations[ociTagAnnotation] == tag {
|
||||
keep = false
|
||||
}
|
||||
if keep {
|
||||
updated = append(updated, desc)
|
||||
}
|
||||
}
|
||||
idx.Manifests = updated
|
||||
return saveIndex(repoPath, idx)
|
||||
}
|
||||
|
||||
func DeleteImage(repoPath string, image string) error {
|
||||
var imagePath string
|
||||
imagePath = ImagePath(repoPath, image)
|
||||
return os.RemoveAll(imagePath)
|
||||
}
|
||||
|
||||
func RenameTag(repoPath string, from string, to string) error {
|
||||
var idx ociIndex
|
||||
var err error
|
||||
var i int
|
||||
var desc ociDescriptor
|
||||
var hasFrom bool
|
||||
var hasTo bool
|
||||
if from == "" || to == "" {
|
||||
return errors.New("tag required")
|
||||
}
|
||||
if from == to {
|
||||
return errors.New("tag unchanged")
|
||||
}
|
||||
idx, err = loadIndex(repoPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i = 0; i < len(idx.Manifests); i++ {
|
||||
desc = idx.Manifests[i]
|
||||
if desc.Annotations == nil {
|
||||
continue
|
||||
}
|
||||
if desc.Annotations[ociTagAnnotation] == to {
|
||||
hasTo = true
|
||||
}
|
||||
}
|
||||
if hasTo {
|
||||
return errors.New("tag already exists")
|
||||
}
|
||||
for i = 0; i < len(idx.Manifests); i++ {
|
||||
desc = idx.Manifests[i]
|
||||
if desc.Annotations == nil {
|
||||
continue
|
||||
}
|
||||
if desc.Annotations[ociTagAnnotation] == from {
|
||||
desc.Annotations[ociTagAnnotation] = to
|
||||
idx.Manifests[i] = desc
|
||||
hasFrom = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasFrom {
|
||||
return ErrNotFound
|
||||
}
|
||||
return saveIndex(repoPath, idx)
|
||||
}
|
||||
|
||||
func RenameImage(repoPath string, from string, to string) error {
|
||||
var srcPath string
|
||||
var destPath string
|
||||
var err error
|
||||
var info os.FileInfo
|
||||
var dir string
|
||||
if from == to {
|
||||
return errors.New("image unchanged")
|
||||
}
|
||||
if IsReservedImagePath(to) {
|
||||
return errors.New("invalid image name")
|
||||
}
|
||||
srcPath = ImagePath(repoPath, from)
|
||||
destPath = ImagePath(repoPath, to)
|
||||
info, err = os.Stat(srcPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if info == nil || !info.IsDir() {
|
||||
return errors.New("source is not a directory")
|
||||
}
|
||||
_, err = os.Stat(destPath)
|
||||
if err == nil {
|
||||
return errors.New("target already exists")
|
||||
}
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
dir = filepath.Dir(destPath)
|
||||
err = os.MkdirAll(dir, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(srcPath, destPath)
|
||||
}
|
||||
|
||||
func ListImages(repoPath string) ([]string, error) {
|
||||
var images []string
|
||||
var err error
|
||||
var seen map[string]struct{}
|
||||
var root string
|
||||
var ok bool
|
||||
var dummy struct{}
|
||||
seen = map[string]struct{}{}
|
||||
root = filepath.Clean(repoPath)
|
||||
err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
var base string
|
||||
var rel string
|
||||
var dir string
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
base = entry.Name()
|
||||
if base == "blobs" || base == "uploads" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if entry.Name() != "oci-layout" {
|
||||
return nil
|
||||
}
|
||||
dir = filepath.Dir(path)
|
||||
rel, err = filepath.Rel(root, dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
rel = ""
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel == ".root" {
|
||||
rel = ""
|
||||
}
|
||||
dummy, ok = seen[rel]
|
||||
_ = dummy
|
||||
if !ok {
|
||||
seen[rel] = struct{}{}
|
||||
images = append(images, rel)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
return images, nil
|
||||
}
|
||||
|
||||
func ImagePath(repoPath string, image string) string {
|
||||
var cleaned string
|
||||
var normalized string
|
||||
cleaned = strings.Trim(image, "/")
|
||||
if cleaned == "" {
|
||||
return filepath.Join(repoPath, ".root")
|
||||
}
|
||||
normalized = strings.ReplaceAll(cleaned, "\\", "/")
|
||||
if normalized == ".root" {
|
||||
return filepath.Join(repoPath, ".root")
|
||||
}
|
||||
return filepath.Join(repoPath, filepath.FromSlash(cleaned))
|
||||
}
|
||||
|
||||
func GetManifestDetail(repoPath string, reference string) (ManifestDetail, error) {
|
||||
var detail ManifestDetail
|
||||
var desc ociDescriptor
|
||||
var err error
|
||||
var data []byte
|
||||
var payload manifestPayload
|
||||
var configData []byte
|
||||
var config ImageConfig
|
||||
desc, err = resolveManifest(repoPath, reference)
|
||||
if err != nil {
|
||||
return detail, err
|
||||
}
|
||||
data, err = ReadBlob(repoPath, desc.Digest)
|
||||
if err != nil {
|
||||
return detail, err
|
||||
}
|
||||
err = json.Unmarshal(data, &payload)
|
||||
if err != nil {
|
||||
return detail, err
|
||||
}
|
||||
if payload.MediaType != "" {
|
||||
desc.MediaType = payload.MediaType
|
||||
}
|
||||
configData, err = ReadBlob(repoPath, payload.Config.Digest)
|
||||
if err == nil {
|
||||
_ = json.Unmarshal(configData, &config)
|
||||
}
|
||||
detail = ManifestDetail{
|
||||
Reference: reference,
|
||||
Digest: desc.Digest,
|
||||
MediaType: desc.MediaType,
|
||||
Size: int64(len(data)),
|
||||
Config: config,
|
||||
Layers: payload.Layers,
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
325
backend/internal/docker/layout.go
Normal file
325
backend/internal/docker/layout.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package docker
|
||||
|
||||
import "crypto/sha256"
|
||||
import "encoding/hex"
|
||||
import "encoding/json"
|
||||
import "errors"
|
||||
import "io"
|
||||
import "os"
|
||||
import "path/filepath"
|
||||
import "strings"
|
||||
|
||||
var ErrNotFound error = errors.New("not found")
|
||||
|
||||
type ociLayout struct {
|
||||
ImageLayoutVersion string `json:"imageLayoutVersion"`
|
||||
}
|
||||
|
||||
type ociIndex struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
MediaType string `json:"mediaType,omitempty"`
|
||||
Manifests []ociDescriptor `json:"manifests"`
|
||||
}
|
||||
|
||||
type ociDescriptor struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
const ociIndexMediaType string = "application/vnd.oci.image.index.v1+json"
|
||||
const ociLayoutVersion string = "1.0.0"
|
||||
const ociTagAnnotation string = "org.opencontainers.image.ref.name"
|
||||
|
||||
func EnsureLayout(repoPath string) error {
|
||||
var err error
|
||||
var layoutPath string
|
||||
var indexPath string
|
||||
var blobsDir string
|
||||
err = os.MkdirAll(repoPath, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blobsDir = filepath.Join(repoPath, "blobs", "sha256")
|
||||
err = os.MkdirAll(blobsDir, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
layoutPath = filepath.Join(repoPath, "oci-layout")
|
||||
_, err = os.Stat(layoutPath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
err = writeJSONFile(layoutPath, ociLayout{ImageLayoutVersion: ociLayoutVersion})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
indexPath = filepath.Join(repoPath, "index.json")
|
||||
_, err = os.Stat(indexPath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
err = writeJSONFile(indexPath, ociIndex{SchemaVersion: 2, MediaType: ociIndexMediaType, Manifests: []ociDescriptor{}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func BlobPath(repoPath string, digest string) (string, bool) {
|
||||
var algo string
|
||||
var hexPart string
|
||||
var ok bool
|
||||
algo, hexPart, ok = parseDigest(digest)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if algo != "sha256" {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Join(repoPath, "blobs", "sha256", hexPart), true
|
||||
}
|
||||
|
||||
func HasBlob(repoPath string, digest string) (bool, error) {
|
||||
var path string
|
||||
var ok bool
|
||||
var err error
|
||||
path, ok = BlobPath(repoPath, digest)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
_, err = os.Stat(path)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func ReadBlob(repoPath string, digest string) ([]byte, error) {
|
||||
var path string
|
||||
var ok bool
|
||||
var data []byte
|
||||
var err error
|
||||
path, ok = BlobPath(repoPath, digest)
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
data, err = os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func WriteBlob(repoPath string, digest string, data []byte) error {
|
||||
var path string
|
||||
var ok bool
|
||||
var err error
|
||||
var dir string
|
||||
path, ok = BlobPath(repoPath, digest)
|
||||
if !ok {
|
||||
return errors.New("invalid digest")
|
||||
}
|
||||
dir = filepath.Dir(path)
|
||||
err = os.MkdirAll(dir, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.WriteFile(path, data, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ComputeDigest(data []byte) string {
|
||||
var sum [32]byte
|
||||
sum = sha256.Sum256(data)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func parseDigest(digest string) (string, string, bool) {
|
||||
var parts []string
|
||||
var algo string
|
||||
var hexPart string
|
||||
parts = strings.SplitN(digest, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", false
|
||||
}
|
||||
algo = parts[0]
|
||||
hexPart = parts[1]
|
||||
if algo == "" || hexPart == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return algo, hexPart, true
|
||||
}
|
||||
|
||||
func loadIndex(repoPath string) (ociIndex, error) {
|
||||
var idx ociIndex
|
||||
var path string
|
||||
var data []byte
|
||||
var err error
|
||||
path = filepath.Join(repoPath, "index.json")
|
||||
data, err = os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return idx, ErrNotFound
|
||||
}
|
||||
return idx, err
|
||||
}
|
||||
err = json.Unmarshal(data, &idx)
|
||||
if err != nil {
|
||||
return idx, err
|
||||
}
|
||||
if idx.Manifests == nil {
|
||||
idx.Manifests = []ociDescriptor{}
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func saveIndex(repoPath string, idx ociIndex) error {
|
||||
var path string
|
||||
path = filepath.Join(repoPath, "index.json")
|
||||
return writeJSONFile(path, idx)
|
||||
}
|
||||
|
||||
func updateTag(repoPath string, tag string, desc ociDescriptor) error {
|
||||
var idx ociIndex
|
||||
var err error
|
||||
var updated []ociDescriptor
|
||||
var i int
|
||||
var existing ociDescriptor
|
||||
if desc.Annotations == nil {
|
||||
desc.Annotations = map[string]string{}
|
||||
}
|
||||
desc.Annotations[ociTagAnnotation] = tag
|
||||
idx, err = loadIndex(repoPath)
|
||||
if err != nil {
|
||||
if err == ErrNotFound {
|
||||
idx = ociIndex{SchemaVersion: 2, MediaType: ociIndexMediaType, Manifests: []ociDescriptor{}}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
updated = make([]ociDescriptor, 0, len(idx.Manifests))
|
||||
for i = 0; i < len(idx.Manifests); i++ {
|
||||
existing = idx.Manifests[i]
|
||||
if existing.Annotations != nil && existing.Annotations[ociTagAnnotation] == tag {
|
||||
continue
|
||||
}
|
||||
updated = append(updated, existing)
|
||||
}
|
||||
updated = append(updated, desc)
|
||||
idx.Manifests = updated
|
||||
return saveIndex(repoPath, idx)
|
||||
}
|
||||
|
||||
func resolveTag(repoPath string, tag string) (ociDescriptor, error) {
|
||||
var idx ociIndex
|
||||
var err error
|
||||
var i int
|
||||
var desc ociDescriptor
|
||||
idx, err = loadIndex(repoPath)
|
||||
if err != nil {
|
||||
return desc, err
|
||||
}
|
||||
for i = 0; i < len(idx.Manifests); i++ {
|
||||
desc = idx.Manifests[i]
|
||||
if desc.Annotations != nil && desc.Annotations[ociTagAnnotation] == tag {
|
||||
return desc, nil
|
||||
}
|
||||
}
|
||||
return desc, ErrNotFound
|
||||
}
|
||||
|
||||
func listTags(repoPath string) ([]string, error) {
|
||||
var idx ociIndex
|
||||
var err error
|
||||
var tags []string
|
||||
var i int
|
||||
var desc ociDescriptor
|
||||
var tag string
|
||||
idx, err = loadIndex(repoPath)
|
||||
if err != nil {
|
||||
if err == ErrNotFound {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
tags = []string{}
|
||||
for i = 0; i < len(idx.Manifests); i++ {
|
||||
desc = idx.Manifests[i]
|
||||
if desc.Annotations == nil {
|
||||
continue
|
||||
}
|
||||
tag = desc.Annotations[ociTagAnnotation]
|
||||
if tag != "" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func writeJSONFile(path string, value interface{}) error {
|
||||
var data []byte
|
||||
var err error
|
||||
var temp string
|
||||
data, err = json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temp = path + ".tmp"
|
||||
err = os.WriteFile(temp, data, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temp, path)
|
||||
}
|
||||
|
||||
func computeDigestFromReader(reader io.Reader) (string, int64, error) {
|
||||
var hash hashWriter
|
||||
var size int64
|
||||
var err error
|
||||
hash = newHashWriter()
|
||||
size, err = io.Copy(&hash, reader)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return hash.Digest(), size, nil
|
||||
}
|
||||
|
||||
type hashWriter struct {
|
||||
hasher hashState
|
||||
}
|
||||
|
||||
type hashState interface {
|
||||
Write([]byte) (int, error)
|
||||
Sum([]byte) []byte
|
||||
}
|
||||
|
||||
func newHashWriter() hashWriter {
|
||||
var h hashWriter
|
||||
h.hasher = sha256.New()
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *hashWriter) Write(p []byte) (int, error) {
|
||||
return h.hasher.Write(p)
|
||||
}
|
||||
|
||||
func (h *hashWriter) Digest() string {
|
||||
var sum []byte
|
||||
sum = h.hasher.Sum(nil)
|
||||
return "sha256:" + hex.EncodeToString(sum)
|
||||
}
|
||||
660
backend/internal/docker/registry.go
Normal file
660
backend/internal/docker/registry.go
Normal file
@@ -0,0 +1,660 @@
|
||||
package docker
|
||||
|
||||
import "encoding/json"
|
||||
import "errors"
|
||||
import "fmt"
|
||||
import "io"
|
||||
import "net/http"
|
||||
import "os"
|
||||
import "path/filepath"
|
||||
import "strconv"
|
||||
import "strings"
|
||||
import "sync"
|
||||
import "time"
|
||||
|
||||
import "codit/internal/db"
|
||||
import "codit/internal/models"
|
||||
import "codit/internal/util"
|
||||
|
||||
type AuthFunc func(username, password string) (bool, error)
|
||||
|
||||
type HTTPServer struct {
|
||||
store *db.Store
|
||||
baseDir string
|
||||
auth AuthFunc
|
||||
logger *util.Logger
|
||||
}
|
||||
|
||||
func NewHTTPServer(store *db.Store, baseDir string, auth AuthFunc, logger *util.Logger) *HTTPServer {
|
||||
return &HTTPServer{
|
||||
store: store,
|
||||
baseDir: baseDir,
|
||||
auth: auth,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var ok bool
|
||||
var userLabel string
|
||||
var username string
|
||||
var password string
|
||||
var status int
|
||||
var recorder *statusRecorder
|
||||
if s.auth != nil {
|
||||
username, password, ok = r.BasicAuth()
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="docker"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
ok, _ = s.auth(username, password)
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="docker"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0")
|
||||
recorder = &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
s.handle(recorder, r)
|
||||
status = recorder.status
|
||||
if s.logger != nil {
|
||||
userLabel = "-"
|
||||
if username != "" {
|
||||
userLabel = username
|
||||
}
|
||||
s.logger.Write("docker", util.LOG_INFO, "method=%s path=%s remote=%s user=%s status=%d",
|
||||
r.Method, r.URL.Path, r.RemoteAddr, userLabel, status)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HTTPServer) handle(w http.ResponseWriter, r *http.Request) {
|
||||
var path string
|
||||
var action string
|
||||
var repoName string
|
||||
var rest string
|
||||
var repo models.Repo
|
||||
var project models.Project
|
||||
var imageName string
|
||||
var imagePath string
|
||||
var err error
|
||||
path = r.URL.Path
|
||||
if path == "/v2" || path == "/v2/" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
repoName, action, rest = parseV2Path(path)
|
||||
if action == "" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if action == "catalog" {
|
||||
s.handleCatalog(w, r)
|
||||
return
|
||||
}
|
||||
repo, project, imageName, err = s.resolveRepo(repoName)
|
||||
_ = project
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
imagePath = ImagePath(repo.Path, imageName)
|
||||
switch action {
|
||||
case "tags":
|
||||
s.handleTags(w, r, repo, repoName, imagePath)
|
||||
case "manifest":
|
||||
s.handleManifest(w, r, repo, repoName, rest, imagePath)
|
||||
case "blob":
|
||||
s.handleBlob(w, r, repo, repoName, rest, imagePath)
|
||||
case "upload_start":
|
||||
s.handleUploadStart(w, r, repo, repoName, imagePath)
|
||||
case "upload":
|
||||
s.handleUpload(w, r, repo, repoName, rest, imagePath)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func parseV2Path(path string) (string, string, string) {
|
||||
var p string
|
||||
var idx int
|
||||
p = strings.TrimPrefix(path, "/v2/")
|
||||
if p == "" || p == "/v2" {
|
||||
return "", "", ""
|
||||
}
|
||||
if strings.HasPrefix(p, "_catalog") {
|
||||
return "", "catalog", ""
|
||||
}
|
||||
if strings.HasSuffix(p, "/tags/list") {
|
||||
p = strings.TrimSuffix(p, "/tags/list")
|
||||
p = strings.TrimSuffix(p, "/")
|
||||
if p == "" {
|
||||
return "", "", ""
|
||||
}
|
||||
return p, "tags", ""
|
||||
}
|
||||
idx = strings.Index(p, "/manifests/")
|
||||
if idx >= 0 {
|
||||
return p[:idx], "manifest", p[idx+len("/manifests/"):]
|
||||
}
|
||||
idx = strings.Index(p, "/blobs/uploads/")
|
||||
if idx >= 0 {
|
||||
if strings.HasSuffix(p, "/blobs/uploads/") {
|
||||
return p[:idx], "upload_start", ""
|
||||
}
|
||||
return p[:idx], "upload", p[idx+len("/blobs/uploads/"):]
|
||||
}
|
||||
if strings.HasSuffix(p, "/blobs/uploads") {
|
||||
p = strings.TrimSuffix(p, "/blobs/uploads")
|
||||
p = strings.TrimSuffix(p, "/")
|
||||
return p, "upload_start", ""
|
||||
}
|
||||
idx = strings.Index(p, "/blobs/")
|
||||
if idx >= 0 {
|
||||
return p[:idx], "blob", p[idx+len("/blobs/"):]
|
||||
}
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
func (s *HTTPServer) resolveRepo(repoName string) (models.Repo, models.Project, string, error) {
|
||||
var parts []string
|
||||
var project models.Project
|
||||
var repo models.Repo
|
||||
var err error
|
||||
var slug string
|
||||
var name string
|
||||
var image string
|
||||
repoName = strings.Trim(repoName, "/")
|
||||
parts = strings.Split(repoName, "/")
|
||||
if len(parts) < 2 {
|
||||
return repo, project, "", errors.New("invalid repo name")
|
||||
}
|
||||
slug = parts[0]
|
||||
name = parts[1]
|
||||
if len(parts) > 2 {
|
||||
image = strings.Join(parts[2:], "/")
|
||||
}
|
||||
if IsReservedImagePath(image) {
|
||||
return repo, project, "", errors.New("invalid image name")
|
||||
}
|
||||
project, err = s.store.GetProjectBySlug(slug)
|
||||
if err != nil {
|
||||
return repo, project, "", err
|
||||
}
|
||||
repo, err = s.store.GetRepoByProjectNameType(project.ID, name, "docker")
|
||||
if err != nil {
|
||||
return repo, project, "", err
|
||||
}
|
||||
if repo.Path == "" {
|
||||
repo.Path = filepath.Join(s.baseDir, project.ID, repo.Name)
|
||||
}
|
||||
return repo, project, image, nil
|
||||
}
|
||||
|
||||
func IsReservedImagePath(image string) bool {
|
||||
var cleaned string
|
||||
var parts []string
|
||||
var i int
|
||||
var part string
|
||||
if image == "" {
|
||||
return false
|
||||
}
|
||||
cleaned = strings.Trim(image, "/")
|
||||
if cleaned == "" {
|
||||
return false
|
||||
}
|
||||
parts = strings.Split(cleaned, "/")
|
||||
for i = 0; i < len(parts); i++ {
|
||||
part = parts[i]
|
||||
if part == ".root" || part == "blobs" || part == "uploads" || part == "oci-layout" || part == "index.json" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *HTTPServer) handleCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
var repos []models.Repo
|
||||
var err error
|
||||
var names []string
|
||||
var i int
|
||||
var repo models.Repo
|
||||
var project models.Project
|
||||
var data []byte
|
||||
var response map[string][]string
|
||||
var images []string
|
||||
var j int
|
||||
var image string
|
||||
repos, err = s.store.ListAllRepos()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
names = []string{}
|
||||
for i = 0; i < len(repos); i++ {
|
||||
repo = repos[i]
|
||||
if repo.Type != "docker" {
|
||||
continue
|
||||
}
|
||||
project, err = s.store.GetProject(repo.ProjectID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
images, err = ListImages(repo.Path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if len(images) == 0 {
|
||||
names = append(names, project.Slug+"/"+repo.Name)
|
||||
continue
|
||||
}
|
||||
for j = 0; j < len(images); j++ {
|
||||
image = images[j]
|
||||
if image == "" {
|
||||
names = append(names, project.Slug+"/"+repo.Name)
|
||||
} else {
|
||||
names = append(names, project.Slug+"/"+repo.Name+"/"+image)
|
||||
}
|
||||
}
|
||||
}
|
||||
response = map[string][]string{"repositories": names}
|
||||
data, err = json.Marshal(response)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (s *HTTPServer) handleTags(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, imagePath string) {
|
||||
var tags []string
|
||||
var err error
|
||||
var response map[string]interface{}
|
||||
var data []byte
|
||||
tags, err = listTags(imagePath)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
response = map[string]interface{}{
|
||||
"name": name,
|
||||
"tags": tags,
|
||||
}
|
||||
data, err = json.Marshal(response)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (s *HTTPServer) handleManifest(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, reference string, imagePath string) {
|
||||
var desc ociDescriptor
|
||||
var err error
|
||||
var digest string
|
||||
var data []byte
|
||||
var mediaType string
|
||||
var ok bool
|
||||
if reference == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPut {
|
||||
data, err = io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "" {
|
||||
mediaType = r.Header.Get("Content-Type")
|
||||
} else {
|
||||
mediaType = detectManifestMediaType(data)
|
||||
}
|
||||
digest = ComputeDigest(data)
|
||||
err = EnsureLayout(imagePath)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ok, err = HasBlob(imagePath, digest)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
err = WriteBlob(imagePath, digest, data)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
desc = ociDescriptor{MediaType: mediaType, Digest: digest, Size: int64(len(data))}
|
||||
if !isDigestRef(reference) {
|
||||
err = withRepoLock(imagePath, func() error {
|
||||
return updateTag(imagePath, reference, desc)
|
||||
})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Docker-Content-Digest", digest)
|
||||
w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, digest))
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
desc, err = resolveManifest(imagePath, reference)
|
||||
if err != nil {
|
||||
if err == ErrNotFound {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data, err = ReadBlob(imagePath, desc.Digest)
|
||||
if err != nil {
|
||||
if err == ErrNotFound {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if desc.MediaType != "" {
|
||||
w.Header().Set("Content-Type", desc.MediaType)
|
||||
}
|
||||
w.Header().Set("Docker-Content-Digest", desc.Digest)
|
||||
if r.Method == http.MethodHead {
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(data)), 10))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (s *HTTPServer) handleBlob(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, digest string, imagePath string) {
|
||||
var data []byte
|
||||
var err error
|
||||
if digest == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
data, err = ReadBlob(imagePath, digest)
|
||||
if err != nil {
|
||||
if err == ErrNotFound {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Docker-Content-Digest", digest)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
if r.Method == http.MethodHead {
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(data)), 10))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (s *HTTPServer) handleUploadStart(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, imagePath string) {
|
||||
var id string
|
||||
var err error
|
||||
var uploadPath string
|
||||
var uploadDir string
|
||||
var created *os.File
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id, err = util.NewID()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
uploadDir = filepath.Join(imagePath, "uploads")
|
||||
err = os.MkdirAll(uploadDir, 0o755)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
uploadPath = filepath.Join(uploadDir, id)
|
||||
_, err = os.Stat(uploadPath)
|
||||
if err == nil {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
created, err = os.Create(uploadPath)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = created.Close()
|
||||
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, id))
|
||||
w.Header().Set("Docker-Upload-UUID", id)
|
||||
w.Header().Set("Range", "0-0")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
func (s *HTTPServer) handleUpload(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, uploadID string, imagePath string) {
|
||||
var uploadDir string
|
||||
var uploadPath string
|
||||
var err error
|
||||
var file *os.File
|
||||
var size int64
|
||||
var digest string
|
||||
var computed string
|
||||
var ok bool
|
||||
var data []byte
|
||||
var info os.FileInfo
|
||||
var appendFile *os.File
|
||||
var appendSize int64
|
||||
if uploadID == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
uploadDir = filepath.Join(imagePath, "uploads")
|
||||
uploadPath = filepath.Join(uploadDir, uploadID)
|
||||
if r.Method == http.MethodDelete {
|
||||
_ = os.Remove(uploadPath)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPatch {
|
||||
file, err = os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
_, err = io.Copy(file, r.Body)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
info, err = file.Stat()
|
||||
if err == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uploadID))
|
||||
w.Header().Set("Docker-Upload-UUID", uploadID)
|
||||
if size > 0 {
|
||||
w.Header().Set("Range", fmt.Sprintf("0-%d", size-1))
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPut {
|
||||
digest = r.URL.Query().Get("digest")
|
||||
if digest == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
appendFile, err = os.OpenFile(uploadPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
appendSize, err = io.Copy(appendFile, r.Body)
|
||||
_ = appendSize
|
||||
_ = appendFile.Close()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
file, err = os.Open(uploadPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
computed, size, err = computeDigestFromReader(file)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if computed != digest {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
data, err = os.ReadFile(uploadPath)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = EnsureLayout(imagePath)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ok, err = HasBlob(imagePath, digest)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
err = WriteBlob(imagePath, digest, data)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = os.Remove(uploadPath)
|
||||
w.Header().Set("Docker-Content-Digest", digest)
|
||||
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest))
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func resolveManifest(repoPath string, reference string) (ociDescriptor, error) {
|
||||
var desc ociDescriptor
|
||||
var err error
|
||||
var ok bool
|
||||
if isDigestRef(reference) {
|
||||
ok, err = HasBlob(repoPath, reference)
|
||||
if err != nil {
|
||||
return desc, err
|
||||
}
|
||||
if !ok {
|
||||
return desc, ErrNotFound
|
||||
}
|
||||
desc = ociDescriptor{Digest: reference}
|
||||
return desc, nil
|
||||
}
|
||||
desc, err = resolveTag(repoPath, reference)
|
||||
if err != nil {
|
||||
return desc, err
|
||||
}
|
||||
return desc, nil
|
||||
}
|
||||
|
||||
func isDigestRef(ref string) bool {
|
||||
return strings.HasPrefix(ref, "sha256:")
|
||||
}
|
||||
|
||||
func detectManifestMediaType(data []byte) string {
|
||||
var tmp map[string]interface{}
|
||||
var value interface{}
|
||||
var s string
|
||||
_ = json.Unmarshal(data, &tmp)
|
||||
if tmp == nil {
|
||||
return ""
|
||||
}
|
||||
value = tmp["mediaType"]
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
s, _ = value.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Flush() {
|
||||
var flusher http.Flusher
|
||||
var ok bool
|
||||
flusher, ok = r.ResponseWriter.(http.Flusher)
|
||||
if ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
var repoLocksMu sync.Mutex
|
||||
var repoLocks map[string]*sync.Mutex = map[string]*sync.Mutex{}
|
||||
|
||||
func repoLock(path string) *sync.Mutex {
|
||||
var lock *sync.Mutex
|
||||
var ok bool
|
||||
repoLocksMu.Lock()
|
||||
lock, ok = repoLocks[path]
|
||||
if !ok {
|
||||
lock = &sync.Mutex{}
|
||||
repoLocks[path] = lock
|
||||
}
|
||||
repoLocksMu.Unlock()
|
||||
return lock
|
||||
}
|
||||
|
||||
func withRepoLock(path string, fn func() error) error {
|
||||
var lock *sync.Mutex
|
||||
lock = repoLock(path)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
return fn()
|
||||
}
|
||||
|
||||
func init() {
|
||||
_ = time.Now()
|
||||
}
|
||||
35
backend/internal/docker/registry_test.go
Normal file
35
backend/internal/docker/registry_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package docker
|
||||
|
||||
import "path/filepath"
|
||||
import "testing"
|
||||
|
||||
func TestParseV2Path(t *testing.T) {
|
||||
var repo string
|
||||
var action string
|
||||
var rest string
|
||||
repo, action, rest = parseV2Path("/v2/p/r/tags/list")
|
||||
if repo != "p/r" || action != "tags" || rest != "" {
|
||||
t.Fatalf("unexpected parse for tags: repo=%s action=%s rest=%s", repo, action, rest)
|
||||
}
|
||||
repo, action, rest = parseV2Path("/v2/p/r/manifests/latest")
|
||||
if repo != "p/r" || action != "manifest" || rest != "latest" {
|
||||
t.Fatalf("unexpected parse for manifest: repo=%s action=%s rest=%s", repo, action, rest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsReservedImagePath(t *testing.T) {
|
||||
if !IsReservedImagePath(".root") {
|
||||
t.Fatalf(".root must be reserved")
|
||||
}
|
||||
if IsReservedImagePath("team/app") {
|
||||
t.Fatalf("normal image path must not be reserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImagePath(t *testing.T) {
|
||||
var path string
|
||||
path = ImagePath(filepath.Join("x", "repo"), "")
|
||||
if path != filepath.Join("x", "repo", ".root") {
|
||||
t.Fatalf("unexpected root image path: %s", path)
|
||||
}
|
||||
}
|
||||
38
backend/internal/git/http_test.go
Normal file
38
backend/internal/git/http_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package git
|
||||
|
||||
import "net/http"
|
||||
import "net/http/httptest"
|
||||
import "path/filepath"
|
||||
import "testing"
|
||||
|
||||
type flushRecorder struct {
|
||||
*httptest.ResponseRecorder
|
||||
flushed bool
|
||||
}
|
||||
|
||||
func (f *flushRecorder) Flush() {
|
||||
f.flushed = true
|
||||
}
|
||||
|
||||
func TestRepoPathFromRequest(t *testing.T) {
|
||||
var server HTTPServer
|
||||
var req *http.Request
|
||||
var got string
|
||||
server = HTTPServer{baseDir: filepath.Join("data", "git")}
|
||||
req = httptest.NewRequest(http.MethodGet, "/p/r.git/info/refs", nil)
|
||||
got = server.repoPathFromRequest(req)
|
||||
if got != filepath.Join("data", "git", "p", "r.git") {
|
||||
t.Fatalf("unexpected repo path: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusRecorderFlush(t *testing.T) {
|
||||
var base *flushRecorder
|
||||
var recorder *statusRecorder
|
||||
base = &flushRecorder{ResponseRecorder: httptest.NewRecorder()}
|
||||
recorder = &statusRecorder{ResponseWriter: base, status: http.StatusOK}
|
||||
recorder.Flush()
|
||||
if !base.flushed {
|
||||
t.Fatalf("expected Flush() to be forwarded")
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import "crypto/rand"
|
||||
import "encoding/hex"
|
||||
import "errors"
|
||||
import "io"
|
||||
import "mime/multipart"
|
||||
@@ -30,6 +32,7 @@ type API struct {
|
||||
RpmMeta *rpm.MetaManager
|
||||
DockerBase string
|
||||
Uploads storage.FileStore
|
||||
Logger *util.Logger
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -45,10 +48,48 @@ type createUserRequest struct {
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
type updateMeRequest struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type updateAuthSettingsRequest struct {
|
||||
AuthMode string `json:"auth_mode"`
|
||||
OIDCEnabled bool `json:"oidc_enabled"`
|
||||
LDAPURL string `json:"ldap_url"`
|
||||
LDAPBindDN string `json:"ldap_bind_dn"`
|
||||
LDAPBindPassword string `json:"ldap_bind_password"`
|
||||
LDAPUserBaseDN string `json:"ldap_user_base_dn"`
|
||||
LDAPUserFilter string `json:"ldap_user_filter"`
|
||||
LDAPTLSInsecureSkipVerify bool `json:"ldap_tls_insecure_skip_verify"`
|
||||
OIDCClientID string `json:"oidc_client_id"`
|
||||
OIDCClientSecret string `json:"oidc_client_secret"`
|
||||
OIDCAuthorizeURL string `json:"oidc_authorize_url"`
|
||||
OIDCTokenURL string `json:"oidc_token_url"`
|
||||
OIDCUserInfoURL string `json:"oidc_userinfo_url"`
|
||||
OIDCRedirectURL string `json:"oidc_redirect_url"`
|
||||
OIDCScopes string `json:"oidc_scopes"`
|
||||
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
|
||||
}
|
||||
|
||||
type testLDAPSettingsRequest struct {
|
||||
AuthMode string `json:"auth_mode"`
|
||||
LDAPURL string `json:"ldap_url"`
|
||||
LDAPBindDN string `json:"ldap_bind_dn"`
|
||||
LDAPBindPassword string `json:"ldap_bind_password"`
|
||||
LDAPUserBaseDN string `json:"ldap_user_base_dn"`
|
||||
LDAPUserFilter string `json:"ldap_user_filter"`
|
||||
LDAPTLSInsecureSkipVerify *bool `json:"ldap_tls_insecure_skip_verify"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type createProjectRequest struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
HomePage string `json:"home_page"`
|
||||
}
|
||||
|
||||
type createRepoRequest struct {
|
||||
@@ -134,6 +175,27 @@ type repoRPMRenameRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type createAPIKeyRequest struct {
|
||||
Name string `json:"name"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
type apiKeyResponse struct {
|
||||
models.APIKey
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
type repoDockerRenameTagRequest struct {
|
||||
Image string `json:"image"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
type repoDockerRenameImageRequest struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
type repoRPMUploadResponse struct {
|
||||
Filename string `json:"filename"`
|
||||
Size int64 `json:"size"`
|
||||
@@ -180,6 +242,7 @@ func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]strin
|
||||
var ldapUser auth.LDAPUser
|
||||
var newUser models.User
|
||||
var created models.User
|
||||
var authCfg config.Config
|
||||
err = DecodeJSON(r, &req)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
@@ -191,15 +254,30 @@ func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]strin
|
||||
}
|
||||
|
||||
user, storedHash, err = api.Store.GetUserByUsername(req.Username)
|
||||
if err == nil && user.Disabled {
|
||||
WriteJSON(w, http.StatusForbidden, map[string]string{"error": "user disabled"})
|
||||
return
|
||||
}
|
||||
if err == nil && storedHash != "" && auth.ComparePassword(storedHash, req.Password) == nil {
|
||||
api.issueSession(w, user)
|
||||
WriteJSON(w, http.StatusOK, user)
|
||||
return
|
||||
}
|
||||
|
||||
if api.Cfg.AuthMode == "ldap" || api.Cfg.AuthMode == "hybrid" {
|
||||
ldapUser, err = auth.LDAPAuthenticate(api.Cfg, req.Username, req.Password)
|
||||
authCfg, _ = api.effectiveAuthConfig()
|
||||
if authCfg.AuthMode == "ldap" || authCfg.AuthMode == "hybrid" {
|
||||
if api.Logger != nil {
|
||||
api.Logger.Write("auth", util.LOG_INFO, "ldap login attempt username=%s mode=%s", req.Username, authCfg.AuthMode)
|
||||
}
|
||||
ldapUser, err = auth.LDAPAuthenticateContext(r.Context(), authCfg, req.Username, req.Password)
|
||||
if err == nil {
|
||||
if api.Logger != nil {
|
||||
api.Logger.Write("auth", util.LOG_INFO, "ldap login success username=%s", req.Username)
|
||||
}
|
||||
if user.ID != "" && user.Disabled {
|
||||
WriteJSON(w, http.StatusForbidden, map[string]string{"error": "user disabled"})
|
||||
return
|
||||
}
|
||||
if user.ID == "" {
|
||||
newUser = models.User{
|
||||
Username: ldapUser.Username,
|
||||
@@ -219,6 +297,9 @@ func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]strin
|
||||
WriteJSON(w, http.StatusOK, user)
|
||||
return
|
||||
}
|
||||
if api.Logger != nil {
|
||||
api.Logger.Write("auth", util.LOG_WARN, "ldap login failed username=%s err=%v", req.Username, err)
|
||||
}
|
||||
}
|
||||
|
||||
WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
@@ -274,6 +355,61 @@ func (api *API) Me(w http.ResponseWriter, r *http.Request, _ map[string]string)
|
||||
WriteJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (api *API) UpdateMe(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||
var ctxUser models.User
|
||||
var ok bool
|
||||
var user models.User
|
||||
var req updateMeRequest
|
||||
var err error
|
||||
var hash string
|
||||
var newPassword bool
|
||||
ctxUser, ok = middleware.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
user, err = api.Store.GetUserByID(ctxUser.ID)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
err = DecodeJSON(r, &req)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
if req.DisplayName != "" {
|
||||
user.DisplayName = req.DisplayName
|
||||
}
|
||||
if req.Email != "" {
|
||||
user.Email = req.Email
|
||||
}
|
||||
newPassword = strings.TrimSpace(req.Password) != ""
|
||||
if newPassword && user.AuthSource != "db" {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "password update not supported for non-db users"})
|
||||
return
|
||||
}
|
||||
if newPassword {
|
||||
hash, err = auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
err = api.Store.UpdateUserWithPassword(user, hash)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err = api.Store.UpdateUser(user)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (api *API) ListUsers(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||
var users []models.User
|
||||
var err error
|
||||
@@ -331,6 +467,7 @@ func (api *API) UpdateUser(w http.ResponseWriter, r *http.Request, params map[st
|
||||
var err error
|
||||
var payload createUserRequest
|
||||
var hash string
|
||||
var newPassword bool
|
||||
if !api.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
@@ -351,15 +488,23 @@ func (api *API) UpdateUser(w http.ResponseWriter, r *http.Request, params map[st
|
||||
user.Email = payload.Email
|
||||
}
|
||||
user.IsAdmin = payload.IsAdmin
|
||||
err = api.Store.UpdateUser(user)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if payload.Password != "" {
|
||||
newPassword = payload.Password != ""
|
||||
if newPassword {
|
||||
hash, err = auth.HashPassword(payload.Password)
|
||||
if err == nil {
|
||||
_ = api.Store.SetUserPassword(user.ID, hash)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
err = api.Store.UpdateUserWithPassword(user, hash)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err = api.Store.UpdateUser(user)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, user)
|
||||
@@ -378,6 +523,194 @@ func (api *API) DeleteUser(w http.ResponseWriter, r *http.Request, params map[st
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *API) DisableUser(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var err error
|
||||
if !api.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
err = api.Store.SetUserDisabled(params["id"], true)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *API) EnableUser(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var err error
|
||||
if !api.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
err = api.Store.SetUserDisabled(params["id"], false)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *API) GetAuthSettings(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||
var settings models.AuthSettings
|
||||
var err error
|
||||
var user models.User
|
||||
var ok bool
|
||||
if !api.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if ok && api.Logger != nil {
|
||||
api.Logger.Write("auth", util.LOG_INFO, "auth settings fetch requested by user=%s", user.Username)
|
||||
}
|
||||
settings, err = api.getMergedAuthSettings()
|
||||
if err != nil {
|
||||
if api.Logger != nil {
|
||||
api.Logger.Write("auth", util.LOG_ERROR, "auth settings fetch failed err=%v", err)
|
||||
}
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if api.Logger != nil {
|
||||
api.Logger.Write("auth", util.LOG_INFO, "auth settings fetch success mode=%s ldap_url=%s oidc_authorize_url=%s", settings.AuthMode, settings.LDAPURL, settings.OIDCAuthorizeURL)
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func (api *API) UpdateAuthSettings(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||
var req updateAuthSettingsRequest
|
||||
var err error
|
||||
var settings models.AuthSettings
|
||||
var user models.User
|
||||
var ok bool
|
||||
if !api.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
err = DecodeJSON(r, &req)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
settings = models.AuthSettings{
|
||||
AuthMode: normalizeAuthMode(req.AuthMode),
|
||||
OIDCEnabled: req.OIDCEnabled,
|
||||
LDAPURL: strings.TrimSpace(req.LDAPURL),
|
||||
LDAPBindDN: strings.TrimSpace(req.LDAPBindDN),
|
||||
LDAPBindPassword: req.LDAPBindPassword,
|
||||
LDAPUserBaseDN: strings.TrimSpace(req.LDAPUserBaseDN),
|
||||
LDAPUserFilter: strings.TrimSpace(req.LDAPUserFilter),
|
||||
LDAPTLSInsecureSkipVerify: req.LDAPTLSInsecureSkipVerify,
|
||||
OIDCClientID: strings.TrimSpace(req.OIDCClientID),
|
||||
OIDCClientSecret: req.OIDCClientSecret,
|
||||
OIDCAuthorizeURL: strings.TrimSpace(req.OIDCAuthorizeURL),
|
||||
OIDCTokenURL: strings.TrimSpace(req.OIDCTokenURL),
|
||||
OIDCUserInfoURL: strings.TrimSpace(req.OIDCUserInfoURL),
|
||||
OIDCRedirectURL: strings.TrimSpace(req.OIDCRedirectURL),
|
||||
OIDCScopes: strings.TrimSpace(req.OIDCScopes),
|
||||
OIDCTLSInsecureSkipVerify: req.OIDCTLSInsecureSkipVerify,
|
||||
}
|
||||
if settings.LDAPUserFilter == "" {
|
||||
settings.LDAPUserFilter = "(uid={username})"
|
||||
}
|
||||
if settings.OIDCScopes == "" {
|
||||
settings.OIDCScopes = "openid profile email"
|
||||
}
|
||||
if ok && api.Logger != nil {
|
||||
api.Logger.Write("auth", util.LOG_INFO, "auth settings update requested by user=%s mode=%s oidc_enabled=%t ldap_url=%s oidc_authorize_url=%s",
|
||||
user.Username, settings.AuthMode, settings.OIDCEnabled, settings.LDAPURL, settings.OIDCAuthorizeURL)
|
||||
}
|
||||
err = api.Store.SetAuthSettings(settings)
|
||||
if err != nil {
|
||||
if api.Logger != nil {
|
||||
api.Logger.Write("auth", util.LOG_ERROR, "auth settings update failed err=%v", err)
|
||||
}
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if api.Logger != nil {
|
||||
api.Logger.Write("auth", util.LOG_INFO, "auth settings update success mode=%s oidc_enabled=%t ldap_url=%s oidc_authorize_url=%s", settings.AuthMode, settings.OIDCEnabled, settings.LDAPURL, settings.OIDCAuthorizeURL)
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func (api *API) TestLDAPSettings(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||
var req testLDAPSettingsRequest
|
||||
var err error
|
||||
var merged models.AuthSettings
|
||||
var cfg config.Config
|
||||
var user auth.LDAPUser
|
||||
if !api.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
err = DecodeJSON(r, &req)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
merged, err = api.getMergedAuthSettings()
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.AuthMode) != "" {
|
||||
merged.AuthMode = normalizeAuthMode(req.AuthMode)
|
||||
}
|
||||
if strings.TrimSpace(req.LDAPURL) != "" {
|
||||
merged.LDAPURL = strings.TrimSpace(req.LDAPURL)
|
||||
}
|
||||
if strings.TrimSpace(req.LDAPBindDN) != "" {
|
||||
merged.LDAPBindDN = strings.TrimSpace(req.LDAPBindDN)
|
||||
}
|
||||
if req.LDAPBindPassword != "" {
|
||||
merged.LDAPBindPassword = req.LDAPBindPassword
|
||||
}
|
||||
if strings.TrimSpace(req.LDAPUserBaseDN) != "" {
|
||||
merged.LDAPUserBaseDN = strings.TrimSpace(req.LDAPUserBaseDN)
|
||||
}
|
||||
if strings.TrimSpace(req.LDAPUserFilter) != "" {
|
||||
merged.LDAPUserFilter = strings.TrimSpace(req.LDAPUserFilter)
|
||||
}
|
||||
if req.LDAPTLSInsecureSkipVerify != nil {
|
||||
merged.LDAPTLSInsecureSkipVerify = *req.LDAPTLSInsecureSkipVerify
|
||||
}
|
||||
if merged.LDAPUserFilter == "" {
|
||||
merged.LDAPUserFilter = "(uid={username})"
|
||||
}
|
||||
cfg = api.Cfg
|
||||
cfg.AuthMode = merged.AuthMode
|
||||
cfg.LDAPURL = merged.LDAPURL
|
||||
cfg.LDAPBindDN = merged.LDAPBindDN
|
||||
cfg.LDAPBindPassword = merged.LDAPBindPassword
|
||||
cfg.LDAPUserBaseDN = merged.LDAPUserBaseDN
|
||||
cfg.LDAPUserFilter = merged.LDAPUserFilter
|
||||
err = auth.LDAPTestConnectionContext(r.Context(), cfg)
|
||||
if err != nil {
|
||||
if api.Logger != nil {
|
||||
api.Logger.Write("auth", util.LOG_WARN, "ldap test connection failed url=%s err=%v", cfg.LDAPURL, err)
|
||||
}
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if api.Logger != nil {
|
||||
api.Logger.Write("auth", util.LOG_INFO, "ldap test connection success url=%s", cfg.LDAPURL)
|
||||
}
|
||||
if strings.TrimSpace(req.Username) != "" && strings.TrimSpace(req.Password) != "" {
|
||||
user, err = auth.LDAPAuthenticateContext(r.Context(), cfg, strings.TrimSpace(req.Username), req.Password)
|
||||
if err != nil {
|
||||
if api.Logger != nil {
|
||||
api.Logger.Write("auth", util.LOG_WARN, "ldap test bind failed username=%s err=%v", strings.TrimSpace(req.Username), err)
|
||||
}
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if api.Logger != nil {
|
||||
api.Logger.Write("auth", util.LOG_INFO, "ldap test bind success username=%s", user.Username)
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok", "user": user.Username})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (api *API) ListProjects(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||
var projects []models.Project
|
||||
var err error
|
||||
@@ -386,9 +719,16 @@ func (api *API) ListProjects(w http.ResponseWriter, r *http.Request, _ map[strin
|
||||
var query string
|
||||
var v string
|
||||
var i int
|
||||
var user models.User
|
||||
var ok bool
|
||||
limit = 0
|
||||
offset = 0
|
||||
query = strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
v = r.URL.Query().Get("limit")
|
||||
if v != "" {
|
||||
i, err = strconv.Atoi(v)
|
||||
@@ -403,10 +743,18 @@ func (api *API) ListProjects(w http.ResponseWriter, r *http.Request, _ map[strin
|
||||
offset = i
|
||||
}
|
||||
}
|
||||
if query != "" || limit > 0 || offset > 0 {
|
||||
projects, err = api.Store.ListProjectsFiltered(limit, offset, query)
|
||||
if user.IsAdmin {
|
||||
if query != "" || limit > 0 || offset > 0 {
|
||||
projects, err = api.Store.ListProjectsFiltered(limit, offset, query)
|
||||
} else {
|
||||
projects, err = api.Store.ListProjects()
|
||||
}
|
||||
} else {
|
||||
projects, err = api.Store.ListProjects()
|
||||
if query != "" || limit > 0 || offset > 0 {
|
||||
projects, err = api.Store.ListProjectsFilteredForUser(user.ID, limit, offset, query)
|
||||
} else {
|
||||
projects, err = api.Store.ListProjectsForUser(user.ID)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -453,6 +801,7 @@ func (api *API) CreateProject(w http.ResponseWriter, r *http.Request, _ map[stri
|
||||
Slug: req.Slug,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
HomePage: normalizeProjectHomePage(req.HomePage),
|
||||
CreatedBy: user.ID,
|
||||
UpdatedBy: user.ID,
|
||||
}
|
||||
@@ -493,6 +842,12 @@ func (api *API) UpdateProject(w http.ResponseWriter, r *http.Request, params map
|
||||
project.Slug = req.Slug
|
||||
}
|
||||
project.Description = req.Description
|
||||
if req.HomePage != "" {
|
||||
project.HomePage = normalizeProjectHomePage(req.HomePage)
|
||||
}
|
||||
if project.HomePage == "" {
|
||||
project.HomePage = "info"
|
||||
}
|
||||
user, _ = middleware.UserFromContext(r.Context())
|
||||
project.UpdatedBy = user.ID
|
||||
project.UpdatedAt = time.Now().UTC().Unix()
|
||||
@@ -618,6 +973,40 @@ func (api *API) RemoveProjectMember(w http.ResponseWriter, r *http.Request, para
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *API) ListProjectMemberCandidates(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var users []models.User
|
||||
var err error
|
||||
var query string
|
||||
var filtered []models.User
|
||||
var i int
|
||||
var username string
|
||||
var displayName string
|
||||
var email string
|
||||
if !api.requireProjectRole(w, r, params["projectId"], "admin") {
|
||||
return
|
||||
}
|
||||
users, err = api.Store.ListUsers()
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
query = strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
|
||||
if query == "" {
|
||||
WriteJSON(w, http.StatusOK, users)
|
||||
return
|
||||
}
|
||||
filtered = make([]models.User, 0, len(users))
|
||||
for i = 0; i < len(users); i++ {
|
||||
username = strings.ToLower(users[i].Username)
|
||||
displayName = strings.ToLower(users[i].DisplayName)
|
||||
email = strings.ToLower(users[i].Email)
|
||||
if strings.Contains(username, query) || strings.Contains(displayName, query) || strings.Contains(email, query) {
|
||||
filtered = append(filtered, users[i])
|
||||
}
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, filtered)
|
||||
}
|
||||
|
||||
func (api *API) ListRepos(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var err error
|
||||
var repos []models.Repo
|
||||
@@ -2501,6 +2890,193 @@ func (api *API) RepoDockerImages(w http.ResponseWriter, r *http.Request, params
|
||||
WriteJSON(w, http.StatusOK, images)
|
||||
}
|
||||
|
||||
func (api *API) ListAPIKeys(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||
var user models.User
|
||||
var ok bool
|
||||
var keys []models.APIKey
|
||||
var err error
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
keys, err = api.Store.ListAPIKeys(user.ID)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, keys)
|
||||
}
|
||||
|
||||
func (api *API) CreateAPIKey(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||
var user models.User
|
||||
var ok bool
|
||||
var req createAPIKeyRequest
|
||||
var err error
|
||||
var name string
|
||||
var buf []byte
|
||||
var token string
|
||||
var prefix string
|
||||
var hash string
|
||||
var key models.APIKey
|
||||
var nowUnix int64
|
||||
var expiresAt int64
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
err = DecodeJSON(r, &req)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
name = strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name required"})
|
||||
return
|
||||
}
|
||||
expiresAt = req.ExpiresAt
|
||||
if expiresAt < 0 {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "expires_at must be 0 or a future unix timestamp"})
|
||||
return
|
||||
}
|
||||
if expiresAt > 0 {
|
||||
nowUnix = time.Now().UTC().Unix()
|
||||
if expiresAt <= nowUnix {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "expires_at must be in the future"})
|
||||
return
|
||||
}
|
||||
}
|
||||
buf = make([]byte, 32)
|
||||
_, err = rand.Read(buf)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to generate token"})
|
||||
return
|
||||
}
|
||||
token = "ck_" + hex.EncodeToString(buf)
|
||||
if len(token) > 8 {
|
||||
prefix = token[:8]
|
||||
} else {
|
||||
prefix = token
|
||||
}
|
||||
hash = util.HashToken(token)
|
||||
key, err = api.Store.CreateAPIKey(user.ID, name, hash, prefix, expiresAt)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusCreated, apiKeyResponse{APIKey: key, Token: token})
|
||||
}
|
||||
|
||||
func (api *API) DeleteAPIKey(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var user models.User
|
||||
var ok bool
|
||||
var err error
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
err = api.Store.DeleteAPIKey(user.ID, params["id"])
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *API) DisableAPIKey(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var user models.User
|
||||
var ok bool
|
||||
var err error
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
err = api.Store.SetAPIKeyDisabled(user.ID, params["id"], true)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *API) EnableAPIKey(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var user models.User
|
||||
var ok bool
|
||||
var err error
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
err = api.Store.SetAPIKeyDisabled(user.ID, params["id"], false)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *API) ListAdminAPIKeys(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||
var keys []models.AdminAPIKey
|
||||
var err error
|
||||
var userID string
|
||||
var query string
|
||||
if !api.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
userID = strings.TrimSpace(r.URL.Query().Get("user_id"))
|
||||
query = strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
keys, err = api.Store.ListAPIKeysAdmin(userID, query)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, keys)
|
||||
}
|
||||
|
||||
func (api *API) DeleteAdminAPIKey(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var err error
|
||||
if !api.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
err = api.Store.DeleteAPIKeyByID(params["id"])
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *API) DisableAdminAPIKey(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var err error
|
||||
if !api.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
err = api.Store.SetAPIKeyDisabledByID(params["id"], true)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *API) EnableAdminAPIKey(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var err error
|
||||
if !api.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
err = api.Store.SetAPIKeyDisabledByID(params["id"], false)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *API) RepoDockerDeleteTag(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var repo models.Repo
|
||||
var err error
|
||||
@@ -2559,6 +3135,92 @@ func (api *API) RepoDockerDeleteImage(w http.ResponseWriter, r *http.Request, pa
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (api *API) RepoDockerRenameTag(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var repo models.Repo
|
||||
var err error
|
||||
var req repoDockerRenameTagRequest
|
||||
var image string
|
||||
var from string
|
||||
var to string
|
||||
var imagePath string
|
||||
repo, err = api.Store.GetRepo(params["id"])
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"})
|
||||
return
|
||||
}
|
||||
if !api.requireRepoRole(w, r, repo.ID, "writer") {
|
||||
return
|
||||
}
|
||||
if repo.Type != "docker" {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not docker"})
|
||||
return
|
||||
}
|
||||
err = DecodeJSON(r, &req)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
image = strings.TrimSpace(req.Image)
|
||||
from = strings.TrimSpace(req.From)
|
||||
to = strings.TrimSpace(req.To)
|
||||
if from == "" || to == "" {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "from and to required"})
|
||||
return
|
||||
}
|
||||
imagePath = docker.ImagePath(repo.Path, image)
|
||||
err = docker.RenameTag(imagePath, from, to)
|
||||
if err != nil {
|
||||
if err == docker.ErrNotFound {
|
||||
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "tag not found"})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (api *API) RepoDockerRenameImage(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var repo models.Repo
|
||||
var err error
|
||||
var req repoDockerRenameImageRequest
|
||||
var from string
|
||||
var to string
|
||||
repo, err = api.Store.GetRepo(params["id"])
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"})
|
||||
return
|
||||
}
|
||||
if !api.requireRepoRole(w, r, repo.ID, "writer") {
|
||||
return
|
||||
}
|
||||
if repo.Type != "docker" {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not docker"})
|
||||
return
|
||||
}
|
||||
err = DecodeJSON(r, &req)
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
from = strings.TrimSpace(req.From)
|
||||
to = strings.TrimSpace(req.To)
|
||||
if from == "" && to == "" {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "from or to required"})
|
||||
return
|
||||
}
|
||||
err = docker.RenameImage(repo.Path, from, to)
|
||||
if err != nil {
|
||||
if err == docker.ErrNotFound {
|
||||
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "image not found"})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (api *API) ListIssues(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var issues []models.Issue
|
||||
var err error
|
||||
@@ -2933,6 +3595,131 @@ func normalizeRole(role string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProjectHomePage(value string) string {
|
||||
var v string
|
||||
v = strings.ToLower(strings.TrimSpace(value))
|
||||
if v == "repos" || v == "issues" || v == "wiki" || v == "files" || v == "info" {
|
||||
return v
|
||||
}
|
||||
return "info"
|
||||
}
|
||||
|
||||
func normalizeAuthMode(value string) string {
|
||||
var v string
|
||||
v = strings.ToLower(strings.TrimSpace(value))
|
||||
if v == "ldap" || v == "hybrid" || v == "db" {
|
||||
return v
|
||||
}
|
||||
return "db"
|
||||
}
|
||||
|
||||
func (api *API) getMergedAuthSettings() (models.AuthSettings, error) {
|
||||
var settings models.AuthSettings
|
||||
var saved models.AuthSettings
|
||||
var err error
|
||||
settings = models.AuthSettings{
|
||||
AuthMode: normalizeAuthMode(api.Cfg.AuthMode),
|
||||
LDAPURL: api.Cfg.LDAPURL,
|
||||
LDAPBindDN: api.Cfg.LDAPBindDN,
|
||||
LDAPBindPassword: api.Cfg.LDAPBindPassword,
|
||||
LDAPUserBaseDN: api.Cfg.LDAPUserBaseDN,
|
||||
LDAPUserFilter: api.Cfg.LDAPUserFilter,
|
||||
LDAPTLSInsecureSkipVerify: api.Cfg.LDAPTLSInsecureSkipVerify,
|
||||
OIDCClientID: api.Cfg.OIDCClientID,
|
||||
OIDCClientSecret: api.Cfg.OIDCClientSecret,
|
||||
OIDCAuthorizeURL: api.Cfg.OIDCAuthorizeURL,
|
||||
OIDCTokenURL: api.Cfg.OIDCTokenURL,
|
||||
OIDCUserInfoURL: api.Cfg.OIDCUserInfoURL,
|
||||
OIDCRedirectURL: api.Cfg.OIDCRedirectURL,
|
||||
OIDCScopes: api.Cfg.OIDCScopes,
|
||||
OIDCEnabled: api.Cfg.OIDCEnabled,
|
||||
OIDCTLSInsecureSkipVerify: api.Cfg.OIDCTLSInsecureSkipVerify,
|
||||
}
|
||||
saved, err = api.Store.GetAuthSettings()
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
if strings.TrimSpace(saved.AuthMode) != "" {
|
||||
settings.AuthMode = normalizeAuthMode(saved.AuthMode)
|
||||
}
|
||||
settings.OIDCEnabled = saved.OIDCEnabled
|
||||
if strings.ToLower(strings.TrimSpace(saved.AuthMode)) == "oidc" {
|
||||
settings.OIDCEnabled = true
|
||||
}
|
||||
if strings.TrimSpace(saved.LDAPURL) != "" {
|
||||
settings.LDAPURL = saved.LDAPURL
|
||||
}
|
||||
if strings.TrimSpace(saved.LDAPBindDN) != "" {
|
||||
settings.LDAPBindDN = saved.LDAPBindDN
|
||||
}
|
||||
if saved.LDAPBindPassword != "" {
|
||||
settings.LDAPBindPassword = saved.LDAPBindPassword
|
||||
}
|
||||
if strings.TrimSpace(saved.LDAPUserBaseDN) != "" {
|
||||
settings.LDAPUserBaseDN = saved.LDAPUserBaseDN
|
||||
}
|
||||
if strings.TrimSpace(saved.LDAPUserFilter) != "" {
|
||||
settings.LDAPUserFilter = saved.LDAPUserFilter
|
||||
}
|
||||
if settings.LDAPUserFilter == "" {
|
||||
settings.LDAPUserFilter = "(uid={username})"
|
||||
}
|
||||
if strings.TrimSpace(saved.OIDCClientID) != "" {
|
||||
settings.OIDCClientID = saved.OIDCClientID
|
||||
}
|
||||
if strings.TrimSpace(saved.OIDCClientSecret) != "" {
|
||||
settings.OIDCClientSecret = saved.OIDCClientSecret
|
||||
}
|
||||
if strings.TrimSpace(saved.OIDCAuthorizeURL) != "" {
|
||||
settings.OIDCAuthorizeURL = saved.OIDCAuthorizeURL
|
||||
}
|
||||
if strings.TrimSpace(saved.OIDCTokenURL) != "" {
|
||||
settings.OIDCTokenURL = saved.OIDCTokenURL
|
||||
}
|
||||
if strings.TrimSpace(saved.OIDCUserInfoURL) != "" {
|
||||
settings.OIDCUserInfoURL = saved.OIDCUserInfoURL
|
||||
}
|
||||
if strings.TrimSpace(saved.OIDCRedirectURL) != "" {
|
||||
settings.OIDCRedirectURL = saved.OIDCRedirectURL
|
||||
}
|
||||
if strings.TrimSpace(saved.OIDCScopes) != "" {
|
||||
settings.OIDCScopes = saved.OIDCScopes
|
||||
}
|
||||
settings.OIDCTLSInsecureSkipVerify = saved.OIDCTLSInsecureSkipVerify
|
||||
if strings.TrimSpace(settings.OIDCScopes) == "" {
|
||||
settings.OIDCScopes = "openid profile email"
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (api *API) effectiveAuthConfig() (config.Config, error) {
|
||||
var cfg config.Config
|
||||
var settings models.AuthSettings
|
||||
var err error
|
||||
cfg = api.Cfg
|
||||
settings, err = api.getMergedAuthSettings()
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
cfg.AuthMode = settings.AuthMode
|
||||
cfg.LDAPURL = settings.LDAPURL
|
||||
cfg.LDAPBindDN = settings.LDAPBindDN
|
||||
cfg.LDAPBindPassword = settings.LDAPBindPassword
|
||||
cfg.LDAPUserBaseDN = settings.LDAPUserBaseDN
|
||||
cfg.LDAPUserFilter = settings.LDAPUserFilter
|
||||
cfg.LDAPTLSInsecureSkipVerify = settings.LDAPTLSInsecureSkipVerify
|
||||
cfg.OIDCClientID = settings.OIDCClientID
|
||||
cfg.OIDCClientSecret = settings.OIDCClientSecret
|
||||
cfg.OIDCAuthorizeURL = settings.OIDCAuthorizeURL
|
||||
cfg.OIDCTokenURL = settings.OIDCTokenURL
|
||||
cfg.OIDCUserInfoURL = settings.OIDCUserInfoURL
|
||||
cfg.OIDCRedirectURL = settings.OIDCRedirectURL
|
||||
cfg.OIDCScopes = settings.OIDCScopes
|
||||
cfg.OIDCEnabled = settings.OIDCEnabled
|
||||
cfg.OIDCTLSInsecureSkipVerify = settings.OIDCTLSInsecureSkipVerify
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func normalizeRepoType(value string) (string, bool) {
|
||||
var v string
|
||||
v = strings.ToLower(strings.TrimSpace(value))
|
||||
|
||||
434
backend/internal/handlers/oidc.go
Normal file
434
backend/internal/handlers/oidc.go
Normal file
@@ -0,0 +1,434 @@
|
||||
package handlers
|
||||
|
||||
import "context"
|
||||
import "crypto/rand"
|
||||
import "crypto/sha1"
|
||||
import "crypto/tls"
|
||||
import "database/sql"
|
||||
import "encoding/base64"
|
||||
import "encoding/hex"
|
||||
import "encoding/json"
|
||||
import "errors"
|
||||
import "fmt"
|
||||
import "io"
|
||||
import "net/http"
|
||||
import "net/url"
|
||||
import "strings"
|
||||
import "time"
|
||||
|
||||
import "codit/internal/config"
|
||||
import "codit/internal/models"
|
||||
import "codit/internal/util"
|
||||
|
||||
type oidcTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
}
|
||||
|
||||
type oidcUserClaims struct {
|
||||
Sub string `json:"sub"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type oidcErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
|
||||
func (api *API) OIDCEnabled(w http.ResponseWriter, _ *http.Request, _ map[string]string) {
|
||||
var settings models.AuthSettings
|
||||
var err error
|
||||
var configured bool
|
||||
settings, err = api.getMergedAuthSettings()
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{"enabled": false, "configured": false, "auth_mode": "db"})
|
||||
return
|
||||
}
|
||||
configured = api.oidcConfiguredFromSettings(settings)
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": settings.OIDCEnabled,
|
||||
"configured": configured,
|
||||
"auth_mode": strings.ToLower(strings.TrimSpace(settings.AuthMode)),
|
||||
})
|
||||
}
|
||||
|
||||
func (api *API) OIDCLogin(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||
var settings models.AuthSettings
|
||||
var cfg config.Config
|
||||
var state string
|
||||
var err error
|
||||
var authURL string
|
||||
settings, err = api.getMergedAuthSettings()
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load auth settings"})
|
||||
return
|
||||
}
|
||||
cfg, err = api.effectiveAuthConfig()
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load auth settings"})
|
||||
return
|
||||
}
|
||||
if !settings.OIDCEnabled {
|
||||
WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "oidc login is disabled"})
|
||||
return
|
||||
}
|
||||
if !api.oidcConfiguredFromSettings(settings) {
|
||||
WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "oidc is not configured"})
|
||||
return
|
||||
}
|
||||
state, err = newOIDCState()
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create state"})
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "codit_oidc_state",
|
||||
Value: state,
|
||||
HttpOnly: true,
|
||||
Path: "/api/auth/oidc",
|
||||
Expires: time.Now().UTC().Add(10 * time.Minute),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
authURL = api.buildOIDCAuthorizeURL(cfg, state)
|
||||
if api.Logger != nil {
|
||||
api.Logger.Write("auth", util.LOG_INFO, "oidc login redirect authorize_url=%s", cfg.OIDCAuthorizeURL)
|
||||
}
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func (api *API) OIDCCallback(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||
var settings models.AuthSettings
|
||||
var cfg config.Config
|
||||
var state string
|
||||
var code string
|
||||
var cookie *http.Cookie
|
||||
var err error
|
||||
var token oidcTokenResponse
|
||||
var claims oidcUserClaims
|
||||
var user models.User
|
||||
settings, err = api.getMergedAuthSettings()
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load auth settings"})
|
||||
return
|
||||
}
|
||||
cfg, err = api.effectiveAuthConfig()
|
||||
if err != nil {
|
||||
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load auth settings"})
|
||||
return
|
||||
}
|
||||
if !settings.OIDCEnabled {
|
||||
WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "oidc login is disabled"})
|
||||
return
|
||||
}
|
||||
if !api.oidcConfiguredFromSettings(settings) {
|
||||
WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "oidc is not configured"})
|
||||
return
|
||||
}
|
||||
state = strings.TrimSpace(r.URL.Query().Get("state"))
|
||||
code = strings.TrimSpace(r.URL.Query().Get("code"))
|
||||
cookie, err = r.Cookie("codit_oidc_state")
|
||||
clearOIDCStateCookie(w)
|
||||
if err != nil || cookie.Value == "" || state == "" || state != cookie.Value {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid oidc state"})
|
||||
return
|
||||
}
|
||||
if code == "" {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "missing authorization code"})
|
||||
return
|
||||
}
|
||||
token, err = api.oidcExchangeCode(r.Context(), cfg, code)
|
||||
if err != nil {
|
||||
if api.Logger != nil {
|
||||
api.Logger.Write("auth", util.LOG_WARN, "oidc token exchange failed err=%v", err)
|
||||
}
|
||||
WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
claims, err = api.oidcResolveClaims(r.Context(), cfg, token)
|
||||
if err != nil {
|
||||
if api.Logger != nil {
|
||||
api.Logger.Write("auth", util.LOG_WARN, "oidc claims fetch failed err=%v", err)
|
||||
}
|
||||
WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "oidc claims fetch failed"})
|
||||
return
|
||||
}
|
||||
user, err = api.oidcGetOrCreateUser(claims)
|
||||
if err != nil {
|
||||
if api.Logger != nil {
|
||||
api.Logger.Write("auth", util.LOG_WARN, "oidc user mapping failed err=%v", err)
|
||||
}
|
||||
WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "oidc user mapping failed"})
|
||||
return
|
||||
}
|
||||
api.issueSession(w, user)
|
||||
if api.Logger != nil {
|
||||
api.Logger.Write("auth", util.LOG_INFO, "oidc login success username=%s", user.Username)
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func (api *API) oidcConfiguredFromSettings(settings models.AuthSettings) bool {
|
||||
if strings.TrimSpace(settings.OIDCClientID) == "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(settings.OIDCClientSecret) == "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(settings.OIDCAuthorizeURL) == "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(settings.OIDCTokenURL) == "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(settings.OIDCRedirectURL) == "" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (api *API) buildOIDCAuthorizeURL(cfg config.Config, state string) string {
|
||||
var values url.Values
|
||||
var scopes string
|
||||
var endpoint string
|
||||
values = url.Values{}
|
||||
values.Set("response_type", "code")
|
||||
values.Set("client_id", cfg.OIDCClientID)
|
||||
values.Set("redirect_uri", cfg.OIDCRedirectURL)
|
||||
scopes = strings.TrimSpace(cfg.OIDCScopes)
|
||||
if scopes == "" {
|
||||
scopes = "openid profile email"
|
||||
}
|
||||
values.Set("scope", scopes)
|
||||
values.Set("state", state)
|
||||
endpoint = cfg.OIDCAuthorizeURL
|
||||
if strings.Contains(endpoint, "?") {
|
||||
return endpoint + "&" + values.Encode()
|
||||
}
|
||||
return endpoint + "?" + values.Encode()
|
||||
}
|
||||
|
||||
func (api *API) oidcHTTPClient(cfg config.Config) *http.Client {
|
||||
var transport *http.Transport
|
||||
transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.OIDCTLSInsecureSkipVerify},
|
||||
}
|
||||
return &http.Client{Transport: transport, Timeout: 15 * time.Second}
|
||||
}
|
||||
|
||||
func (api *API) oidcExchangeCode(ctx context.Context, cfg config.Config, code string) (oidcTokenResponse, error) {
|
||||
var client *http.Client
|
||||
var form url.Values
|
||||
var req *http.Request
|
||||
var res *http.Response
|
||||
var body []byte
|
||||
var token oidcTokenResponse
|
||||
var err error
|
||||
client = api.oidcHTTPClient(cfg)
|
||||
form = url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", code)
|
||||
form.Set("redirect_uri", cfg.OIDCRedirectURL)
|
||||
form.Set("client_id", cfg.OIDCClientID)
|
||||
form.Set("client_secret", cfg.OIDCClientSecret)
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodPost, cfg.OIDCTokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return token, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
res, err = client.Do(req)
|
||||
if err != nil {
|
||||
return token, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, err = io.ReadAll(io.LimitReader(res.Body, 1024*1024))
|
||||
if err != nil {
|
||||
return token, err
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return token, fmt.Errorf("oidc token exchange failed: status=%d detail=%s", res.StatusCode, oidcErrorDetail(body))
|
||||
}
|
||||
err = json.Unmarshal(body, &token)
|
||||
if err != nil {
|
||||
return token, err
|
||||
}
|
||||
if strings.TrimSpace(token.AccessToken) == "" && strings.TrimSpace(token.IDToken) == "" {
|
||||
return token, errors.New("missing oidc token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func oidcErrorDetail(body []byte) string {
|
||||
var payload oidcErrorResponse
|
||||
var err error
|
||||
var text string
|
||||
err = json.Unmarshal(body, &payload)
|
||||
if err == nil {
|
||||
if strings.TrimSpace(payload.ErrorDescription) != "" {
|
||||
return payload.ErrorDescription
|
||||
}
|
||||
if strings.TrimSpace(payload.Error) != "" {
|
||||
return payload.Error
|
||||
}
|
||||
}
|
||||
text = strings.TrimSpace(string(body))
|
||||
if len(text) > 240 {
|
||||
text = text[:240]
|
||||
}
|
||||
if text == "" {
|
||||
text = "empty response body"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (api *API) oidcResolveClaims(ctx context.Context, cfg config.Config, token oidcTokenResponse) (oidcUserClaims, error) {
|
||||
var claims oidcUserClaims
|
||||
var err error
|
||||
if strings.TrimSpace(cfg.OIDCUserInfoURL) != "" && strings.TrimSpace(token.AccessToken) != "" {
|
||||
claims, err = api.oidcUserInfo(ctx, cfg, token.AccessToken)
|
||||
if err == nil {
|
||||
return claims, nil
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(token.IDToken) == "" {
|
||||
return claims, errors.New("missing id token and userinfo unavailable")
|
||||
}
|
||||
claims, err = oidcClaimsFromIDToken(token.IDToken)
|
||||
if err != nil {
|
||||
return claims, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (api *API) oidcUserInfo(ctx context.Context, cfg config.Config, accessToken string) (oidcUserClaims, error) {
|
||||
var client *http.Client
|
||||
var req *http.Request
|
||||
var res *http.Response
|
||||
var body []byte
|
||||
var claims oidcUserClaims
|
||||
var err error
|
||||
client = api.oidcHTTPClient(cfg)
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodGet, cfg.OIDCUserInfoURL, nil)
|
||||
if err != nil {
|
||||
return claims, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
res, err = client.Do(req)
|
||||
if err != nil {
|
||||
return claims, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, err = io.ReadAll(io.LimitReader(res.Body, 1024*1024))
|
||||
if err != nil {
|
||||
return claims, err
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return claims, fmt.Errorf("userinfo status %d", res.StatusCode)
|
||||
}
|
||||
err = json.Unmarshal(body, &claims)
|
||||
if err != nil {
|
||||
return claims, err
|
||||
}
|
||||
if strings.TrimSpace(claims.Sub) == "" {
|
||||
return claims, errors.New("userinfo missing sub")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (api *API) oidcGetOrCreateUser(claims oidcUserClaims) (models.User, error) {
|
||||
var username string
|
||||
var displayName string
|
||||
var email string
|
||||
var user models.User
|
||||
var hash string
|
||||
var err error
|
||||
var created models.User
|
||||
username = oidcUsernameFromSub(claims.Sub)
|
||||
user, hash, err = api.Store.GetUserByUsername(username)
|
||||
_ = hash
|
||||
if err == nil {
|
||||
if user.Disabled {
|
||||
return user, errors.New("user disabled")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return user, err
|
||||
}
|
||||
displayName = strings.TrimSpace(claims.Name)
|
||||
if displayName == "" {
|
||||
displayName = strings.TrimSpace(claims.PreferredUsername)
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = username
|
||||
}
|
||||
email = strings.TrimSpace(claims.Email)
|
||||
if email == "" {
|
||||
email = username + "@oidc.local"
|
||||
}
|
||||
user = models.User{
|
||||
Username: username,
|
||||
DisplayName: displayName,
|
||||
Email: email,
|
||||
AuthSource: "oidc",
|
||||
}
|
||||
created, err = api.Store.CreateUser(user, "")
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func oidcClaimsFromIDToken(idToken string) (oidcUserClaims, error) {
|
||||
var claims oidcUserClaims
|
||||
var parts []string
|
||||
var payload []byte
|
||||
var err error
|
||||
parts = strings.Split(idToken, ".")
|
||||
if len(parts) < 2 {
|
||||
return claims, errors.New("invalid id token")
|
||||
}
|
||||
payload, err = base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return claims, err
|
||||
}
|
||||
err = json.Unmarshal(payload, &claims)
|
||||
if err != nil {
|
||||
return claims, err
|
||||
}
|
||||
if strings.TrimSpace(claims.Sub) == "" {
|
||||
return claims, errors.New("id token missing sub")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func oidcUsernameFromSub(sub string) string {
|
||||
var sum [20]byte
|
||||
sum = sha1.Sum([]byte(strings.TrimSpace(sub)))
|
||||
return "oidc-" + hex.EncodeToString(sum[:6])
|
||||
}
|
||||
|
||||
func newOIDCState() (string, error) {
|
||||
var buf []byte
|
||||
var err error
|
||||
buf = make([]byte, 24)
|
||||
_, err = rand.Read(buf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func clearOIDCStateCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "codit_oidc_state",
|
||||
Value: "",
|
||||
Path: "/api/auth/oidc",
|
||||
Expires: time.Unix(0, 0),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
39
backend/internal/handlers/response_test.go
Normal file
39
backend/internal/handlers/response_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package handlers
|
||||
|
||||
import "bytes"
|
||||
import "encoding/json"
|
||||
import "net/http"
|
||||
import "net/http/httptest"
|
||||
import "testing"
|
||||
|
||||
func TestWriteJSON(t *testing.T) {
|
||||
var recorder *httptest.ResponseRecorder
|
||||
var payload map[string]string
|
||||
recorder = httptest.NewRecorder()
|
||||
payload = map[string]string{"status": "ok"}
|
||||
WriteJSON(recorder, http.StatusAccepted, payload)
|
||||
if recorder.Code != http.StatusAccepted {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
if recorder.Header().Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("unexpected content-type: %s", recorder.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeJSON(t *testing.T) {
|
||||
var body []byte
|
||||
var req *http.Request
|
||||
var target struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
var err error
|
||||
body, _ = json.Marshal(map[string]string{"name": "bob"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||
err = DecodeJSON(req, &target)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeJSON error: %v", err)
|
||||
}
|
||||
if target.Name != "bob" {
|
||||
t.Fatalf("unexpected decoded value: %s", target.Name)
|
||||
}
|
||||
}
|
||||
37
backend/internal/http/router_test.go
Normal file
37
backend/internal/http/router_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package httpx
|
||||
|
||||
import "net/http"
|
||||
import "net/http/httptest"
|
||||
import "testing"
|
||||
|
||||
func TestRouterMatchWithParams(t *testing.T) {
|
||||
var router *Router
|
||||
var recorder *httptest.ResponseRecorder
|
||||
var req *http.Request
|
||||
router = NewRouter()
|
||||
router.Handle("GET", "/api/repos/:id", func(w http.ResponseWriter, _ *http.Request, params Params) {
|
||||
if params["id"] != "abc" {
|
||||
t.Fatalf("unexpected route param: %s", params["id"])
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
recorder = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/repos/abc", nil)
|
||||
router.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterNotFound(t *testing.T) {
|
||||
var router *Router
|
||||
var recorder *httptest.ResponseRecorder
|
||||
var req *http.Request
|
||||
router = NewRouter()
|
||||
recorder = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/missing", nil)
|
||||
router.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
41
backend/internal/middleware/access_log_test.go
Normal file
41
backend/internal/middleware/access_log_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package middleware
|
||||
|
||||
import "bytes"
|
||||
import "context"
|
||||
import "net/http"
|
||||
import "net/http/httptest"
|
||||
import "strings"
|
||||
import "testing"
|
||||
|
||||
import "codit/internal/models"
|
||||
import "codit/internal/util"
|
||||
|
||||
func TestAccessLogWritesRecord(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
var logger *util.Logger
|
||||
var handler http.Handler
|
||||
var req *http.Request
|
||||
var recorder *httptest.ResponseRecorder
|
||||
var user models.User
|
||||
var ctx context.Context
|
||||
logger = util.NewLogger("test", &buf, util.LOG_ALL)
|
||||
defer logger.Close()
|
||||
handler = AccessLog(logger, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
user = models.User{ID: "u1", Username: "alice"}
|
||||
ctx = context.WithValue(context.Background(), userKey, user)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/demo", nil).WithContext(ctx)
|
||||
recorder = httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, req)
|
||||
logger.Close()
|
||||
if recorder.Code != http.StatusCreated {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "path=/api/demo") {
|
||||
t.Fatalf("missing path in log: %s", buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "user=alice") {
|
||||
t.Fatalf("missing username in log: %s", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,12 @@ package middleware
|
||||
|
||||
import "context"
|
||||
import "net/http"
|
||||
import "strings"
|
||||
import "time"
|
||||
|
||||
import "codit/internal/db"
|
||||
import "codit/internal/models"
|
||||
import "codit/internal/util"
|
||||
|
||||
type ctxKey string
|
||||
|
||||
@@ -18,14 +20,40 @@ func WithUser(store *db.Store, next http.Handler) http.Handler {
|
||||
var user models.User
|
||||
var expires time.Time
|
||||
var ctx context.Context
|
||||
var token string
|
||||
var hash string
|
||||
cookie, err = r.Cookie("codit_session")
|
||||
if err != nil || cookie.Value == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
token = apiKeyFromRequest(r)
|
||||
if token == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
hash = util.HashToken(token)
|
||||
user, err = store.GetUserByAPIKeyHash(hash)
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
ctx = context.WithValue(r.Context(), userKey, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
user, expires, err = store.GetSessionUser(cookie.Value)
|
||||
if err != nil || time.Now().UTC().After(expires) {
|
||||
next.ServeHTTP(w, r)
|
||||
token = apiKeyFromRequest(r)
|
||||
if token == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
hash = util.HashToken(token)
|
||||
user, err = store.GetUserByAPIKeyHash(hash)
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
ctx = context.WithValue(r.Context(), userKey, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
ctx = context.WithValue(r.Context(), userKey, user)
|
||||
@@ -64,3 +92,25 @@ func RequireAdmin(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func apiKeyFromRequest(r *http.Request) string {
|
||||
var token string
|
||||
var auth string
|
||||
var parts []string
|
||||
token = r.Header.Get("X-API-Key")
|
||||
if token != "" {
|
||||
return token
|
||||
}
|
||||
auth = r.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
return ""
|
||||
}
|
||||
parts = strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 {
|
||||
return ""
|
||||
}
|
||||
if strings.ToLower(parts[0]) != "bearer" {
|
||||
return ""
|
||||
}
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
69
backend/internal/middleware/auth_test.go
Normal file
69
backend/internal/middleware/auth_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package middleware
|
||||
|
||||
import "context"
|
||||
import "net/http"
|
||||
import "net/http/httptest"
|
||||
import "testing"
|
||||
|
||||
import "codit/internal/models"
|
||||
|
||||
func TestAPIKeyFromRequest(t *testing.T) {
|
||||
var req *http.Request
|
||||
var token string
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("X-API-Key", "abc")
|
||||
token = apiKeyFromRequest(req)
|
||||
if token != "abc" {
|
||||
t.Fatalf("expected header token, got %q", token)
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer xyz")
|
||||
token = apiKeyFromRequest(req)
|
||||
if token != "xyz" {
|
||||
t.Fatalf("expected bearer token, got %q", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAuth(t *testing.T) {
|
||||
var called bool
|
||||
var handler http.Handler
|
||||
var recorder *httptest.ResponseRecorder
|
||||
var req *http.Request
|
||||
handler = RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
recorder = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
handler.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", recorder.Code)
|
||||
}
|
||||
if called {
|
||||
t.Fatalf("protected handler should not be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAdmin(t *testing.T) {
|
||||
var called bool
|
||||
var handler http.Handler
|
||||
var recorder *httptest.ResponseRecorder
|
||||
var req *http.Request
|
||||
var user models.User
|
||||
var ctx context.Context
|
||||
handler = RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
user = models.User{ID: "u1", Username: "admin", IsAdmin: true}
|
||||
ctx = context.WithValue(context.Background(), userKey, user)
|
||||
recorder = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
||||
handler.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d", recorder.Code)
|
||||
}
|
||||
if !called {
|
||||
t.Fatalf("admin handler should be called")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ type User struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Disabled bool `json:"disabled"`
|
||||
AuthSource string `json:"auth_source"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
@@ -16,6 +17,7 @@ type Project struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
HomePage string `json:"home_page"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
CreatedByName string `json:"created_by_name"`
|
||||
@@ -82,3 +84,47 @@ type Upload struct {
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"prefix"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastUsedAt int64 `json:"last_used_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
type AdminAPIKey struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"prefix"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastUsedAt int64 `json:"last_used_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
type AuthSettings struct {
|
||||
AuthMode string `json:"auth_mode"`
|
||||
OIDCEnabled bool `json:"oidc_enabled"`
|
||||
LDAPURL string `json:"ldap_url"`
|
||||
LDAPBindDN string `json:"ldap_bind_dn"`
|
||||
LDAPBindPassword string `json:"ldap_bind_password"`
|
||||
LDAPUserBaseDN string `json:"ldap_user_base_dn"`
|
||||
LDAPUserFilter string `json:"ldap_user_filter"`
|
||||
LDAPTLSInsecureSkipVerify bool `json:"ldap_tls_insecure_skip_verify"`
|
||||
OIDCClientID string `json:"oidc_client_id"`
|
||||
OIDCClientSecret string `json:"oidc_client_secret"`
|
||||
OIDCAuthorizeURL string `json:"oidc_authorize_url"`
|
||||
OIDCTokenURL string `json:"oidc_token_url"`
|
||||
OIDCUserInfoURL string `json:"oidc_userinfo_url"`
|
||||
OIDCRedirectURL string `json:"oidc_redirect_url"`
|
||||
OIDCScopes string `json:"oidc_scopes"`
|
||||
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
|
||||
}
|
||||
|
||||
75
backend/internal/models/models_json_test.go
Normal file
75
backend/internal/models/models_json_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package models
|
||||
|
||||
import "encoding/json"
|
||||
import "testing"
|
||||
|
||||
func TestUserJSONTags(t *testing.T) {
|
||||
var u User
|
||||
var data []byte
|
||||
var err error
|
||||
var decoded map[string]interface{}
|
||||
u = User{
|
||||
ID: "u1",
|
||||
Username: "alice",
|
||||
DisplayName: "Alice",
|
||||
Email: "a@x",
|
||||
IsAdmin: true,
|
||||
Disabled: true,
|
||||
AuthSource: "db",
|
||||
CreatedAt: 1,
|
||||
UpdatedAt: 2,
|
||||
}
|
||||
data, err = json.Marshal(u)
|
||||
if err != nil {
|
||||
t.Fatalf("json marshal error: %v", err)
|
||||
}
|
||||
decoded = map[string]interface{}{}
|
||||
err = json.Unmarshal(data, &decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("json unmarshal error: %v", err)
|
||||
}
|
||||
if _, ok := decoded["display_name"]; !ok {
|
||||
t.Fatalf("missing display_name json key")
|
||||
}
|
||||
if _, ok := decoded["is_admin"]; !ok {
|
||||
t.Fatalf("missing is_admin json key")
|
||||
}
|
||||
if _, ok := decoded["disabled"]; !ok {
|
||||
t.Fatalf("missing disabled json key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyJSONTags(t *testing.T) {
|
||||
var k APIKey
|
||||
var data []byte
|
||||
var err error
|
||||
var decoded map[string]interface{}
|
||||
k = APIKey{
|
||||
ID: "k1",
|
||||
UserID: "u1",
|
||||
Name: "n",
|
||||
Prefix: "pre",
|
||||
CreatedAt: 10,
|
||||
LastUsedAt: 11,
|
||||
ExpiresAt: 12,
|
||||
Disabled: true,
|
||||
}
|
||||
data, err = json.Marshal(k)
|
||||
if err != nil {
|
||||
t.Fatalf("json marshal error: %v", err)
|
||||
}
|
||||
decoded = map[string]interface{}{}
|
||||
err = json.Unmarshal(data, &decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("json unmarshal error: %v", err)
|
||||
}
|
||||
if _, ok := decoded["expires_at"]; !ok {
|
||||
t.Fatalf("missing expires_at json key")
|
||||
}
|
||||
if _, ok := decoded["last_used_at"]; !ok {
|
||||
t.Fatalf("missing last_used_at json key")
|
||||
}
|
||||
if _, ok := decoded["disabled"]; !ok {
|
||||
t.Fatalf("missing disabled json key")
|
||||
}
|
||||
}
|
||||
54
backend/internal/rpm/http_test.go
Normal file
54
backend/internal/rpm/http_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package rpm
|
||||
|
||||
import "bytes"
|
||||
import "net/http"
|
||||
import "net/http/httptest"
|
||||
import "os"
|
||||
import "path/filepath"
|
||||
import "testing"
|
||||
|
||||
import "codit/internal/util"
|
||||
|
||||
func TestHTTPServerUnauthorizedWithoutBasicAuth(t *testing.T) {
|
||||
var dir string
|
||||
var logger *util.Logger
|
||||
var server *HTTPServer
|
||||
var req *http.Request
|
||||
var recorder *httptest.ResponseRecorder
|
||||
dir = t.TempDir()
|
||||
logger = util.NewLogger("test", &bytes.Buffer{}, util.LOG_ALL)
|
||||
defer logger.Close()
|
||||
server = NewHTTPServer(dir, func(username, password string) (bool, error) {
|
||||
return true, nil
|
||||
}, logger)
|
||||
req = httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
recorder = httptest.NewRecorder()
|
||||
server.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPServerAuthorized(t *testing.T) {
|
||||
var dir string
|
||||
var logger *util.Logger
|
||||
var server *HTTPServer
|
||||
var req *http.Request
|
||||
var recorder *httptest.ResponseRecorder
|
||||
var path string
|
||||
dir = t.TempDir()
|
||||
path = filepath.Join(dir, "a.txt")
|
||||
_ = os.WriteFile(path, []byte("ok"), 0o644)
|
||||
logger = util.NewLogger("test", &bytes.Buffer{}, util.LOG_ALL)
|
||||
defer logger.Close()
|
||||
server = NewHTTPServer(dir, func(username, password string) (bool, error) {
|
||||
return username == "u" && password == "p", nil
|
||||
}, logger)
|
||||
req = httptest.NewRequest(http.MethodGet, "/a.txt", nil)
|
||||
req.SetBasicAuth("u", "p")
|
||||
recorder = httptest.NewRecorder()
|
||||
server.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
56
backend/internal/storage/files_test.go
Normal file
56
backend/internal/storage/files_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package storage
|
||||
|
||||
import "io"
|
||||
import "os"
|
||||
import "path/filepath"
|
||||
import "strings"
|
||||
import "testing"
|
||||
|
||||
func TestFileStoreSaveAndOpen(t *testing.T) {
|
||||
var fs FileStore
|
||||
var content string
|
||||
var path string
|
||||
var n int64
|
||||
var err error
|
||||
var file *os.File
|
||||
var data []byte
|
||||
fs = FileStore{BaseDir: filepath.Join(t.TempDir(), "uploads")}
|
||||
content = "hello storage"
|
||||
path, n, err = fs.Save("a.txt", strings.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("Save() error: %v", err)
|
||||
}
|
||||
if n != int64(len(content)) {
|
||||
t.Fatalf("unexpected bytes copied: got=%d want=%d", n, len(content))
|
||||
}
|
||||
file, err = fs.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
data, err = io.ReadAll(file)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll() error: %v", err)
|
||||
}
|
||||
if string(data) != content {
|
||||
t.Fatalf("unexpected content: got=%q want=%q", string(data), content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStoreEnsureCreatesDirectory(t *testing.T) {
|
||||
var fs FileStore
|
||||
var err error
|
||||
var st os.FileInfo
|
||||
fs = FileStore{BaseDir: filepath.Join(t.TempDir(), "x", "y", "z")}
|
||||
err = fs.Ensure()
|
||||
if err != nil {
|
||||
t.Fatalf("Ensure() error: %v", err)
|
||||
}
|
||||
st, err = os.Stat(fs.BaseDir)
|
||||
if err != nil {
|
||||
t.Fatalf("stat basedir: %v", err)
|
||||
}
|
||||
if !st.IsDir() {
|
||||
t.Fatalf("base path is not directory")
|
||||
}
|
||||
}
|
||||
35
backend/internal/util/id_test.go
Normal file
35
backend/internal/util/id_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package util
|
||||
|
||||
import "regexp"
|
||||
import "testing"
|
||||
|
||||
func TestNewIDFormat(t *testing.T) {
|
||||
var id string
|
||||
var err error
|
||||
var re *regexp.Regexp
|
||||
re = regexp.MustCompile("^[0-9a-f]{32}$")
|
||||
id, err = NewID()
|
||||
if err != nil {
|
||||
t.Fatalf("NewID() error: %v", err)
|
||||
}
|
||||
if !re.MatchString(id) {
|
||||
t.Fatalf("invalid id format: %s", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIDUniqueness(t *testing.T) {
|
||||
var a string
|
||||
var b string
|
||||
var err error
|
||||
a, err = NewID()
|
||||
if err != nil {
|
||||
t.Fatalf("NewID() error for first id: %v", err)
|
||||
}
|
||||
b, err = NewID()
|
||||
if err != nil {
|
||||
t.Fatalf("NewID() error for second id: %v", err)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatalf("ids must differ: %s", a)
|
||||
}
|
||||
}
|
||||
10
backend/internal/util/token.go
Normal file
10
backend/internal/util/token.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package util
|
||||
|
||||
import "crypto/sha256"
|
||||
import "encoding/hex"
|
||||
|
||||
func HashToken(token string) string {
|
||||
var sum [32]byte
|
||||
sum = sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
28
backend/internal/util/token_test.go
Normal file
28
backend/internal/util/token_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package util
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHashTokenDeterministic(t *testing.T) {
|
||||
var input string
|
||||
var first string
|
||||
var second string
|
||||
input = "sample-token"
|
||||
first = HashToken(input)
|
||||
second = HashToken(input)
|
||||
if first != second {
|
||||
t.Fatalf("hash must be deterministic: %s vs %s", first, second)
|
||||
}
|
||||
if len(first) != 64 {
|
||||
t.Fatalf("hash length must be 64, got %d", len(first))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashTokenDifferentInputs(t *testing.T) {
|
||||
var a string
|
||||
var b string
|
||||
a = HashToken("token-a")
|
||||
b = HashToken("token-b")
|
||||
if a == b {
|
||||
t.Fatalf("different tokens must produce different hashes")
|
||||
}
|
||||
}
|
||||
13
backend/migrations/006_api_keys.sql
Normal file
13
backend/migrations/006_api_keys.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
|
||||
1
backend/migrations/007_project_home_page.sql
Normal file
1
backend/migrations/007_project_home_page.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE projects ADD COLUMN home_page TEXT NOT NULL DEFAULT 'info';
|
||||
3
backend/migrations/008_api_key_expiry.sql
Normal file
3
backend/migrations/008_api_key_expiry.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE api_keys ADD COLUMN expires_at INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON api_keys(expires_at);
|
||||
5
backend/migrations/009_user_api_key_disabled.sql
Normal file
5
backend/migrations/009_user_api_key_disabled.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE users ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE api_keys ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_disabled ON users(disabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_disabled ON api_keys(disabled);
|
||||
5
backend/migrations/010_app_settings.sql
Normal file
5
backend/migrations/010_app_settings.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -4,6 +4,7 @@ export interface User {
|
||||
display_name: string
|
||||
email: string
|
||||
is_admin: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
@@ -11,6 +12,7 @@ export interface Project {
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
home_page?: 'info' | 'repos' | 'issues' | 'wiki' | 'files'
|
||||
created_by?: string
|
||||
updated_by?: string
|
||||
created_by_name?: string
|
||||
@@ -170,6 +172,52 @@ export interface ProjectMember {
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface APIKey {
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
prefix: string
|
||||
created_at: number
|
||||
last_used_at: number
|
||||
expires_at: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface APIKeyWithToken extends APIKey {
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface AdminAPIKey extends APIKey {
|
||||
username: string
|
||||
display_name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface AuthSettings {
|
||||
auth_mode: 'db' | 'ldap' | 'hybrid'
|
||||
oidc_enabled: boolean
|
||||
ldap_url: string
|
||||
ldap_bind_dn: string
|
||||
ldap_bind_password: string
|
||||
ldap_user_base_dn: string
|
||||
ldap_user_filter: string
|
||||
ldap_tls_insecure_skip_verify: boolean
|
||||
oidc_client_id: string
|
||||
oidc_client_secret: string
|
||||
oidc_authorize_url: string
|
||||
oidc_token_url: string
|
||||
oidc_userinfo_url: string
|
||||
oidc_redirect_url: string
|
||||
oidc_scopes: string
|
||||
oidc_tls_insecure_skip_verify: boolean
|
||||
}
|
||||
|
||||
export interface OIDCStatus {
|
||||
enabled: boolean
|
||||
configured?: boolean
|
||||
auth_mode?: string
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
credentials: 'include',
|
||||
@@ -213,6 +261,7 @@ async function requestBinary(path: string, options: RequestInit = {}): Promise<A
|
||||
}
|
||||
|
||||
export const api = {
|
||||
oidcStatus: () => request<OIDCStatus>('/api/auth/oidc/enabled'),
|
||||
login: (username: string, password: string) =>
|
||||
request<User>('/api/login', {
|
||||
method: 'POST',
|
||||
@@ -220,6 +269,36 @@ export const api = {
|
||||
}),
|
||||
logout: () => request<void>('/api/logout', { method: 'POST' }),
|
||||
me: () => request<User>('/api/me'),
|
||||
updateMe: (payload: { display_name?: string; email?: string; password?: string }) =>
|
||||
request<User>('/api/me', { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||
listAPIKeys: () => request<APIKey[]>('/api/me/keys'),
|
||||
createAPIKey: (name: string, expires_at?: number) =>
|
||||
request<APIKeyWithToken>('/api/me/keys', { method: 'POST', body: JSON.stringify({ name, expires_at: expires_at || 0 }) }),
|
||||
deleteAPIKey: (id: string) => request<void>(`/api/me/keys/${id}`, { method: 'DELETE' }),
|
||||
disableAPIKey: (id: string) => request<void>(`/api/me/keys/${id}/disable`, { method: 'POST' }),
|
||||
enableAPIKey: (id: string) => request<void>(`/api/me/keys/${id}/enable`, { method: 'POST' }),
|
||||
listAdminAPIKeys: (user_id?: string, q?: string) => {
|
||||
const params = new URLSearchParams()
|
||||
if (user_id) params.set('user_id', user_id)
|
||||
if (q) params.set('q', q)
|
||||
const qs = params.toString()
|
||||
return request<AdminAPIKey[]>(`/api/admin/api-keys${qs ? `?${qs}` : ''}`)
|
||||
},
|
||||
deleteAdminAPIKey: (id: string) => request<void>(`/api/admin/api-keys/${id}`, { method: 'DELETE' }),
|
||||
disableAdminAPIKey: (id: string) => request<void>(`/api/admin/api-keys/${id}/disable`, { method: 'POST' }),
|
||||
enableAdminAPIKey: (id: string) => request<void>(`/api/admin/api-keys/${id}/enable`, { method: 'POST' }),
|
||||
getAuthSettings: () => request<AuthSettings>('/api/admin/auth'),
|
||||
updateAuthSettings: (payload: AuthSettings) =>
|
||||
request<AuthSettings>('/api/admin/auth', { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||
testAuthSettings: (
|
||||
payload: Partial<AuthSettings> & { username?: string; password?: string },
|
||||
signal?: AbortSignal
|
||||
) =>
|
||||
request<{ status: string; user?: string }>('/api/admin/auth/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
signal
|
||||
}),
|
||||
|
||||
listUsers: () => request<User[]>('/api/users'),
|
||||
createUser: (payload: { username: string; display_name: string; email: string; password: string; is_admin: boolean }) =>
|
||||
@@ -227,6 +306,8 @@ export const api = {
|
||||
updateUser: (id: string, payload: { display_name?: string; email?: string; password?: string; is_admin: boolean }) =>
|
||||
request<User>(`/api/users/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||
deleteUser: (id: string) => request<void>(`/api/users/${id}`, { method: 'DELETE' }),
|
||||
disableUser: (id: string) => request<void>(`/api/users/${id}/disable`, { method: 'POST' }),
|
||||
enableUser: (id: string) => request<void>(`/api/users/${id}/enable`, { method: 'POST' }),
|
||||
|
||||
listProjects: (limit?: number, offset?: number, query?: string) => {
|
||||
const params = new URLSearchParams()
|
||||
@@ -244,13 +325,19 @@ export const api = {
|
||||
return request<Repo[]>(`/api/repos${qs ? `?${qs}` : ''}`)
|
||||
},
|
||||
getProject: (id: string) => request<Project>(`/api/projects/${id}`),
|
||||
createProject: (payload: { slug: string; name: string; description: string }) =>
|
||||
createProject: (payload: { slug: string; name: string; description: string; home_page?: string }) =>
|
||||
request<Project>('/api/projects', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
updateProject: (id: string, payload: { slug?: string; name?: string; description?: string }) =>
|
||||
updateProject: (id: string, payload: { slug?: string; name?: string; description?: string; home_page?: string }) =>
|
||||
request<Project>(`/api/projects/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||
deleteProject: (id: string) => request<void>(`/api/projects/${id}`, { method: 'DELETE' }),
|
||||
|
||||
listProjectMembers: (projectId: string) => request<ProjectMember[]>(`/api/projects/${projectId}/members`),
|
||||
listProjectMemberCandidates: (projectId: string, query?: string) => {
|
||||
const params = new URLSearchParams()
|
||||
if (query) params.set('q', query)
|
||||
const qs = params.toString()
|
||||
return request<User[]>(`/api/projects/${projectId}/member-candidates${qs ? `?${qs}` : ''}`)
|
||||
},
|
||||
addProjectMember: (projectId: string, payload: { user_id: string; role: string }) =>
|
||||
request<ProjectMember>(`/api/projects/${projectId}/members`, { method: 'POST', body: JSON.stringify(payload) }),
|
||||
updateProjectMember: (projectId: string, payload: { user_id: string; role: string }) =>
|
||||
@@ -423,6 +510,16 @@ export const api = {
|
||||
if (image) params.set('image', image)
|
||||
return request<{ status: string }>(`/api/repos/${repoId}/docker/image?${params.toString()}`, { method: 'DELETE' })
|
||||
},
|
||||
renameDockerTag: (repoId: string, image: string, from: string, to: string) =>
|
||||
request<{ status: string }>(`/api/repos/${repoId}/docker/tag/rename`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ image, from, to })
|
||||
}),
|
||||
renameDockerImage: (repoId: string, from: string, to: string) =>
|
||||
request<{ status: string }>(`/api/repos/${repoId}/docker/image/rename`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ from, to })
|
||||
}),
|
||||
|
||||
listIssues: (projectId: string) => request<Issue[]>(`/api/projects/${projectId}/issues`),
|
||||
createIssue: (projectId: string, payload: { title: string; body: string }) =>
|
||||
|
||||
@@ -58,6 +58,11 @@ export default function App() {
|
||||
fontFamily
|
||||
},
|
||||
components: {
|
||||
MuiDialog: {
|
||||
defaultProps: {
|
||||
disableRestoreFocus: true
|
||||
}
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
|
||||
@@ -20,6 +20,10 @@ import DashboardIcon from '@mui/icons-material/Dashboard'
|
||||
import WorkspacesIcon from '@mui/icons-material/Workspaces'
|
||||
import StorageIcon from '@mui/icons-material/Storage'
|
||||
import PeopleIcon from '@mui/icons-material/People'
|
||||
import KeyIcon from '@mui/icons-material/Key'
|
||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'
|
||||
import PersonIcon from '@mui/icons-material/Person'
|
||||
import BadgeIcon from '@mui/icons-material/Badge'
|
||||
import DarkModeIcon from '@mui/icons-material/DarkMode'
|
||||
import LightModeIcon from '@mui/icons-material/LightMode'
|
||||
import { ThemeModeContext } from './ThemeModeContext'
|
||||
@@ -46,11 +50,15 @@ export default function Layout() {
|
||||
const navItems = useMemo(() => {
|
||||
const items = [
|
||||
{ label: 'Dashboard', path: '/', icon: <DashboardIcon fontSize="small" /> },
|
||||
{ label: 'Account', path: '/account', icon: <PersonIcon fontSize="small" /> },
|
||||
{ label: 'Projects', path: '/projects', icon: <WorkspacesIcon fontSize="small" /> },
|
||||
{ label: 'Repositories', path: '/repos', icon: <StorageIcon fontSize="small" /> }
|
||||
{ label: 'Repositories', path: '/repos', icon: <StorageIcon fontSize="small" /> },
|
||||
{ label: 'API Keys', path: '/api-keys', icon: <KeyIcon fontSize="small" /> }
|
||||
]
|
||||
if (user?.is_admin) {
|
||||
items.push({ label: 'Admin Users', path: '/admin/users', icon: <PeopleIcon fontSize="small" /> })
|
||||
items.push({ label: 'Admin API Keys', path: '/admin/api-keys', icon: <AdminPanelSettingsIcon fontSize="small" /> })
|
||||
items.push({ label: 'Site Auth', path: '/admin/auth', icon: <BadgeIcon fontSize="small" /> })
|
||||
}
|
||||
return items
|
||||
}, [user])
|
||||
|
||||
@@ -4,6 +4,7 @@ import DashboardPage from '../pages/DashboardPage'
|
||||
import LoginPage from '../pages/LoginPage'
|
||||
import ProjectsPage from '../pages/ProjectsPage'
|
||||
import GlobalReposPage from '../pages/GlobalReposPage'
|
||||
import ProjectEntryPage from '../pages/ProjectEntryPage'
|
||||
import ProjectHomePage from '../pages/ProjectHomePage'
|
||||
import ReposPage from '../pages/ReposPage'
|
||||
import RepoDetailPage from '../pages/RepoDetailPage'
|
||||
@@ -14,6 +15,10 @@ import IssuesPage from '../pages/IssuesPage'
|
||||
import WikiPage from '../pages/WikiPage'
|
||||
import FilesPage from '../pages/FilesPage'
|
||||
import AdminUsersPage from '../pages/AdminUsersPage'
|
||||
import AdminApiKeysPage from '../pages/AdminApiKeysPage'
|
||||
import AdminAuthLdapPage from '../pages/AdminAuthLdapPage'
|
||||
import ApiKeysPage from '../pages/ApiKeysPage'
|
||||
import AccountPage from '../pages/AccountPage'
|
||||
import NotFoundPage from '../pages/NotFoundPage'
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
@@ -25,7 +30,10 @@ export const routes: RouteObject[] = [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: 'projects', element: <ProjectsPage /> },
|
||||
{ path: 'repos', element: <GlobalReposPage /> },
|
||||
{ path: 'projects/:projectId', element: <ProjectHomePage /> },
|
||||
{ path: 'account', element: <AccountPage /> },
|
||||
{ path: 'api-keys', element: <ApiKeysPage /> },
|
||||
{ path: 'projects/:projectId', element: <ProjectEntryPage /> },
|
||||
{ path: 'projects/:projectId/info', element: <ProjectHomePage /> },
|
||||
{ path: 'projects/:projectId/repos', element: <ReposPage /> },
|
||||
{ path: 'projects/:projectId/repos/:repoId', element: <RepoDetailPage /> },
|
||||
{ path: 'projects/:projectId/repos/:repoId/branches', element: <BranchesPage /> },
|
||||
@@ -34,7 +42,10 @@ export const routes: RouteObject[] = [
|
||||
{ path: 'projects/:projectId/issues', element: <IssuesPage /> },
|
||||
{ path: 'projects/:projectId/wiki', element: <WikiPage /> },
|
||||
{ path: 'projects/:projectId/files', element: <FilesPage /> },
|
||||
{ path: 'admin/users', element: <AdminUsersPage /> }
|
||||
{ path: 'admin/users', element: <AdminUsersPage /> },
|
||||
{ path: 'admin/api-keys', element: <AdminApiKeysPage /> },
|
||||
{ path: 'admin/auth', element: <AdminAuthLdapPage /> },
|
||||
{ path: 'admin/auth/ldap', element: <AdminAuthLdapPage /> }
|
||||
]
|
||||
},
|
||||
{ path: '*', element: <NotFoundPage /> }
|
||||
|
||||
@@ -5,6 +5,7 @@ import FolderIcon from '@mui/icons-material/Folder'
|
||||
import BugReportIcon from '@mui/icons-material/BugReport'
|
||||
import MenuBookIcon from '@mui/icons-material/MenuBook'
|
||||
import AttachFileIcon from '@mui/icons-material/AttachFile'
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
type ProjectNavBarProps = {
|
||||
@@ -32,6 +33,14 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
|
||||
]}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/projects/${projectId}/info`}
|
||||
startIcon={<InfoOutlinedIcon />}
|
||||
sx={{ justifyContent: 'flex-start', minWidth: 0, width: 'fit-content' }}
|
||||
>
|
||||
Info
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/projects/${projectId}/repos`}
|
||||
|
||||
101
frontend/src/pages/AccountPage.tsx
Normal file
101
frontend/src/pages/AccountPage.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import { Box, Button, Paper, TextField, Typography } from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, User } from '../api'
|
||||
|
||||
export default function AccountPage() {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.me()
|
||||
.then((me) => {
|
||||
setUser(me)
|
||||
setDisplayName(me.display_name || '')
|
||||
setEmail(me.email || '')
|
||||
})
|
||||
.catch(() => {
|
||||
setUser(null)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null)
|
||||
setSaved(false)
|
||||
if (password !== passwordConfirm) {
|
||||
setError('Password and confirmation must match.')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const updated = await api.updateMe({
|
||||
display_name: displayName.trim(),
|
||||
email: email.trim(),
|
||||
password: password
|
||||
})
|
||||
setUser(updated)
|
||||
setDisplayName(updated.display_name || '')
|
||||
setEmail(updated.email || '')
|
||||
setPassword('')
|
||||
setPasswordConfirm('')
|
||||
setSaved(true)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update account'
|
||||
setError(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>
|
||||
My Account
|
||||
</Typography>
|
||||
<Paper sx={{ p: 2, maxWidth: 560 }}>
|
||||
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
|
||||
{saved ? <Alert severity="success" sx={{ mb: 1 }}>Saved.</Alert> : null}
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<TextField label="Username" value={user?.username || ''} disabled />
|
||||
<TextField
|
||||
label="Display Name"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="New Password (optional)"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
helperText={user?.auth_source === 'db' ? '' : 'Password updates are available for db users only.'}
|
||||
/>
|
||||
<TextField
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
error={passwordConfirm !== '' && password !== passwordConfirm}
|
||||
helperText={passwordConfirm !== '' && password !== passwordConfirm ? 'Passwords do not match.' : ''}
|
||||
onChange={(event) => setPasswordConfirm(event.target.value)}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
|
||||
<Button variant="contained" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
315
frontend/src/pages/AdminApiKeysPage.tsx
Normal file
315
frontend/src/pages/AdminApiKeysPage.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import BlockIcon from '@mui/icons-material/Block'
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
List,
|
||||
ListItemIcon,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
Paper,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AdminAPIKey, api, User } from '../api'
|
||||
|
||||
function formatUnix(value: number) {
|
||||
if (!value || value <= 0) {
|
||||
return 'Never'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
export default function AdminApiKeysPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [keys, setKeys] = useState<AdminAPIKey[]>([])
|
||||
const [query, setQuery] = useState('')
|
||||
const [userID, setUserID] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminAPIKey | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState('')
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [togglingID, setTogglingID] = useState('')
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [bulkOpen, setBulkOpen] = useState(false)
|
||||
const [bulkConfirm, setBulkConfirm] = useState('')
|
||||
const [bulkDeleting, setBulkDeleting] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const list = await api.listAdminAPIKeys(userID || undefined, query.trim() || undefined)
|
||||
setKeys(Array.isArray(list) ? list : [])
|
||||
setSelected([])
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load API keys'
|
||||
setError(message)
|
||||
setKeys([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.listUsers()
|
||||
.then((list) => setUsers(Array.isArray(list) ? list : []))
|
||||
.catch(() => setUsers([]))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [userID])
|
||||
|
||||
const handleSearch = () => {
|
||||
load()
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) {
|
||||
return
|
||||
}
|
||||
setDeleting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await api.deleteAdminAPIKey(deleteTarget.id)
|
||||
setDeleteTarget(null)
|
||||
setDeleteConfirm('')
|
||||
await load()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to revoke API key'
|
||||
setError(message)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelected = (id: string) => {
|
||||
setSelected((prev) => (prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]))
|
||||
}
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelected(keys.map((key) => key.id))
|
||||
return
|
||||
}
|
||||
setSelected([])
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
const ids = [...selected]
|
||||
if (!ids.length) {
|
||||
return
|
||||
}
|
||||
setBulkDeleting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await Promise.all(ids.map((id) => api.deleteAdminAPIKey(id)))
|
||||
setBulkOpen(false)
|
||||
setBulkConfirm('')
|
||||
setSelected([])
|
||||
await load()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to revoke selected API keys'
|
||||
setError(message)
|
||||
} finally {
|
||||
setBulkDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleKeyState = async (key: AdminAPIKey) => {
|
||||
setTogglingID(key.id)
|
||||
setError(null)
|
||||
try {
|
||||
if (key.disabled) {
|
||||
await api.enableAdminAPIKey(key.id)
|
||||
} else {
|
||||
await api.disableAdminAPIKey(key.id)
|
||||
}
|
||||
await load()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update API key'
|
||||
setError(message)
|
||||
} finally {
|
||||
setTogglingID('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>
|
||||
Admin: API Keys
|
||||
</Typography>
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Search"
|
||||
placeholder="key name, prefix, username, email"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSearch()
|
||||
}
|
||||
}}
|
||||
sx={{ minWidth: 280 }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
select
|
||||
label="User"
|
||||
value={userID}
|
||||
onChange={(event) => setUserID(event.target.value)}
|
||||
sx={{ minWidth: 220 }}
|
||||
>
|
||||
<MenuItem value="">All users</MenuItem>
|
||||
{users.map((user) => (
|
||||
<MenuItem key={user.id} value={user.id}>
|
||||
{user.username}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<Button variant="outlined" onClick={handleSearch}>
|
||||
Search
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={!selected.length}
|
||||
onClick={() => setBulkOpen(true)}
|
||||
>
|
||||
Revoke Selected ({selected.length})
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
|
||||
{loading ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Loading API keys...
|
||||
</Typography>
|
||||
) : (
|
||||
<List>
|
||||
{keys.length ? (
|
||||
<ListItem divider>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={selected.length > 0 && selected.length === keys.length}
|
||||
indeterminate={selected.length > 0 && selected.length < keys.length}
|
||||
onChange={(event) => handleSelectAll(event.target.checked)}
|
||||
/>
|
||||
<ListItemText primary="Select all" />
|
||||
</ListItem>
|
||||
) : null}
|
||||
{keys.map((key) => (
|
||||
<ListItem
|
||||
key={key.id}
|
||||
divider
|
||||
secondaryAction={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color={key.disabled ? 'success' : 'warning'}
|
||||
onClick={() => toggleKeyState(key)}
|
||||
title={key.disabled ? 'Enable key' : 'Disable key'}
|
||||
disabled={togglingID === key.id}
|
||||
>
|
||||
{key.disabled ? <CheckCircleOutlineIcon fontSize="small" /> : <BlockIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setDeleteTarget(key)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={selected.includes(key.id)}
|
||||
onChange={() => toggleSelected(key.id)}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={`${key.name}${key.disabled ? ' (disabled)' : ''} (${key.prefix})`}
|
||||
secondary={`${key.username} | ${key.email} | Created: ${formatUnix(key.created_at)} | Last used: ${formatUnix(key.last_used_at)} | Expires: ${formatUnix(key.expires_at)}`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
{!keys.length ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No API keys found.
|
||||
</Typography>
|
||||
) : null}
|
||||
</List>
|
||||
)}
|
||||
</Paper>
|
||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Revoke API Key</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Type the API key name to confirm revocation.
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Key name"
|
||||
value={deleteConfirm}
|
||||
onChange={(event) => setDeleteConfirm(event.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => { setDeleteTarget(null); setDeleteConfirm('') }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={deleting || !deleteTarget || deleteConfirm !== deleteTarget.name}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{deleting ? 'Revoking...' : 'Revoke'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Dialog open={bulkOpen} onClose={() => setBulkOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Revoke Selected API Keys</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Type DELETE to revoke {selected.length} selected API key(s).
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Confirmation"
|
||||
value={bulkConfirm}
|
||||
onChange={(event) => setBulkConfirm(event.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => { setBulkOpen(false); setBulkConfirm('') }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={bulkDeleting || !selected.length || bulkConfirm !== 'DELETE'}
|
||||
onClick={handleBulkDelete}
|
||||
>
|
||||
{bulkDeleting ? 'Revoking...' : 'Revoke Selected'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
248
frontend/src/pages/AdminAuthLdapPage.tsx
Normal file
248
frontend/src/pages/AdminAuthLdapPage.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import { Box, Button, Checkbox, FormControlLabel, MenuItem, Paper, TextField, Typography } from '@mui/material'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, AuthSettings } from '../api'
|
||||
|
||||
export default function AdminAuthLdapPage() {
|
||||
const [settings, setSettings] = useState<AuthSettings>({
|
||||
auth_mode: 'db',
|
||||
oidc_enabled: false,
|
||||
ldap_url: '',
|
||||
ldap_bind_dn: '',
|
||||
ldap_bind_password: '',
|
||||
ldap_user_base_dn: '',
|
||||
ldap_user_filter: '(uid={username})',
|
||||
ldap_tls_insecure_skip_verify: false,
|
||||
oidc_client_id: '',
|
||||
oidc_client_secret: '',
|
||||
oidc_authorize_url: '',
|
||||
oidc_token_url: '',
|
||||
oidc_userinfo_url: '',
|
||||
oidc_redirect_url: '',
|
||||
oidc_scopes: 'openid profile email',
|
||||
oidc_tls_insecure_skip_verify: false
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [testResult, setTestResult] = useState<string | null>(null)
|
||||
const [testUsername, setTestUsername] = useState('')
|
||||
const [testPassword, setTestPassword] = useState('')
|
||||
const testControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
api
|
||||
.getAuthSettings()
|
||||
.then((data) => setSettings(data))
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load authentication settings'
|
||||
setError(message)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSaved(false)
|
||||
try {
|
||||
const updated = await api.updateAuthSettings(settings)
|
||||
setSettings(updated)
|
||||
setSaved(true)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save authentication settings'
|
||||
setError(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = async () => {
|
||||
let controller: AbortController
|
||||
if (testing) {
|
||||
if (testControllerRef.current) {
|
||||
testControllerRef.current.abort()
|
||||
}
|
||||
return
|
||||
}
|
||||
controller = new AbortController()
|
||||
testControllerRef.current = controller
|
||||
setTesting(true)
|
||||
setError(null)
|
||||
setTestResult(null)
|
||||
try {
|
||||
const result = await api.testAuthSettings({
|
||||
...settings,
|
||||
username: testUsername.trim() || undefined,
|
||||
password: testPassword || undefined
|
||||
}, controller.signal)
|
||||
setTestResult(result.user ? `Connection ok. User test ok: ${result.user}` : 'Connection ok.')
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
setTestResult('LDAP test canceled.')
|
||||
return
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'LDAP test failed'
|
||||
setError(message)
|
||||
} finally {
|
||||
setTesting(false)
|
||||
testControllerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (testControllerRef.current) {
|
||||
testControllerRef.current.abort()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>
|
||||
Admin: Site Authentication
|
||||
</Typography>
|
||||
<Paper sx={{ p: 2, maxWidth: 820 }}>
|
||||
{loading ? <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>Loading...</Typography> : null}
|
||||
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
|
||||
{saved ? <Alert severity="success" sx={{ mb: 1 }}>Saved.</Alert> : null}
|
||||
{testResult ? <Alert severity="success" sx={{ mb: 1 }}>{testResult}</Alert> : null}
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<TextField
|
||||
select
|
||||
label="Auth Mode"
|
||||
value={settings.auth_mode}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, auth_mode: event.target.value as 'db' | 'ldap' | 'hybrid' }))}
|
||||
>
|
||||
<MenuItem value="db">db</MenuItem>
|
||||
<MenuItem value="ldap">ldap</MenuItem>
|
||||
<MenuItem value="hybrid">hybrid</MenuItem>
|
||||
</TextField>
|
||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>
|
||||
OIDC
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={settings.oidc_enabled}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_enabled: event.target.checked }))}
|
||||
/>
|
||||
}
|
||||
label="Enable OIDC login"
|
||||
/>
|
||||
<TextField
|
||||
label="Client ID"
|
||||
value={settings.oidc_client_id}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_client_id: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Client Secret"
|
||||
type="password"
|
||||
value={settings.oidc_client_secret}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_client_secret: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Authorize URL"
|
||||
value={settings.oidc_authorize_url}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_authorize_url: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Token URL"
|
||||
value={settings.oidc_token_url}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_token_url: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="UserInfo URL (optional)"
|
||||
value={settings.oidc_userinfo_url}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_userinfo_url: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Redirect URL"
|
||||
value={settings.oidc_redirect_url}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_redirect_url: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Scopes"
|
||||
value={settings.oidc_scopes}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_scopes: event.target.value }))}
|
||||
helperText="Example: openid profile email"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={settings.oidc_tls_insecure_skip_verify}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_tls_insecure_skip_verify: event.target.checked }))}
|
||||
/>
|
||||
}
|
||||
label="OIDC TLS insecure skip verify (testing/self-signed only)"
|
||||
/>
|
||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>
|
||||
LDAP
|
||||
</Typography>
|
||||
<TextField
|
||||
label="LDAP URL"
|
||||
value={settings.ldap_url}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, ldap_url: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Bind DN"
|
||||
value={settings.ldap_bind_dn}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, ldap_bind_dn: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Bind Password"
|
||||
type="password"
|
||||
value={settings.ldap_bind_password}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, ldap_bind_password: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="User Base DN"
|
||||
value={settings.ldap_user_base_dn}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, ldap_user_base_dn: event.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
label="User Filter"
|
||||
value={settings.ldap_user_filter}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, ldap_user_filter: event.target.value }))}
|
||||
helperText="Use {username} placeholder."
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={settings.ldap_tls_insecure_skip_verify}
|
||||
onChange={(event) => setSettings((prev) => ({ ...prev, ldap_tls_insecure_skip_verify: event.target.checked }))}
|
||||
/>
|
||||
}
|
||||
label="TLS insecure skip verify (testing/self-signed only)"
|
||||
/>
|
||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>
|
||||
Test (optional user bind)
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Test Username"
|
||||
value={testUsername}
|
||||
onChange={(event) => setTestUsername(event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Test Password"
|
||||
type="password"
|
||||
value={testPassword}
|
||||
onChange={(event) => setTestPassword(event.target.value)}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}>
|
||||
<Button variant="outlined" onClick={handleTest} color={testing ? 'warning' : 'primary'}>
|
||||
{testing ? 'Cancel Test' : 'Test Connection'}
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,58 +1,340 @@
|
||||
import { Box, Button, Checkbox, FormControlLabel, List, ListItem, ListItemText, Paper, TextField, Typography } from '@mui/material'
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import BlockIcon from '@mui/icons-material/Block'
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Paper,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, User } from '../api'
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('')
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [deletingUser, setDeletingUser] = useState<User | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState('')
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [togglingID, setTogglingID] = useState('')
|
||||
const [editUser, setEditUser] = useState<User | null>(null)
|
||||
const [editDisplayName, setEditDisplayName] = useState('')
|
||||
const [editEmail, setEditEmail] = useState('')
|
||||
const [editPassword, setEditPassword] = useState('')
|
||||
const [editPasswordConfirm, setEditPasswordConfirm] = useState('')
|
||||
const [editIsAdmin, setEditIsAdmin] = useState(false)
|
||||
const [editError, setEditError] = useState<string | null>(null)
|
||||
const [savingEdit, setSavingEdit] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.listUsers().then((list) => setUsers(Array.isArray(list) ? list : []))
|
||||
}, [])
|
||||
|
||||
const handleCreate = async (evt: React.FormEvent<HTMLFormElement>) => {
|
||||
const reloadUsers = async () => {
|
||||
const list = await api.listUsers()
|
||||
setUsers(Array.isArray(list) ? list : [])
|
||||
}
|
||||
|
||||
const resetCreateForm = () => {
|
||||
setUsername('')
|
||||
setDisplayName('')
|
||||
setEmail('')
|
||||
setPassword('')
|
||||
setPasswordConfirm('')
|
||||
setIsAdmin(false)
|
||||
setCreateError(null)
|
||||
}
|
||||
|
||||
const closeCreateDialog = () => {
|
||||
setCreateOpen(false)
|
||||
resetCreateForm()
|
||||
}
|
||||
|
||||
const handleCreate = async (evt: React.FormEvent) => {
|
||||
evt.preventDefault()
|
||||
const data = new FormData(evt.currentTarget)
|
||||
setCreateError(null)
|
||||
setError(null)
|
||||
const payload = {
|
||||
username: String(data.get('username') || ''),
|
||||
display_name: String(data.get('display_name') || ''),
|
||||
email: String(data.get('email') || ''),
|
||||
password: String(data.get('password') || ''),
|
||||
is_admin: String(data.get('is_admin') || '') === 'on'
|
||||
username: username.trim(),
|
||||
display_name: displayName.trim(),
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
is_admin: isAdmin
|
||||
}
|
||||
if (password !== passwordConfirm) {
|
||||
setCreateError('Password and confirmation must match.')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
try {
|
||||
const created = await api.createUser(payload)
|
||||
setUsers((prev) => [...prev, created])
|
||||
closeCreateDialog()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to create user'
|
||||
setCreateError(message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleUserState = async (user: User) => {
|
||||
setError(null)
|
||||
setTogglingID(user.id)
|
||||
try {
|
||||
if (user.disabled) {
|
||||
await api.enableUser(user.id)
|
||||
} else {
|
||||
await api.disableUser(user.id)
|
||||
}
|
||||
await reloadUsers()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update user'
|
||||
setError(message)
|
||||
} finally {
|
||||
setTogglingID('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!deletingUser) {
|
||||
return
|
||||
}
|
||||
setDeleting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await api.deleteUser(deletingUser.id)
|
||||
setDeletingUser(null)
|
||||
setDeleteConfirm('')
|
||||
await reloadUsers()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to delete user'
|
||||
setError(message)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openEditUser = (user: User) => {
|
||||
setEditUser(user)
|
||||
setEditDisplayName(user.display_name || '')
|
||||
setEditEmail(user.email || '')
|
||||
setEditPassword('')
|
||||
setEditPasswordConfirm('')
|
||||
setEditIsAdmin(Boolean(user.is_admin))
|
||||
setEditError(null)
|
||||
}
|
||||
|
||||
const closeEditUser = () => {
|
||||
setEditUser(null)
|
||||
setEditDisplayName('')
|
||||
setEditEmail('')
|
||||
setEditPassword('')
|
||||
setEditPasswordConfirm('')
|
||||
setEditIsAdmin(false)
|
||||
setEditError(null)
|
||||
}
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editUser) {
|
||||
return
|
||||
}
|
||||
setSavingEdit(true)
|
||||
setEditError(null)
|
||||
if (editPassword !== editPasswordConfirm) {
|
||||
setEditError('Password and confirmation must match.')
|
||||
setSavingEdit(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const updated = await api.updateUser(editUser.id, {
|
||||
display_name: editDisplayName.trim(),
|
||||
email: editEmail.trim(),
|
||||
password: editPassword,
|
||||
is_admin: editIsAdmin
|
||||
})
|
||||
setUsers((prev) => prev.map((user) => (user.id === updated.id ? updated : user)))
|
||||
closeEditUser()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update user'
|
||||
setEditError(message)
|
||||
} finally {
|
||||
setSavingEdit(false)
|
||||
}
|
||||
const created = await api.createUser(payload)
|
||||
setUsers((prev) => [...prev, created])
|
||||
evt.currentTarget.reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Admin: Users
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h5">Admin: Users</Typography>
|
||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||
New User
|
||||
</Button>
|
||||
</Box>
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
|
||||
<List>
|
||||
{users.map((u) => (
|
||||
<ListItem key={u.id} divider>
|
||||
<ListItemText primary={u.username} secondary={u.is_admin ? 'admin' : 'user'} />
|
||||
<ListItem
|
||||
key={u.id}
|
||||
divider
|
||||
secondaryAction={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<IconButton size="small" onClick={() => openEditUser(u)} title="Edit user">
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color={u.disabled ? 'success' : 'warning'}
|
||||
onClick={() => toggleUserState(u)}
|
||||
title={u.disabled ? 'Enable user' : 'Disable user'}
|
||||
disabled={togglingID === u.id}
|
||||
>
|
||||
{u.disabled ? <CheckCircleOutlineIcon fontSize="small" /> : <BlockIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setDeletingUser(u)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={`${u.display_name || u.username} (${u.username})${u.disabled ? ' (disabled)' : ''}`}
|
||||
secondary={`${u.is_admin ? 'admin' : 'user'} · source: ${u.auth_source || 'db'}`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Create User
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handleCreate} sx={{ display: 'grid', gap: 1 }}>
|
||||
<TextField name="username" label="Username" />
|
||||
<TextField name="display_name" label="Display Name" />
|
||||
<TextField name="email" label="Email" />
|
||||
<TextField name="password" label="Password" type="password" />
|
||||
<FormControlLabel control={<Checkbox name="is_admin" />} label="Admin" />
|
||||
<Button type="submit" variant="contained">
|
||||
Create
|
||||
<Dialog open={createOpen} onClose={closeCreateDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>New User</DialogTitle>
|
||||
<DialogContent>
|
||||
{createError ? <Alert severity="error" sx={{ mb: 1 }}>{createError}</Alert> : null}
|
||||
<Box component="form" onSubmit={handleCreate} sx={{ display: 'grid', gap: 1, mt: 1 }}>
|
||||
<TextField name="username" label="Username" value={username} onChange={(event) => setUsername(event.target.value)} />
|
||||
<TextField
|
||||
name="display_name"
|
||||
label="Display Name"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
<TextField name="email" label="Email" value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||
<TextField
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
name="password_confirm"
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
error={passwordConfirm !== '' && password !== passwordConfirm}
|
||||
helperText={passwordConfirm !== '' && password !== passwordConfirm ? 'Passwords do not match.' : ''}
|
||||
onChange={(event) => setPasswordConfirm(event.target.value)}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={isAdmin} onChange={(event) => setIsAdmin(event.target.checked)} />}
|
||||
label="Admin"
|
||||
/>
|
||||
<DialogActions sx={{ px: 0 }}>
|
||||
<Button onClick={closeCreateDialog}>Cancel</Button>
|
||||
<Button type="submit" variant="contained" disabled={creating}>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={Boolean(editUser)} onClose={closeEditUser} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
<DialogContent>
|
||||
{editError ? <Alert severity="error" sx={{ mb: 1 }}>{editError}</Alert> : null}
|
||||
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
|
||||
<TextField label="Username" value={editUser?.username || ''} disabled />
|
||||
<TextField
|
||||
label="Display Name"
|
||||
value={editDisplayName}
|
||||
onChange={(event) => setEditDisplayName(event.target.value)}
|
||||
/>
|
||||
<TextField label="Email" value={editEmail} onChange={(event) => setEditEmail(event.target.value)} />
|
||||
<TextField
|
||||
label="New Password (optional)"
|
||||
type="password"
|
||||
value={editPassword}
|
||||
onChange={(event) => setEditPassword(event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
value={editPasswordConfirm}
|
||||
error={editPasswordConfirm !== '' && editPassword !== editPasswordConfirm}
|
||||
helperText={editPasswordConfirm !== '' && editPassword !== editPasswordConfirm ? 'Passwords do not match.' : ''}
|
||||
onChange={(event) => setEditPasswordConfirm(event.target.value)}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={editIsAdmin} onChange={(event) => setEditIsAdmin(event.target.checked)} />}
|
||||
label="Admin"
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={closeEditUser}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSaveEdit} disabled={savingEdit}>
|
||||
{savingEdit ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Dialog open={Boolean(deletingUser)} onClose={() => setDeletingUser(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Delete User</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Type the username to confirm deletion.
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
value={deleteConfirm}
|
||||
onChange={(event) => setDeleteConfirm(event.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => { setDeletingUser(null); setDeleteConfirm('') }}>Cancel</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={deleting || !deletingUser || deleteConfirm !== deletingUser.username}
|
||||
onClick={handleDeleteUser}
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
312
frontend/src/pages/ApiKeysPage.tsx
Normal file
312
frontend/src/pages/ApiKeysPage.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import BlockIcon from '@mui/icons-material/Block'
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
Paper,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, APIKey } from '../api'
|
||||
|
||||
function formatUnix(value: number) {
|
||||
if (!value || value <= 0) {
|
||||
return 'Never'
|
||||
}
|
||||
return new Date(value * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
const [keys, setKeys] = useState<APIKey[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createName, setCreateName] = useState('')
|
||||
const [createExpiryAmount, setCreateExpiryAmount] = useState('')
|
||||
const [createExpiryUnit, setCreateExpiryUnit] = useState<'days' | 'hours' | 'minutes' | 'seconds'>('days')
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newToken, setNewToken] = useState<string | null>(null)
|
||||
const [tokenCopied, setTokenCopied] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<APIKey | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState('')
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [togglingID, setTogglingID] = useState('')
|
||||
|
||||
const load = async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const list = await api.listAPIKeys()
|
||||
setKeys(Array.isArray(list) ? list : [])
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load API keys'
|
||||
setError(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleCreate = async () => {
|
||||
var expiresAtUnix: number
|
||||
var amount: number
|
||||
var seconds: number
|
||||
var nowUnix: number
|
||||
setCreateError(null)
|
||||
if (!createName.trim()) {
|
||||
setCreateError('Name is required.')
|
||||
return
|
||||
}
|
||||
expiresAtUnix = 0
|
||||
if (createExpiryAmount.trim()) {
|
||||
amount = Number(createExpiryAmount)
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
setCreateError('Expiration must be a positive number.')
|
||||
return
|
||||
}
|
||||
seconds = amount
|
||||
if (createExpiryUnit == 'days') {
|
||||
seconds = amount * 24 * 60 * 60
|
||||
} else if (createExpiryUnit == 'hours') {
|
||||
seconds = amount * 60 * 60
|
||||
} else if (createExpiryUnit == 'minutes') {
|
||||
seconds = amount * 60
|
||||
}
|
||||
nowUnix = Math.floor(Date.now() / 1000)
|
||||
expiresAtUnix = nowUnix + Math.floor(seconds)
|
||||
}
|
||||
setCreating(true)
|
||||
try {
|
||||
const created = await api.createAPIKey(createName.trim(), expiresAtUnix)
|
||||
setNewToken(created.token)
|
||||
setTokenCopied(false)
|
||||
setCreateName('')
|
||||
setCreateExpiryAmount('')
|
||||
setCreateExpiryUnit('days')
|
||||
await load()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to create API key'
|
||||
setCreateError(message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyToken = async () => {
|
||||
if (!newToken) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(newToken)
|
||||
setTokenCopied(true)
|
||||
} catch {
|
||||
setTokenCopied(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) {
|
||||
return
|
||||
}
|
||||
setDeleting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await api.deleteAPIKey(deleteTarget.id)
|
||||
setDeleteTarget(null)
|
||||
setDeleteConfirm('')
|
||||
await load()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to delete API key'
|
||||
setError(message)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleKeyState = async (key: APIKey) => {
|
||||
setTogglingID(key.id)
|
||||
setError(null)
|
||||
try {
|
||||
if (key.disabled) {
|
||||
await api.enableAPIKey(key.id)
|
||||
} else {
|
||||
await api.disableAPIKey(key.id)
|
||||
}
|
||||
await load()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update API key'
|
||||
setError(message)
|
||||
} finally {
|
||||
setTogglingID('')
|
||||
}
|
||||
}
|
||||
|
||||
const closeCreate = () => {
|
||||
setCreateOpen(false)
|
||||
setCreateName('')
|
||||
setCreateExpiryAmount('')
|
||||
setCreateExpiryUnit('days')
|
||||
setCreateError(null)
|
||||
setNewToken(null)
|
||||
setTokenCopied(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h5">API Keys</Typography>
|
||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||
New API Key
|
||||
</Button>
|
||||
</Box>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
|
||||
{loading ? (
|
||||
<Typography variant="body2" color="text.secondary">Loading API keys...</Typography>
|
||||
) : (
|
||||
<List>
|
||||
{keys.map((key) => (
|
||||
<ListItem
|
||||
key={key.id}
|
||||
divider
|
||||
secondaryAction={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color={key.disabled ? 'success' : 'warning'}
|
||||
onClick={() => toggleKeyState(key)}
|
||||
title={key.disabled ? 'Enable key' : 'Disable key'}
|
||||
disabled={togglingID === key.id}
|
||||
>
|
||||
{key.disabled ? <CheckCircleOutlineIcon fontSize="small" /> : <BlockIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => setDeleteTarget(key)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={key.disabled ? `${key.name} (disabled)` : key.name}
|
||||
secondary={`Prefix: ${key.prefix} | Created: ${formatUnix(key.created_at)} | Last used: ${formatUnix(key.last_used_at)} | Expires: ${key.expires_at > 0 ? formatUnix(key.expires_at) : 'Never'}`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
{!keys.length ? (
|
||||
<Typography variant="body2" color="text.secondary">No API keys yet.</Typography>
|
||||
) : null}
|
||||
</List>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Dialog open={createOpen} onClose={closeCreate} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Create API Key</DialogTitle>
|
||||
<DialogContent>
|
||||
{createError ? <Alert severity="error" sx={{ mb: 1 }}>{createError}</Alert> : null}
|
||||
<TextField
|
||||
margin="dense"
|
||||
fullWidth
|
||||
label="Key name"
|
||||
value={createName}
|
||||
onChange={(event) => setCreateName(event.target.value)}
|
||||
disabled={Boolean(newToken)}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Expires in (optional)"
|
||||
value={createExpiryAmount}
|
||||
onChange={(event) => setCreateExpiryAmount(event.target.value)}
|
||||
disabled={Boolean(newToken)}
|
||||
sx={{ width: 180 }}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
select
|
||||
label="Unit"
|
||||
value={createExpiryUnit}
|
||||
onChange={(event) => setCreateExpiryUnit(event.target.value as 'days' | 'hours' | 'minutes' | 'seconds')}
|
||||
disabled={Boolean(newToken)}
|
||||
sx={{ width: 160, ml: 1 }}
|
||||
>
|
||||
<MenuItem value="days">Days</MenuItem>
|
||||
<MenuItem value="hours">Hours</MenuItem>
|
||||
<MenuItem value="minutes">Minutes</MenuItem>
|
||||
<MenuItem value="seconds">Seconds</MenuItem>
|
||||
</TextField>
|
||||
{newToken ? (
|
||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||
This token is shown only once. Copy and store it now.
|
||||
</Alert>
|
||||
) : null}
|
||||
{newToken ? (
|
||||
<Paper variant="outlined" sx={{ mt: 1, p: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ flex: 1, wordBreak: 'break-all' }}>
|
||||
{newToken}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={handleCopyToken} title="Copy API key">
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Paper>
|
||||
) : null}
|
||||
{tokenCopied ? (
|
||||
<Typography variant="body2" color="success.main" sx={{ mt: 1 }}>
|
||||
Copied.
|
||||
</Typography>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={closeCreate}>{newToken ? 'Done' : 'Cancel'}</Button>
|
||||
{!newToken ? (
|
||||
<Button onClick={handleCreate} variant="contained" disabled={creating}>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Delete API Key</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Type the API key name to confirm deletion.
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Key name"
|
||||
value={deleteConfirm}
|
||||
onChange={(event) => setDeleteConfirm(event.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => { setDeleteTarget(null); setDeleteConfirm('') }}>Cancel</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={deleting || !deleteTarget || deleteConfirm !== deleteTarget.name}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,27 @@
|
||||
import { Box, Button, Paper, TextField, Typography } from '@mui/material'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [oidcEnabled, setOIDCEnabled] = useState(false)
|
||||
const [oidcConfigured, setOIDCConfigured] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.oidcStatus()
|
||||
.then((res) => {
|
||||
setOIDCEnabled(Boolean(res.enabled))
|
||||
setOIDCConfigured(res.configured !== false)
|
||||
})
|
||||
.catch(() => {
|
||||
setOIDCEnabled(false)
|
||||
setOIDCConfigured(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleLogin = async (evt: React.FormEvent<HTMLFormElement>) => {
|
||||
evt.preventDefault()
|
||||
const data = new FormData(evt.currentTarget)
|
||||
@@ -36,6 +51,27 @@ export default function LoginPage() {
|
||||
<Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}>
|
||||
Login
|
||||
</Button>
|
||||
{oidcEnabled ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
sx={{ mt: 1 }}
|
||||
onClick={() => {
|
||||
window.location.assign('/api/auth/oidc/login')
|
||||
}}
|
||||
disabled={!oidcConfigured}
|
||||
>
|
||||
Login with OIDC
|
||||
</Button>
|
||||
{!oidcConfigured ? (
|
||||
<Typography color="warning.main" variant="body2" sx={{ mt: 1 }}>
|
||||
OIDC is selected but not fully configured.
|
||||
</Typography>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
</Paper>
|
||||
)
|
||||
|
||||
37
frontend/src/pages/ProjectEntryPage.tsx
Normal file
37
frontend/src/pages/ProjectEntryPage.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
|
||||
function targetPath(projectId: string, homePage?: string) {
|
||||
if (homePage === 'repos') return `/projects/${projectId}/repos`
|
||||
if (homePage === 'issues') return `/projects/${projectId}/issues`
|
||||
if (homePage === 'wiki') return `/projects/${projectId}/wiki`
|
||||
if (homePage === 'files') return `/projects/${projectId}/files`
|
||||
return `/projects/${projectId}/info`
|
||||
}
|
||||
|
||||
export default function ProjectEntryPage() {
|
||||
const { projectId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
api
|
||||
.getProject(projectId)
|
||||
.then((project) => {
|
||||
navigate(targetPath(projectId, project.home_page), { replace: true })
|
||||
})
|
||||
.catch(() => {
|
||||
navigate(`/projects/${projectId}/info`, { replace: true })
|
||||
})
|
||||
}, [projectId, navigate])
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Opening project...
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Paper, Tab, Tabs, TextField, Typography } from '@mui/material'
|
||||
import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, MenuItem, Paper, Tab, Tabs, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { api, Project } from '../api'
|
||||
import { api, Project, ProjectMember, User } from '../api'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import ProjectNavBar from '../components/ProjectNavBar'
|
||||
import HeaderActionButton from '../components/HeaderActionButton'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
|
||||
export default function ProjectHomePage() {
|
||||
const { projectId } = useParams()
|
||||
@@ -15,6 +16,7 @@ export default function ProjectHomePage() {
|
||||
const [editSlug, setEditSlug] = useState('')
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editDescription, setEditDescription] = useState('')
|
||||
const [editHomePage, setEditHomePage] = useState<'info' | 'repos' | 'issues' | 'wiki' | 'files'>('info')
|
||||
const [editDescTab, setEditDescTab] = useState<'write' | 'preview'>('write')
|
||||
const [editError, setEditError] = useState<string | null>(null)
|
||||
const [savingEdit, setSavingEdit] = useState(false)
|
||||
@@ -26,6 +28,15 @@ export default function ProjectHomePage() {
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [members, setMembers] = useState<ProjectMember[]>([])
|
||||
const [memberUsers, setMemberUsers] = useState<User[]>([])
|
||||
const [membersError, setMembersError] = useState<string | null>(null)
|
||||
const [canManageMembers, setCanManageMembers] = useState(false)
|
||||
const [newMemberUserID, setNewMemberUserID] = useState('')
|
||||
const [newMemberRole, setNewMemberRole] = useState<'viewer' | 'writer' | 'admin'>('viewer')
|
||||
const [addingMember, setAddingMember] = useState(false)
|
||||
const [updatingMemberUserID, setUpdatingMemberUserID] = useState('')
|
||||
const [removingMemberUserID, setRemovingMemberUserID] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,11 +44,34 @@ export default function ProjectHomePage() {
|
||||
api.getProject(projectId).then(setProject)
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
api
|
||||
.listProjectMembers(projectId)
|
||||
.then((list) => {
|
||||
setMembers(Array.isArray(list) ? list : [])
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load members'
|
||||
setMembersError(message)
|
||||
})
|
||||
api
|
||||
.listProjectMemberCandidates(projectId)
|
||||
.then((list) => {
|
||||
setMemberUsers(Array.isArray(list) ? list : [])
|
||||
setCanManageMembers(true)
|
||||
})
|
||||
.catch(() => {
|
||||
setCanManageMembers(false)
|
||||
})
|
||||
}, [projectId])
|
||||
|
||||
const openEdit = () => {
|
||||
if (!project) return
|
||||
setEditSlug(project.slug)
|
||||
setEditName(project.name)
|
||||
setEditDescription(project.description || '')
|
||||
setEditHomePage(project.home_page || 'info')
|
||||
setEditDescTab('write')
|
||||
setEditError(null)
|
||||
setEditOpen(true)
|
||||
@@ -52,7 +86,8 @@ export default function ProjectHomePage() {
|
||||
const payload = {
|
||||
slug: editSlug.trim(),
|
||||
name: editName.trim(),
|
||||
description: editDescription.trim()
|
||||
description: editDescription.trim(),
|
||||
home_page: editHomePage
|
||||
}
|
||||
setEditError(null)
|
||||
setSavingEdit(true)
|
||||
@@ -119,6 +154,66 @@ export default function ProjectHomePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const refreshMembers = async () => {
|
||||
if (!projectId) return
|
||||
const list = await api.listProjectMembers(projectId)
|
||||
setMembers(Array.isArray(list) ? list : [])
|
||||
}
|
||||
|
||||
const handleAddMember = async () => {
|
||||
if (!projectId || !newMemberUserID) return
|
||||
setMembersError(null)
|
||||
setAddingMember(true)
|
||||
try {
|
||||
await api.addProjectMember(projectId, { user_id: newMemberUserID, role: newMemberRole })
|
||||
setNewMemberUserID('')
|
||||
setNewMemberRole('viewer')
|
||||
await refreshMembers()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to add member'
|
||||
setMembersError(message)
|
||||
} finally {
|
||||
setAddingMember(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateMemberRole = async (userID: string, role: string) => {
|
||||
if (!projectId) return
|
||||
setMembersError(null)
|
||||
setUpdatingMemberUserID(userID)
|
||||
try {
|
||||
await api.updateProjectMember(projectId, { user_id: userID, role })
|
||||
await refreshMembers()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update member role'
|
||||
setMembersError(message)
|
||||
} finally {
|
||||
setUpdatingMemberUserID('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveMember = async (userID: string) => {
|
||||
if (!projectId) return
|
||||
setMembersError(null)
|
||||
setRemovingMemberUserID(userID)
|
||||
try {
|
||||
await api.removeProjectMember(projectId, userID)
|
||||
await refreshMembers()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to remove member'
|
||||
setMembersError(message)
|
||||
} finally {
|
||||
setRemovingMemberUserID('')
|
||||
}
|
||||
}
|
||||
|
||||
const memberName = (userID: string) => {
|
||||
const user = memberUsers.find((item) => item.id === userID)
|
||||
if (!user) return userID
|
||||
if (user.display_name) return `${user.display_name} (${user.username})`
|
||||
return user.username
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1, gap: 2, flexWrap: 'wrap' }}>
|
||||
@@ -201,6 +296,96 @@ export default function ProjectHomePage() {
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2, mt: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
||||
Members
|
||||
</Typography>
|
||||
{membersError ? <Alert severity="error" sx={{ mb: 1 }}>{membersError}</Alert> : null}
|
||||
{canManageMembers ? (
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1, flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label="User"
|
||||
value={newMemberUserID}
|
||||
onChange={(event) => setNewMemberUserID(event.target.value)}
|
||||
sx={{ minWidth: 260 }}
|
||||
>
|
||||
<MenuItem value="">Select user</MenuItem>
|
||||
{memberUsers
|
||||
.filter((user) => !members.some((member) => member.user_id === user.id))
|
||||
.map((user) => (
|
||||
<MenuItem key={user.id} value={user.id}>
|
||||
{user.display_name ? `${user.display_name} (${user.username})` : user.username}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
exclusive
|
||||
value={newMemberRole}
|
||||
onChange={(_, value) => {
|
||||
if (value) {
|
||||
setNewMemberRole(value as 'viewer' | 'writer' | 'admin')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="viewer">Viewer</ToggleButton>
|
||||
<ToggleButton value="writer">Writer</ToggleButton>
|
||||
<ToggleButton value="admin">Admin</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<Button variant="outlined" onClick={handleAddMember} disabled={!newMemberUserID || addingMember}>
|
||||
{addingMember ? 'Adding...' : 'Add Member'}
|
||||
</Button>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box sx={{ display: 'grid', gap: 0.75 }}>
|
||||
{members.map((member) => (
|
||||
<Box
|
||||
key={member.user_id}
|
||||
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}
|
||||
>
|
||||
<Typography variant="body2">{memberName(member.user_id)}</Typography>
|
||||
{canManageMembers ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
exclusive
|
||||
value={member.role}
|
||||
onChange={(_, value) => {
|
||||
if (value && value !== member.role) {
|
||||
handleUpdateMemberRole(member.user_id, value)
|
||||
}
|
||||
}}
|
||||
disabled={updatingMemberUserID === member.user_id}
|
||||
>
|
||||
<ToggleButton value="viewer">Viewer</ToggleButton>
|
||||
<ToggleButton value="writer">Writer</ToggleButton>
|
||||
<ToggleButton value="admin">Admin</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleRemoveMember(member.user_id)}
|
||||
disabled={removingMemberUserID === member.user_id}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{member.role}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{members.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No members.
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</Paper>
|
||||
<Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Edit Project</DialogTitle>
|
||||
<DialogContent>
|
||||
@@ -221,6 +406,20 @@ export default function ProjectHomePage() {
|
||||
value={editName}
|
||||
onChange={(event) => setEditName(event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
select
|
||||
label="Default Project Page"
|
||||
fullWidth
|
||||
value={editHomePage}
|
||||
onChange={(event) => setEditHomePage(event.target.value as 'info' | 'repos' | 'issues' | 'wiki' | 'files')}
|
||||
>
|
||||
<MenuItem value="info">Info</MenuItem>
|
||||
<MenuItem value="repos">Repositories</MenuItem>
|
||||
<MenuItem value="issues">Issues</MenuItem>
|
||||
<MenuItem value="wiki">Wiki</MenuItem>
|
||||
<MenuItem value="files">Files</MenuItem>
|
||||
</TextField>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Tabs
|
||||
value={editDescTab}
|
||||
|
||||
@@ -2,7 +2,7 @@ import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import { Autocomplete } from '@mui/material'
|
||||
import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, List, ListItem, ListItemText, Paper, Tab, Tabs, TextField, Typography } from '@mui/material'
|
||||
import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, List, ListItem, ListItemText, MenuItem, Paper, Tab, Tabs, TextField, Typography } from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api, Project, User } from '../api'
|
||||
@@ -17,12 +17,14 @@ export default function ProjectsPage() {
|
||||
const [editSlug, setEditSlug] = useState('')
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editDescription, setEditDescription] = useState('')
|
||||
const [editHomePage, setEditHomePage] = useState<'info' | 'repos' | 'issues' | 'wiki' | 'files'>('info')
|
||||
const [editDescTab, setEditDescTab] = useState<'write' | 'preview'>('write')
|
||||
const [editError, setEditError] = useState<string | null>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [createDescription, setCreateDescription] = useState('')
|
||||
const [createHomePage, setCreateHomePage] = useState<'info' | 'repos' | 'issues' | 'wiki' | 'files'>('info')
|
||||
const [createDescTab, setCreateDescTab] = useState<'write' | 'preview'>('write')
|
||||
const [savingEdit, setSavingEdit] = useState(false)
|
||||
const [deleteProject, setDeleteProject] = useState<Project | null>(null)
|
||||
@@ -74,7 +76,8 @@ export default function ProjectsPage() {
|
||||
const payload = {
|
||||
slug: String(data.get('slug') || ''),
|
||||
name: String(data.get('name') || ''),
|
||||
description: createDescription
|
||||
description: createDescription,
|
||||
home_page: createHomePage
|
||||
}
|
||||
if (/\s/.test(payload.slug)) {
|
||||
setCreateError('Slug cannot contain whitespace.')
|
||||
@@ -87,6 +90,7 @@ export default function ProjectsPage() {
|
||||
setProjects((prev) => [...prev, created])
|
||||
form.reset()
|
||||
setCreateDescription('')
|
||||
setCreateHomePage('info')
|
||||
setCreateDescTab('write')
|
||||
setCreateOpen(false)
|
||||
} catch (err) {
|
||||
@@ -102,6 +106,7 @@ export default function ProjectsPage() {
|
||||
setEditSlug(project.slug)
|
||||
setEditName(project.name)
|
||||
setEditDescription(project.description || '')
|
||||
setEditHomePage(project.home_page || 'info')
|
||||
setEditDescTab('write')
|
||||
setEditError(null)
|
||||
}
|
||||
@@ -115,7 +120,8 @@ export default function ProjectsPage() {
|
||||
const payload = {
|
||||
slug: editSlug.trim(),
|
||||
name: editName.trim(),
|
||||
description: editDescription.trim()
|
||||
description: editDescription.trim(),
|
||||
home_page: editHomePage
|
||||
}
|
||||
setEditError(null)
|
||||
setSavingEdit(true)
|
||||
@@ -126,6 +132,7 @@ export default function ProjectsPage() {
|
||||
setEditSlug('')
|
||||
setEditName('')
|
||||
setEditDescription('')
|
||||
setEditHomePage('info')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update project'
|
||||
setEditError(message)
|
||||
@@ -190,6 +197,7 @@ export default function ProjectsPage() {
|
||||
setCreateOpen(false)
|
||||
setCreateError(null)
|
||||
setCreateDescription('')
|
||||
setCreateHomePage('info')
|
||||
setCreateDescTab('write')
|
||||
}
|
||||
|
||||
@@ -339,6 +347,20 @@ export default function ProjectsPage() {
|
||||
value={editName}
|
||||
onChange={(event) => setEditName(event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
select
|
||||
label="Default Project Page"
|
||||
fullWidth
|
||||
value={editHomePage}
|
||||
onChange={(event) => setEditHomePage(event.target.value as 'info' | 'repos' | 'issues' | 'wiki' | 'files')}
|
||||
>
|
||||
<MenuItem value="info">Info</MenuItem>
|
||||
<MenuItem value="repos">Repositories</MenuItem>
|
||||
<MenuItem value="issues">Issues</MenuItem>
|
||||
<MenuItem value="wiki">Wiki</MenuItem>
|
||||
<MenuItem value="files">Files</MenuItem>
|
||||
</TextField>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Tabs
|
||||
value={editDescTab}
|
||||
@@ -393,6 +415,18 @@ export default function ProjectsPage() {
|
||||
helperText={createError && createError.toLowerCase().includes('slug') ? createError : ''}
|
||||
/>
|
||||
<TextField name="name" label="Name" />
|
||||
<TextField
|
||||
select
|
||||
label="Default Project Page"
|
||||
value={createHomePage}
|
||||
onChange={(event) => setCreateHomePage(event.target.value as 'info' | 'repos' | 'issues' | 'wiki' | 'files')}
|
||||
>
|
||||
<MenuItem value="info">Info</MenuItem>
|
||||
<MenuItem value="repos">Repositories</MenuItem>
|
||||
<MenuItem value="issues">Issues</MenuItem>
|
||||
<MenuItem value="wiki">Wiki</MenuItem>
|
||||
<MenuItem value="files">Files</MenuItem>
|
||||
</TextField>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Tabs
|
||||
value={createDescTab}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { api, DockerManifestDetail, DockerTagInfo, Project, Repo } from '../api'
|
||||
import ProjectNavBar from '../components/ProjectNavBar'
|
||||
import RepoSubNav from '../components/RepoSubNav'
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
|
||||
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
|
||||
|
||||
type RepoDockerDetailPageProps = {
|
||||
initialRepo?: Repo
|
||||
@@ -51,6 +52,15 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
||||
const [deleteImageConfirm, setDeleteImageConfirm] = useState('')
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [renameTagOpen, setRenameTagOpen] = useState(false)
|
||||
const [renameTagFrom, setRenameTagFrom] = useState('')
|
||||
const [renameTagTo, setRenameTagTo] = useState('')
|
||||
const [renameImageOpen, setRenameImageOpen] = useState(false)
|
||||
const [renameImageFrom, setRenameImageFrom] = useState('')
|
||||
const [renameImageFromLabel, setRenameImageFromLabel] = useState('')
|
||||
const [renameImageTo, setRenameImageTo] = useState('')
|
||||
const [renameError, setRenameError] = useState<string | null>(null)
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const initRepoRef = useRef<string | null>(null)
|
||||
const initProjectRef = useRef<string | null>(null)
|
||||
|
||||
@@ -231,6 +241,49 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRenameTag = async () => {
|
||||
if (!repoId) return
|
||||
if (!renameTagFrom.trim() || !renameTagTo.trim()) {
|
||||
setRenameError('Both tag names are required.')
|
||||
return
|
||||
}
|
||||
setRenameError(null)
|
||||
setRenaming(true)
|
||||
try {
|
||||
await api.renameDockerTag(repoId, selectedImage, renameTagFrom.trim(), renameTagTo.trim())
|
||||
setRenameTagOpen(false)
|
||||
setRenameTagFrom('')
|
||||
setRenameTagTo('')
|
||||
refreshTags()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to rename tag'
|
||||
setRenameError(message)
|
||||
} finally {
|
||||
setRenaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRenameImage = async () => {
|
||||
if (!repoId) return
|
||||
setRenameError(null)
|
||||
setRenaming(true)
|
||||
try {
|
||||
await api.renameDockerImage(repoId, renameImageFrom, renameImageTo.trim())
|
||||
setRenameImageOpen(false)
|
||||
setRenameImageFrom('')
|
||||
setRenameImageFromLabel('')
|
||||
setRenameImageTo('')
|
||||
setSelectedImage(renameImageTo.trim())
|
||||
refreshImages()
|
||||
refreshTags()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to rename image'
|
||||
setRenameError(message)
|
||||
} finally {
|
||||
setRenaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
@@ -309,6 +362,20 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setRenameError(null)
|
||||
setRenameImageFrom(image)
|
||||
setRenameImageFromLabel(image || '(root)')
|
||||
setRenameImageTo(image || '')
|
||||
setRenameImageOpen(true)
|
||||
}}
|
||||
aria-label={`Rename image ${image || '(root)'}`}
|
||||
>
|
||||
<DriveFileRenameOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
))}
|
||||
{!images.length && !imagesError ? (
|
||||
@@ -356,6 +423,18 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setRenameError(null)
|
||||
setRenameTagFrom(tag.tag)
|
||||
setRenameTagTo(tag.tag)
|
||||
setRenameTagOpen(true)
|
||||
}}
|
||||
aria-label={`Rename tag ${tag.tag}`}
|
||||
>
|
||||
<DriveFileRenameOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
))}
|
||||
{!tags.length && !tagsError ? (
|
||||
@@ -455,6 +534,47 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Dialog open={renameTagOpen} onClose={() => setRenameTagOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Rename tag</DialogTitle>
|
||||
<DialogContent>
|
||||
{renameError ? <Alert severity="error">{renameError}</Alert> : null}
|
||||
<TextField label="From" value={renameTagFrom} fullWidth margin="dense" InputProps={{ readOnly: true }} />
|
||||
<TextField
|
||||
label="To"
|
||||
value={renameTagTo}
|
||||
onChange={(event) => setRenameTagTo(event.target.value)}
|
||||
fullWidth
|
||||
margin="dense"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setRenameTagOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleRenameTag} variant="contained" disabled={renaming}>
|
||||
{renaming ? 'Renaming...' : 'Rename'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Dialog open={renameImageOpen} onClose={() => setRenameImageOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Rename image</DialogTitle>
|
||||
<DialogContent>
|
||||
{renameError ? <Alert severity="error">{renameError}</Alert> : null}
|
||||
<TextField label="From" value={renameImageFromLabel} fullWidth margin="dense" InputProps={{ readOnly: true }} />
|
||||
<TextField
|
||||
label="To"
|
||||
value={renameImageTo}
|
||||
onChange={(event) => setRenameImageTo(event.target.value)}
|
||||
fullWidth
|
||||
margin="dense"
|
||||
helperText="Leave blank to move to root."
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setRenameImageOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleRenameImage} variant="contained" disabled={renaming}>
|
||||
{renaming ? 'Renaming...' : 'Rename'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:1080'
|
||||
'^/api(?:/|$)': 'http://localhost:1080'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user