Compare commits

...

3 Commits

47 changed files with 5980 additions and 106 deletions

View File

@@ -272,6 +272,7 @@ func main() {
RpmMeta: rpmMeta, RpmMeta: rpmMeta,
DockerBase: dockerBase, DockerBase: dockerBase,
Uploads: uploadStore, Uploads: uploadStore,
Logger: logger,
} }
var graphqlHandler http.Handler var graphqlHandler http.Handler
@@ -286,12 +287,37 @@ func main() {
var user models.User var user models.User
var hash string var hash string
var err error var err error
var key string
user, hash, err = store.GetUserByUsername(username) user, hash, err = store.GetUserByUsername(username)
if err != nil || hash == "" { 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 return false, nil
} }
err = auth.ComparePassword(hash, password) err = auth.ComparePassword(hash, password)
if err != nil { 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 false, nil
} }
return user.ID != "", nil return user.ID != "", nil
@@ -310,12 +336,24 @@ func main() {
router.Handle("GET", "/api/health", api.Health) router.Handle("GET", "/api/health", api.Health)
router.Handle("POST", "/api/login", api.Login) router.Handle("POST", "/api/login", api.Login)
router.Handle("POST", "/api/logout", api.Logout) 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("GET", "/api/me", api.Me)
router.Handle("PATCH", "/api/me", api.UpdateMe)
router.Handle("GET", "/api/users", api.ListUsers) router.Handle("GET", "/api/users", api.ListUsers)
router.Handle("POST", "/api/users", api.CreateUser) router.Handle("POST", "/api/users", api.CreateUser)
router.Handle("PATCH", "/api/users/:id", api.UpdateUser) router.Handle("PATCH", "/api/users/:id", api.UpdateUser)
router.Handle("DELETE", "/api/users/:id", api.DeleteUser) 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("GET", "/api/projects", api.ListProjects)
router.Handle("POST", "/api/projects", api.CreateProject) router.Handle("POST", "/api/projects", api.CreateProject)
@@ -324,6 +362,7 @@ func main() {
router.Handle("DELETE", "/api/projects/:id", api.DeleteProject) router.Handle("DELETE", "/api/projects/:id", api.DeleteProject)
router.Handle("GET", "/api/projects/:projectId/members", api.ListProjectMembers) 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("POST", "/api/projects/:projectId/members", api.AddProjectMember)
router.Handle("PATCH", "/api/projects/:projectId/members", api.UpdateProjectMember) router.Handle("PATCH", "/api/projects/:projectId/members", api.UpdateProjectMember)
router.Handle("DELETE", "/api/projects/:projectId/members/:userId", api.RemoveProjectMember) 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("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/tag", api.RepoDockerDeleteTag)
router.Handle("DELETE", "/api/repos/:id/docker/image", api.RepoDockerDeleteImage) 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("GET", "/api/projects/:projectId/issues", api.ListIssues)
router.Handle("POST", "/api/projects/:projectId/issues", api.CreateIssue) 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/", middleware.WithUser(store, middleware.AccessLog(logger, middleware.RequireAuth(router))))
mux.Handle("/api/login", middleware.WithUser(store, middleware.AccessLog(logger, 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/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("/api/health", middleware.AccessLog(logger, router))
mux.Handle("/", middleware.WithUser(store, spaHandler(filepath.Join("..", "frontend", "dist")))) mux.Handle("/", middleware.WithUser(store, spaHandler(filepath.Join("..", "frontend", "dist"))))
@@ -413,16 +466,29 @@ func main() {
} }
func spaHandler(root string) http.HandlerFunc { func spaHandler(root string) http.HandlerFunc {
var fileServer http.Handler
fileServer = http.FileServer(http.Dir(root))
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var path string 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 info os.FileInfo
var err error var err error
info, err = os.Stat(path) info, err = os.Stat(path)
if err == nil && !info.IsDir() { if err == nil && !info.IsDir() {
fileServer.ServeHTTP(w, r) http.ServeFile(w, r, path)
return
}
if filepath.Ext(cleaned) != "" {
w.WriteHeader(http.StatusNotFound)
return return
} }
var indexPath string var indexPath string
@@ -444,7 +510,7 @@ func bootstrapAdmin(store *db.Store) error {
var hash string var hash string
var err error var err error
bootstrap = os.Getenv("BUN_BOOTSTRAP_ADMIN") bootstrap = os.Getenv("CODIT_BOOTSTRAP_ADMIN")
if bootstrap == "" { if bootstrap == "" {
return nil return nil
} }

View 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)
}
}

View File

@@ -1,7 +1,11 @@
package auth package auth
import "context"
import "fmt" import "fmt"
import "net"
import "strings" import "strings"
import "time"
import "crypto/tls"
import "codit/internal/config" import "codit/internal/config"
import "github.com/go-ldap/ldap/v3" import "github.com/go-ldap/ldap/v3"
@@ -12,8 +16,15 @@ type LDAPUser struct {
Email string Email string
} }
const LDAPOperationTimeout time.Duration = 8 * time.Second
func LDAPAuthenticate(cfg config.Config, username, password string) (LDAPUser, error) { 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 conn *ldap.Conn
var cleanup func()
var err error var err error
var filter string var filter string
var search *ldap.SearchRequest var search *ldap.SearchRequest
@@ -21,15 +32,18 @@ func LDAPAuthenticate(cfg config.Config, username, password string) (LDAPUser, e
var entry *ldap.Entry var entry *ldap.Entry
var userDN string var userDN string
var user LDAPUser var user LDAPUser
conn, err = ldap.DialURL(cfg.LDAPURL) conn, cleanup, err = ldapConnWithContext(ctx, cfg)
if err != nil { if err != nil {
return LDAPUser{}, err return LDAPUser{}, err
} }
defer conn.Close() defer cleanup()
if cfg.LDAPBindDN != "" { if cfg.LDAPBindDN != "" {
err = conn.Bind(cfg.LDAPBindDN, cfg.LDAPBindPassword) err = conn.Bind(cfg.LDAPBindDN, cfg.LDAPBindPassword)
if err != nil { if err != nil {
if ctx.Err() != nil {
return LDAPUser{}, ctx.Err()
}
return LDAPUser{}, err return LDAPUser{}, err
} }
} }
@@ -45,6 +59,9 @@ func LDAPAuthenticate(cfg config.Config, username, password string) (LDAPUser, e
res, err = conn.Search(search) res, err = conn.Search(search)
if err != nil { if err != nil {
if ctx.Err() != nil {
return LDAPUser{}, ctx.Err()
}
return LDAPUser{}, err return LDAPUser{}, err
} }
if len(res.Entries) == 0 { if len(res.Entries) == 0 {
@@ -54,6 +71,9 @@ func LDAPAuthenticate(cfg config.Config, username, password string) (LDAPUser, e
userDN = entry.DN userDN = entry.DN
err = conn.Bind(userDN, password) err = conn.Bind(userDN, password)
if err != nil { if err != nil {
if ctx.Err() != nil {
return LDAPUser{}, ctx.Err()
}
return LDAPUser{}, err return LDAPUser{}, err
} }
@@ -67,3 +87,64 @@ func LDAPAuthenticate(cfg config.Config, username, password string) (LDAPUser, e
} }
return user, nil 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
}

View File

@@ -5,6 +5,7 @@ import "errors"
import "os" import "os"
import "strings" import "strings"
import "time" import "time"
import "strconv"
type Config struct { type Config struct {
HTTPAddr string `json:"http_addr"` HTTPAddr string `json:"http_addr"`
@@ -19,6 +20,16 @@ type Config struct {
LDAPBindPassword string `json:"ldap_bind_password"` LDAPBindPassword string `json:"ldap_bind_password"`
LDAPUserBaseDN string `json:"ldap_user_base_dn"` LDAPUserBaseDN string `json:"ldap_user_base_dn"`
LDAPUserFilter string `json:"ldap_user_filter"` 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"` GitHTTPPrefix string `json:"git_http_prefix"`
RPMHTTPPrefix string `json:"rpm_http_prefix"` RPMHTTPPrefix string `json:"rpm_http_prefix"`
} }
@@ -35,6 +46,7 @@ func Load(path string) (Config, error) {
SessionTTL: Duration(24 * time.Hour), SessionTTL: Duration(24 * time.Hour),
AuthMode: "db", AuthMode: "db",
LDAPUserFilter: "(uid={username})", LDAPUserFilter: "(uid={username})",
OIDCScopes: "openid profile email",
GitHTTPPrefix: "/git", GitHTTPPrefix: "/git",
RPMHTTPPrefix: "/rpm", RPMHTTPPrefix: "/rpm",
} }
@@ -58,55 +70,95 @@ func Load(path string) (Config, error) {
func override(cfg *Config) { func override(cfg *Config) {
var v string var v string
v = os.Getenv("BUN_HTTP_ADDR") v = os.Getenv("CODIT_HTTP_ADDR")
if v != "" { if v != "" {
cfg.HTTPAddr = v cfg.HTTPAddr = v
} }
v = os.Getenv("BUN_PUBLIC_BASE_URL") v = os.Getenv("CODIT_PUBLIC_BASE_URL")
if v != "" { if v != "" {
cfg.PublicBaseURL = v cfg.PublicBaseURL = v
} }
v = os.Getenv("BUN_DATA_DIR") v = os.Getenv("CODIT_DATA_DIR")
if v != "" { if v != "" {
cfg.DataDir = v cfg.DataDir = v
} }
v = os.Getenv("BUN_DB_DRIVER") v = os.Getenv("CODIT_DB_DRIVER")
if v != "" { if v != "" {
cfg.DBDriver = v cfg.DBDriver = v
} }
v = os.Getenv("BUN_DB_DSN") v = os.Getenv("CODIT_DB_DSN")
if v != "" { if v != "" {
cfg.DBDSN = v cfg.DBDSN = v
} }
v = os.Getenv("BUN_AUTH_MODE") v = os.Getenv("CODIT_AUTH_MODE")
if v != "" { if v != "" {
cfg.AuthMode = v cfg.AuthMode = v
} }
v = os.Getenv("BUN_LDAP_URL") v = os.Getenv("CODIT_LDAP_URL")
if v != "" { if v != "" {
cfg.LDAPURL = v cfg.LDAPURL = v
} }
v = os.Getenv("BUN_LDAP_BIND_DN") v = os.Getenv("CODIT_LDAP_BIND_DN")
if v != "" { if v != "" {
cfg.LDAPBindDN = v cfg.LDAPBindDN = v
} }
v = os.Getenv("BUN_LDAP_BIND_PASSWORD") v = os.Getenv("CODIT_LDAP_BIND_PASSWORD")
if v != "" { if v != "" {
cfg.LDAPBindPassword = v cfg.LDAPBindPassword = v
} }
v = os.Getenv("BUN_LDAP_USER_BASE_DN") v = os.Getenv("CODIT_LDAP_USER_BASE_DN")
if v != "" { if v != "" {
cfg.LDAPUserBaseDN = v cfg.LDAPUserBaseDN = v
} }
v = os.Getenv("BUN_LDAP_USER_FILTER") v = os.Getenv("CODIT_LDAP_USER_FILTER")
if v != "" { if v != "" {
cfg.LDAPUserFilter = 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 != "" { if v != "" {
cfg.GitHTTPPrefix = v cfg.GitHTTPPrefix = v
} }
v = os.Getenv("BUN_RPM_HTTP_PREFIX") v = os.Getenv("CODIT_RPM_HTTP_PREFIX")
if v != "" { if v != "" {
cfg.RPMHTTPPrefix = v cfg.RPMHTTPPrefix = v
} }
@@ -139,3 +191,21 @@ func (d *Duration) UnmarshalJSON(b []byte) error {
} }
return errors.New("invalid duration format") 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
}

View 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())
}
}

View 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)
}
}

View File

@@ -28,9 +28,9 @@ func (s *Store) CreateUser(user models.User, passwordHash string) (models.User,
user.AuthSource = "db" user.AuthSource = "db"
} }
_, err = s.DB.Exec(` _, err = s.DB.Exec(`
INSERT INTO users (id, username, display_name, email, password_hash, is_admin, auth_source, created_at, updated_at) INSERT INTO users (id, username, display_name, email, password_hash, is_admin, disabled, auth_source, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, user.ID, user.Username, user.DisplayName, user.Email, passwordHash, user.IsAdmin, user.AuthSource, now, now) `, user.ID, user.Username, user.DisplayName, user.Email, passwordHash, user.IsAdmin, user.Disabled, user.AuthSource, now, now)
return user, err return user, err
} }
@@ -41,11 +41,36 @@ func (s *Store) UpdateUser(user models.User) error {
now = time.Now().UTC() now = time.Now().UTC()
nowUnix = now.Unix() nowUnix = now.Unix()
user.UpdatedAt = nowUnix user.UpdatedAt = nowUnix
_, err = s.DB.Exec(`UPDATE users SET display_name = ?, email = ?, is_admin = ?, updated_at = ? WHERE id = ?`, _, err = s.DB.Exec(`UPDATE users SET display_name = ?, email = ?, is_admin = ?, disabled = ?, updated_at = ? WHERE id = ?`,
user.DisplayName, user.Email, user.IsAdmin, now, user.ID) user.DisplayName, user.Email, user.IsAdmin, user.Disabled, now, user.ID)
return err 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 { func (s *Store) SetUserPassword(userID, passwordHash string) error {
var err error var err error
_, err = s.DB.Exec(`UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, passwordHash, time.Now().UTC(), userID) _, 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 created time.Time
var updated time.Time var updated time.Time
var err error 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) 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.AuthSource, &created, &updated) err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &created, &updated)
if err != nil { if err != nil {
return user, err return user, err
} }
@@ -75,8 +100,8 @@ func (s *Store) GetUserByUsername(username string) (models.User, string, error)
var err error var err error
var created time.Time var created time.Time
var updated 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) 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.AuthSource, &passwordHash, &created, &updated) err = row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.IsAdmin, &user.Disabled, &user.AuthSource, &passwordHash, &created, &updated)
if err != nil { if err != nil {
return user, passwordHash.String, err return user, passwordHash.String, err
} }
@@ -92,13 +117,13 @@ func (s *Store) ListUsers() ([]models.User, error) {
var u models.User var u models.User
var created time.Time var created time.Time
var updated 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 { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
for rows.Next() { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -115,6 +140,417 @@ func (s *Store) DeleteUser(id string) error {
return err 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 { func (s *Store) CreateSession(userID, token string, expiresAt time.Time) error {
var err error var err error
_, err = s.DB.Exec(`INSERT INTO sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)`, _, 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 created time.Time
var updated time.Time var updated time.Time
row = s.DB.QueryRow(` 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 FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ? WHERE s.token = ? AND u.disabled = 0
`, token) `, 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 { if err != nil {
return user, time.Time{}, err return user, time.Time{}, err
} }
@@ -173,12 +609,16 @@ func (s *Store) CreateProject(project models.Project) (models.Project, error) {
if err != nil { if err != nil {
return project, err 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) if project.HomePage == "" {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 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.ID,
project.Slug, project.Slug,
project.Name, project.Name,
project.Description, project.Description,
project.HomePage,
project.CreatedBy, project.CreatedBy,
project.UpdatedBy, project.UpdatedBy,
now, now,
@@ -206,10 +646,14 @@ func (s *Store) CreateProject(project models.Project) (models.Project, error) {
func (s *Store) UpdateProject(project models.Project) error { func (s *Store) UpdateProject(project models.Project) error {
var err 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.Slug,
project.Name, project.Name,
project.Description, project.Description,
project.HomePage,
time.Unix(project.UpdatedAt, 0).UTC(), time.Unix(project.UpdatedAt, 0).UTC(),
project.UpdatedBy, project.UpdatedBy,
project.UpdatedAt, project.UpdatedAt,
@@ -222,7 +666,7 @@ func (s *Store) GetProject(id string) (models.Project, error) {
var project models.Project var project models.Project
var row *sql.Row var row *sql.Row
row = s.DB.QueryRow(` 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, p.created_by, p.updated_by,
COALESCE(c.username, ''), COALESCE(u.username, ''), COALESCE(c.username, ''), COALESCE(u.username, ''),
p.created_at_unix, p.updated_at_unix p.created_at_unix, p.updated_at_unix
@@ -236,6 +680,7 @@ func (s *Store) GetProject(id string) (models.Project, error) {
&project.Slug, &project.Slug,
&project.Name, &project.Name,
&project.Description, &project.Description,
&project.HomePage,
&project.CreatedBy, &project.CreatedBy,
&project.UpdatedBy, &project.UpdatedBy,
&project.CreatedByName, &project.CreatedByName,
@@ -250,7 +695,7 @@ func (s *Store) GetProjectBySlug(slug string) (models.Project, error) {
var row *sql.Row var row *sql.Row
var err error var err error
row = s.DB.QueryRow(` 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, p.created_by, p.updated_by,
COALESCE(c.username, ''), COALESCE(u.username, ''), COALESCE(c.username, ''), COALESCE(u.username, ''),
p.created_at_unix, p.updated_at_unix p.created_at_unix, p.updated_at_unix
@@ -264,6 +709,7 @@ func (s *Store) GetProjectBySlug(slug string) (models.Project, error) {
&project.Slug, &project.Slug,
&project.Name, &project.Name,
&project.Description, &project.Description,
&project.HomePage,
&project.CreatedBy, &project.CreatedBy,
&project.UpdatedBy, &project.UpdatedBy,
&project.CreatedByName, &project.CreatedByName,
@@ -283,7 +729,7 @@ func (s *Store) ListProjects() ([]models.Project, error) {
var projects []models.Project var projects []models.Project
var p models.Project var p models.Project
rows, err = s.DB.Query(` 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, p.created_by, p.updated_by,
COALESCE(c.username, ''), COALESCE(u.username, ''), COALESCE(c.username, ''), COALESCE(u.username, ''),
p.created_at_unix, p.updated_at_unix p.created_at_unix, p.updated_at_unix
@@ -302,6 +748,50 @@ func (s *Store) ListProjects() ([]models.Project, error) {
&p.Slug, &p.Slug,
&p.Name, &p.Name,
&p.Description, &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.CreatedBy,
&p.UpdatedBy, &p.UpdatedBy,
&p.CreatedByName, &p.CreatedByName,
@@ -330,7 +820,7 @@ func (s *Store) ListProjectsFiltered(limit int, offset int, query string) ([]mod
} }
if query == "" { if query == "" {
rows, err = s.DB.Query( 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, p.created_by, p.updated_by,
COALESCE(c.username, ''), COALESCE(u.username, ''), COALESCE(c.username, ''), COALESCE(u.username, ''),
p.created_at_unix, p.updated_at_unix p.created_at_unix, p.updated_at_unix
@@ -343,7 +833,7 @@ func (s *Store) ListProjectsFiltered(limit int, offset int, query string) ([]mod
) )
} else { } else {
rows, err = s.DB.Query( 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, p.created_by, p.updated_by,
COALESCE(c.username, ''), COALESCE(u.username, ''), COALESCE(c.username, ''), COALESCE(u.username, ''),
p.created_at_unix, p.updated_at_unix p.created_at_unix, p.updated_at_unix
@@ -368,6 +858,79 @@ func (s *Store) ListProjectsFiltered(limit int, offset int, query string) ([]mod
&p.Slug, &p.Slug,
&p.Name, &p.Name,
&p.Description, &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.CreatedBy,
&p.UpdatedBy, &p.UpdatedBy,
&p.CreatedByName, &p.CreatedByName,

View 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
}

View 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)
}

View 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()
}

View 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)
}
}

View 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")
}
}

View File

@@ -1,5 +1,7 @@
package handlers package handlers
import "crypto/rand"
import "encoding/hex"
import "errors" import "errors"
import "io" import "io"
import "mime/multipart" import "mime/multipart"
@@ -30,6 +32,7 @@ type API struct {
RpmMeta *rpm.MetaManager RpmMeta *rpm.MetaManager
DockerBase string DockerBase string
Uploads storage.FileStore Uploads storage.FileStore
Logger *util.Logger
} }
type loginRequest struct { type loginRequest struct {
@@ -45,10 +48,48 @@ type createUserRequest struct {
IsAdmin bool `json:"is_admin"` 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 { type createProjectRequest struct {
Slug string `json:"slug"` Slug string `json:"slug"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
HomePage string `json:"home_page"`
} }
type createRepoRequest struct { type createRepoRequest struct {
@@ -134,6 +175,27 @@ type repoRPMRenameRequest struct {
Name string `json:"name"` 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 { type repoRPMUploadResponse struct {
Filename string `json:"filename"` Filename string `json:"filename"`
Size int64 `json:"size"` 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 ldapUser auth.LDAPUser
var newUser models.User var newUser models.User
var created models.User var created models.User
var authCfg config.Config
err = DecodeJSON(r, &req) err = DecodeJSON(r, &req)
if err != nil { if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) 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) 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 { if err == nil && storedHash != "" && auth.ComparePassword(storedHash, req.Password) == nil {
api.issueSession(w, user) api.issueSession(w, user)
WriteJSON(w, http.StatusOK, user) WriteJSON(w, http.StatusOK, user)
return return
} }
if api.Cfg.AuthMode == "ldap" || api.Cfg.AuthMode == "hybrid" { authCfg, _ = api.effectiveAuthConfig()
ldapUser, err = auth.LDAPAuthenticate(api.Cfg, req.Username, req.Password) 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 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 == "" { if user.ID == "" {
newUser = models.User{ newUser = models.User{
Username: ldapUser.Username, 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) WriteJSON(w, http.StatusOK, user)
return 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"}) 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) 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) { func (api *API) ListUsers(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var users []models.User var users []models.User
var err error var err error
@@ -331,6 +467,7 @@ func (api *API) UpdateUser(w http.ResponseWriter, r *http.Request, params map[st
var err error var err error
var payload createUserRequest var payload createUserRequest
var hash string var hash string
var newPassword bool
if !api.requireAdmin(w, r) { if !api.requireAdmin(w, r) {
return return
} }
@@ -351,15 +488,23 @@ func (api *API) UpdateUser(w http.ResponseWriter, r *http.Request, params map[st
user.Email = payload.Email user.Email = payload.Email
} }
user.IsAdmin = payload.IsAdmin user.IsAdmin = payload.IsAdmin
err = api.Store.UpdateUser(user) newPassword = payload.Password != ""
if newPassword {
hash, err = auth.HashPassword(payload.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 { if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
} }
if payload.Password != "" { } else {
hash, err = auth.HashPassword(payload.Password) err = api.Store.UpdateUser(user)
if err == nil { if err != nil {
_ = api.Store.SetUserPassword(user.ID, hash) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
} }
} }
WriteJSON(w, http.StatusOK, user) 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) 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) { func (api *API) ListProjects(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var projects []models.Project var projects []models.Project
var err error var err error
@@ -386,9 +719,16 @@ func (api *API) ListProjects(w http.ResponseWriter, r *http.Request, _ map[strin
var query string var query string
var v string var v string
var i int var i int
var user models.User
var ok bool
limit = 0 limit = 0
offset = 0 offset = 0
query = strings.TrimSpace(r.URL.Query().Get("q")) 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") v = r.URL.Query().Get("limit")
if v != "" { if v != "" {
i, err = strconv.Atoi(v) i, err = strconv.Atoi(v)
@@ -403,11 +743,19 @@ func (api *API) ListProjects(w http.ResponseWriter, r *http.Request, _ map[strin
offset = i offset = i
} }
} }
if user.IsAdmin {
if query != "" || limit > 0 || offset > 0 { if query != "" || limit > 0 || offset > 0 {
projects, err = api.Store.ListProjectsFiltered(limit, offset, query) projects, err = api.Store.ListProjectsFiltered(limit, offset, query)
} else { } else {
projects, err = api.Store.ListProjects() projects, err = api.Store.ListProjects()
} }
} else {
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 { if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
@@ -453,6 +801,7 @@ func (api *API) CreateProject(w http.ResponseWriter, r *http.Request, _ map[stri
Slug: req.Slug, Slug: req.Slug,
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
HomePage: normalizeProjectHomePage(req.HomePage),
CreatedBy: user.ID, CreatedBy: user.ID,
UpdatedBy: 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.Slug = req.Slug
} }
project.Description = req.Description project.Description = req.Description
if req.HomePage != "" {
project.HomePage = normalizeProjectHomePage(req.HomePage)
}
if project.HomePage == "" {
project.HomePage = "info"
}
user, _ = middleware.UserFromContext(r.Context()) user, _ = middleware.UserFromContext(r.Context())
project.UpdatedBy = user.ID project.UpdatedBy = user.ID
project.UpdatedAt = time.Now().UTC().Unix() 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) 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) { func (api *API) ListRepos(w http.ResponseWriter, r *http.Request, params map[string]string) {
var err error var err error
var repos []models.Repo var repos []models.Repo
@@ -2501,6 +2890,193 @@ func (api *API) RepoDockerImages(w http.ResponseWriter, r *http.Request, params
WriteJSON(w, http.StatusOK, images) 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) { func (api *API) RepoDockerDeleteTag(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo var repo models.Repo
var err error 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"}) 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) { func (api *API) ListIssues(w http.ResponseWriter, r *http.Request, params map[string]string) {
var issues []models.Issue var issues []models.Issue
var err error 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) { func normalizeRepoType(value string) (string, bool) {
var v string var v string
v = strings.ToLower(strings.TrimSpace(value)) v = strings.ToLower(strings.TrimSpace(value))

View 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,
})
}

View 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)
}
}

View 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)
}
}

View 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())
}
}

View File

@@ -2,10 +2,12 @@ package middleware
import "context" import "context"
import "net/http" import "net/http"
import "strings"
import "time" import "time"
import "codit/internal/db" import "codit/internal/db"
import "codit/internal/models" import "codit/internal/models"
import "codit/internal/util"
type ctxKey string type ctxKey string
@@ -18,16 +20,42 @@ func WithUser(store *db.Store, next http.Handler) http.Handler {
var user models.User var user models.User
var expires time.Time var expires time.Time
var ctx context.Context var ctx context.Context
var token string
var hash string
cookie, err = r.Cookie("codit_session") cookie, err = r.Cookie("codit_session")
if err != nil || cookie.Value == "" { if err != nil || cookie.Value == "" {
token = apiKeyFromRequest(r)
if token == "" {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return 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) user, expires, err = store.GetSessionUser(cookie.Value)
if err != nil || time.Now().UTC().After(expires) { if err != nil || time.Now().UTC().After(expires) {
token = apiKeyFromRequest(r)
if token == "" {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return 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) ctx = context.WithValue(r.Context(), userKey, user)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
@@ -64,3 +92,25 @@ func RequireAdmin(next http.Handler) http.Handler {
next.ServeHTTP(w, r) 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]
}

View 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")
}
}

View File

@@ -6,6 +6,7 @@ type User struct {
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Email string `json:"email"` Email string `json:"email"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
Disabled bool `json:"disabled"`
AuthSource string `json:"auth_source"` AuthSource string `json:"auth_source"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
@@ -16,6 +17,7 @@ type Project struct {
Slug string `json:"slug"` Slug string `json:"slug"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
HomePage string `json:"home_page"`
CreatedBy string `json:"created_by"` CreatedBy string `json:"created_by"`
UpdatedBy string `json:"updated_by"` UpdatedBy string `json:"updated_by"`
CreatedByName string `json:"created_by_name"` CreatedByName string `json:"created_by_name"`
@@ -82,3 +84,47 @@ type Upload struct {
CreatedBy string `json:"created_by"` CreatedBy string `json:"created_by"`
CreatedAt int64 `json:"created_at"` 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"`
}

View 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")
}
}

View 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)
}
}

View 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")
}
}

View 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)
}
}

View 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[:])
}

View 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")
}
}

View 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);

View File

@@ -0,0 +1 @@
ALTER TABLE projects ADD COLUMN home_page TEXT NOT NULL DEFAULT 'info';

View 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);

View 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);

View 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
);

View File

@@ -4,6 +4,7 @@ export interface User {
display_name: string display_name: string
email: string email: string
is_admin: boolean is_admin: boolean
disabled?: boolean
} }
export interface Project { export interface Project {
@@ -11,6 +12,7 @@ export interface Project {
slug: string slug: string
name: string name: string
description: string description: string
home_page?: 'info' | 'repos' | 'issues' | 'wiki' | 'files'
created_by?: string created_by?: string
updated_by?: string updated_by?: string
created_by_name?: string created_by_name?: string
@@ -170,6 +172,52 @@ export interface ProjectMember {
role: string 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> { async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(path, { const res = await fetch(path, {
credentials: 'include', credentials: 'include',
@@ -213,6 +261,7 @@ async function requestBinary(path: string, options: RequestInit = {}): Promise<A
} }
export const api = { export const api = {
oidcStatus: () => request<OIDCStatus>('/api/auth/oidc/enabled'),
login: (username: string, password: string) => login: (username: string, password: string) =>
request<User>('/api/login', { request<User>('/api/login', {
method: 'POST', method: 'POST',
@@ -220,6 +269,36 @@ export const api = {
}), }),
logout: () => request<void>('/api/logout', { method: 'POST' }), logout: () => request<void>('/api/logout', { method: 'POST' }),
me: () => request<User>('/api/me'), 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'), listUsers: () => request<User[]>('/api/users'),
createUser: (payload: { username: string; display_name: string; email: string; password: string; is_admin: boolean }) => 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 }) => 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) }), request<User>(`/api/users/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
deleteUser: (id: string) => request<void>(`/api/users/${id}`, { method: 'DELETE' }), 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) => { listProjects: (limit?: number, offset?: number, query?: string) => {
const params = new URLSearchParams() const params = new URLSearchParams()
@@ -244,13 +325,19 @@ export const api = {
return request<Repo[]>(`/api/repos${qs ? `?${qs}` : ''}`) return request<Repo[]>(`/api/repos${qs ? `?${qs}` : ''}`)
}, },
getProject: (id: string) => request<Project>(`/api/projects/${id}`), 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) }), 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) }), request<Project>(`/api/projects/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
deleteProject: (id: string) => request<void>(`/api/projects/${id}`, { method: 'DELETE' }), deleteProject: (id: string) => request<void>(`/api/projects/${id}`, { method: 'DELETE' }),
listProjectMembers: (projectId: string) => request<ProjectMember[]>(`/api/projects/${projectId}/members`), 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 }) => addProjectMember: (projectId: string, payload: { user_id: string; role: string }) =>
request<ProjectMember>(`/api/projects/${projectId}/members`, { method: 'POST', body: JSON.stringify(payload) }), request<ProjectMember>(`/api/projects/${projectId}/members`, { method: 'POST', body: JSON.stringify(payload) }),
updateProjectMember: (projectId: string, payload: { user_id: string; role: string }) => updateProjectMember: (projectId: string, payload: { user_id: string; role: string }) =>
@@ -423,6 +510,16 @@ export const api = {
if (image) params.set('image', image) if (image) params.set('image', image)
return request<{ status: string }>(`/api/repos/${repoId}/docker/image?${params.toString()}`, { method: 'DELETE' }) 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`), listIssues: (projectId: string) => request<Issue[]>(`/api/projects/${projectId}/issues`),
createIssue: (projectId: string, payload: { title: string; body: string }) => createIssue: (projectId: string, payload: { title: string; body: string }) =>

View File

@@ -58,6 +58,11 @@ export default function App() {
fontFamily fontFamily
}, },
components: { components: {
MuiDialog: {
defaultProps: {
disableRestoreFocus: true
}
},
MuiPaper: { MuiPaper: {
styleOverrides: { styleOverrides: {
root: { root: {

View File

@@ -20,6 +20,10 @@ import DashboardIcon from '@mui/icons-material/Dashboard'
import WorkspacesIcon from '@mui/icons-material/Workspaces' import WorkspacesIcon from '@mui/icons-material/Workspaces'
import StorageIcon from '@mui/icons-material/Storage' import StorageIcon from '@mui/icons-material/Storage'
import PeopleIcon from '@mui/icons-material/People' 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 DarkModeIcon from '@mui/icons-material/DarkMode'
import LightModeIcon from '@mui/icons-material/LightMode' import LightModeIcon from '@mui/icons-material/LightMode'
import { ThemeModeContext } from './ThemeModeContext' import { ThemeModeContext } from './ThemeModeContext'
@@ -46,11 +50,15 @@ export default function Layout() {
const navItems = useMemo(() => { const navItems = useMemo(() => {
const items = [ const items = [
{ label: 'Dashboard', path: '/', icon: <DashboardIcon fontSize="small" /> }, { label: 'Dashboard', path: '/', icon: <DashboardIcon fontSize="small" /> },
{ label: 'Account', path: '/account', icon: <PersonIcon fontSize="small" /> },
{ label: 'Projects', path: '/projects', icon: <WorkspacesIcon 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) { if (user?.is_admin) {
items.push({ label: 'Admin Users', path: '/admin/users', icon: <PeopleIcon fontSize="small" /> }) 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 return items
}, [user]) }, [user])

View File

@@ -4,6 +4,7 @@ import DashboardPage from '../pages/DashboardPage'
import LoginPage from '../pages/LoginPage' import LoginPage from '../pages/LoginPage'
import ProjectsPage from '../pages/ProjectsPage' import ProjectsPage from '../pages/ProjectsPage'
import GlobalReposPage from '../pages/GlobalReposPage' import GlobalReposPage from '../pages/GlobalReposPage'
import ProjectEntryPage from '../pages/ProjectEntryPage'
import ProjectHomePage from '../pages/ProjectHomePage' import ProjectHomePage from '../pages/ProjectHomePage'
import ReposPage from '../pages/ReposPage' import ReposPage from '../pages/ReposPage'
import RepoDetailPage from '../pages/RepoDetailPage' import RepoDetailPage from '../pages/RepoDetailPage'
@@ -14,6 +15,10 @@ import IssuesPage from '../pages/IssuesPage'
import WikiPage from '../pages/WikiPage' import WikiPage from '../pages/WikiPage'
import FilesPage from '../pages/FilesPage' import FilesPage from '../pages/FilesPage'
import AdminUsersPage from '../pages/AdminUsersPage' 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' import NotFoundPage from '../pages/NotFoundPage'
export const routes: RouteObject[] = [ export const routes: RouteObject[] = [
@@ -25,7 +30,10 @@ export const routes: RouteObject[] = [
{ index: true, element: <DashboardPage /> }, { index: true, element: <DashboardPage /> },
{ path: 'projects', element: <ProjectsPage /> }, { path: 'projects', element: <ProjectsPage /> },
{ path: 'repos', element: <GlobalReposPage /> }, { 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', element: <ReposPage /> },
{ path: 'projects/:projectId/repos/:repoId', element: <RepoDetailPage /> }, { path: 'projects/:projectId/repos/:repoId', element: <RepoDetailPage /> },
{ path: 'projects/:projectId/repos/:repoId/branches', element: <BranchesPage /> }, { 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/issues', element: <IssuesPage /> },
{ path: 'projects/:projectId/wiki', element: <WikiPage /> }, { path: 'projects/:projectId/wiki', element: <WikiPage /> },
{ path: 'projects/:projectId/files', element: <FilesPage /> }, { 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 /> } { path: '*', element: <NotFoundPage /> }

View File

@@ -5,6 +5,7 @@ import FolderIcon from '@mui/icons-material/Folder'
import BugReportIcon from '@mui/icons-material/BugReport' import BugReportIcon from '@mui/icons-material/BugReport'
import MenuBookIcon from '@mui/icons-material/MenuBook' import MenuBookIcon from '@mui/icons-material/MenuBook'
import AttachFileIcon from '@mui/icons-material/AttachFile' import AttachFileIcon from '@mui/icons-material/AttachFile'
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
type ProjectNavBarProps = { type ProjectNavBarProps = {
@@ -32,6 +33,14 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
]} ]}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> <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 <Button
component={Link} component={Link}
to={`/projects/${projectId}/repos`} to={`/projects/${projectId}/repos`}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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 { useEffect, useState } from 'react'
import { api, User } from '../api' import { api, User } from '../api'
export default function AdminUsersPage() { export default function AdminUsersPage() {
const [users, setUsers] = useState<User[]>([]) 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(() => { useEffect(() => {
api.listUsers().then((list) => setUsers(Array.isArray(list) ? list : [])) api.listUsers().then((list) => setUsers(Array.isArray(list) ? list : []))
}, []) }, [])
const handleCreate = async (evt: React.FormEvent<HTMLFormElement>) => { const reloadUsers = async () => {
evt.preventDefault() const list = await api.listUsers()
const data = new FormData(evt.currentTarget) setUsers(Array.isArray(list) ? list : [])
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'
} }
const resetCreateForm = () => {
setUsername('')
setDisplayName('')
setEmail('')
setPassword('')
setPasswordConfirm('')
setIsAdmin(false)
setCreateError(null)
}
const closeCreateDialog = () => {
setCreateOpen(false)
resetCreateForm()
}
const handleCreate = async (evt: React.FormEvent) => {
evt.preventDefault()
setCreateError(null)
setError(null)
const payload = {
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) const created = await api.createUser(payload)
setUsers((prev) => [...prev, created]) setUsers((prev) => [...prev, created])
evt.currentTarget.reset() 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)
}
} }
return ( return (
<Box> <Box>
<Typography variant="h5" gutterBottom> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
Admin: Users <Typography variant="h5">Admin: Users</Typography>
</Typography> <Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
New User
</Button>
</Box>
<Paper sx={{ p: 2, mb: 2 }}> <Paper sx={{ p: 2, mb: 2 }}>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<List> <List>
{users.map((u) => ( {users.map((u) => (
<ListItem key={u.id} divider> <ListItem
<ListItemText primary={u.username} secondary={u.is_admin ? 'admin' : 'user'} /> 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> </ListItem>
))} ))}
</List> </List>
</Paper> </Paper>
<Paper sx={{ p: 2 }}> <Dialog open={createOpen} onClose={closeCreateDialog} maxWidth="sm" fullWidth>
<Typography variant="subtitle1" gutterBottom> <DialogTitle>New User</DialogTitle>
Create User <DialogContent>
</Typography> {createError ? <Alert severity="error" sx={{ mb: 1 }}>{createError}</Alert> : null}
<Box component="form" onSubmit={handleCreate} sx={{ display: 'grid', gap: 1 }}> <Box component="form" onSubmit={handleCreate} sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField name="username" label="Username" /> <TextField name="username" label="Username" value={username} onChange={(event) => setUsername(event.target.value)} />
<TextField name="display_name" label="Display Name" /> <TextField
<TextField name="email" label="Email" /> name="display_name"
<TextField name="password" label="Password" type="password" /> label="Display Name"
<FormControlLabel control={<Checkbox name="is_admin" />} label="Admin" /> value={displayName}
<Button type="submit" variant="contained"> onChange={(event) => setDisplayName(event.target.value)}
Create />
<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> </Button>
</DialogActions>
</Box> </Box>
</Paper> </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>
</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> </Box>
) )
} }

View 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>
)
}

View File

@@ -1,12 +1,27 @@
import { Box, Button, Paper, TextField, Typography } from '@mui/material' 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 { useNavigate } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
export default function LoginPage() { export default function LoginPage() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [oidcEnabled, setOIDCEnabled] = useState(false)
const [oidcConfigured, setOIDCConfigured] = useState(true)
const navigate = useNavigate() 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>) => { const handleLogin = async (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault() evt.preventDefault()
const data = new FormData(evt.currentTarget) const data = new FormData(evt.currentTarget)
@@ -36,6 +51,27 @@ export default function LoginPage() {
<Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}> <Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}>
Login Login
</Button> </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> </Box>
</Paper> </Paper>
) )

View 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>
)
}

View File

@@ -1,12 +1,13 @@
import Alert from '@mui/material/Alert' 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 { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' 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 ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import ProjectNavBar from '../components/ProjectNavBar' import ProjectNavBar from '../components/ProjectNavBar'
import HeaderActionButton from '../components/HeaderActionButton' import HeaderActionButton from '../components/HeaderActionButton'
import DeleteIcon from '@mui/icons-material/Delete'
export default function ProjectHomePage() { export default function ProjectHomePage() {
const { projectId } = useParams() const { projectId } = useParams()
@@ -15,6 +16,7 @@ export default function ProjectHomePage() {
const [editSlug, setEditSlug] = useState('') const [editSlug, setEditSlug] = useState('')
const [editName, setEditName] = useState('') const [editName, setEditName] = useState('')
const [editDescription, setEditDescription] = useState('') const [editDescription, setEditDescription] = useState('')
const [editHomePage, setEditHomePage] = useState<'info' | 'repos' | 'issues' | 'wiki' | 'files'>('info')
const [editDescTab, setEditDescTab] = useState<'write' | 'preview'>('write') const [editDescTab, setEditDescTab] = useState<'write' | 'preview'>('write')
const [editError, setEditError] = useState<string | null>(null) const [editError, setEditError] = useState<string | null>(null)
const [savingEdit, setSavingEdit] = useState(false) const [savingEdit, setSavingEdit] = useState(false)
@@ -26,6 +28,15 @@ export default function ProjectHomePage() {
const [deleteError, setDeleteError] = useState<string | null>(null) const [deleteError, setDeleteError] = useState<string | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false) const [deleteLoading, setDeleteLoading] = useState(false)
const [deleting, setDeleting] = 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() const navigate = useNavigate()
useEffect(() => { useEffect(() => {
@@ -33,11 +44,34 @@ export default function ProjectHomePage() {
api.getProject(projectId).then(setProject) api.getProject(projectId).then(setProject)
}, [projectId]) }, [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 = () => { const openEdit = () => {
if (!project) return if (!project) return
setEditSlug(project.slug) setEditSlug(project.slug)
setEditName(project.name) setEditName(project.name)
setEditDescription(project.description || '') setEditDescription(project.description || '')
setEditHomePage(project.home_page || 'info')
setEditDescTab('write') setEditDescTab('write')
setEditError(null) setEditError(null)
setEditOpen(true) setEditOpen(true)
@@ -52,7 +86,8 @@ export default function ProjectHomePage() {
const payload = { const payload = {
slug: editSlug.trim(), slug: editSlug.trim(),
name: editName.trim(), name: editName.trim(),
description: editDescription.trim() description: editDescription.trim(),
home_page: editHomePage
} }
setEditError(null) setEditError(null)
setSavingEdit(true) 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 ( return (
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1, gap: 2, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1, gap: 2, flexWrap: 'wrap' }}>
@@ -201,6 +296,96 @@ export default function ProjectHomePage() {
</Typography> </Typography>
)} )}
</Paper> </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> <Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Edit Project</DialogTitle> <DialogTitle>Edit Project</DialogTitle>
<DialogContent> <DialogContent>
@@ -221,6 +406,20 @@ export default function ProjectHomePage() {
value={editName} value={editName}
onChange={(event) => setEditName(event.target.value)} 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 }}> <Box sx={{ mt: 1 }}>
<Tabs <Tabs
value={editDescTab} value={editDescTab}

View File

@@ -2,7 +2,7 @@ import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit' import EditIcon from '@mui/icons-material/Edit'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import { Autocomplete } from '@mui/material' 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 { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { api, Project, User } from '../api' import { api, Project, User } from '../api'
@@ -17,12 +17,14 @@ export default function ProjectsPage() {
const [editSlug, setEditSlug] = useState('') const [editSlug, setEditSlug] = useState('')
const [editName, setEditName] = useState('') const [editName, setEditName] = useState('')
const [editDescription, setEditDescription] = useState('') const [editDescription, setEditDescription] = useState('')
const [editHomePage, setEditHomePage] = useState<'info' | 'repos' | 'issues' | 'wiki' | 'files'>('info')
const [editDescTab, setEditDescTab] = useState<'write' | 'preview'>('write') const [editDescTab, setEditDescTab] = useState<'write' | 'preview'>('write')
const [editError, setEditError] = useState<string | null>(null) const [editError, setEditError] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [createError, setCreateError] = useState<string | null>(null) const [createError, setCreateError] = useState<string | null>(null)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [createDescription, setCreateDescription] = useState('') const [createDescription, setCreateDescription] = useState('')
const [createHomePage, setCreateHomePage] = useState<'info' | 'repos' | 'issues' | 'wiki' | 'files'>('info')
const [createDescTab, setCreateDescTab] = useState<'write' | 'preview'>('write') const [createDescTab, setCreateDescTab] = useState<'write' | 'preview'>('write')
const [savingEdit, setSavingEdit] = useState(false) const [savingEdit, setSavingEdit] = useState(false)
const [deleteProject, setDeleteProject] = useState<Project | null>(null) const [deleteProject, setDeleteProject] = useState<Project | null>(null)
@@ -74,7 +76,8 @@ export default function ProjectsPage() {
const payload = { const payload = {
slug: String(data.get('slug') || ''), slug: String(data.get('slug') || ''),
name: String(data.get('name') || ''), name: String(data.get('name') || ''),
description: createDescription description: createDescription,
home_page: createHomePage
} }
if (/\s/.test(payload.slug)) { if (/\s/.test(payload.slug)) {
setCreateError('Slug cannot contain whitespace.') setCreateError('Slug cannot contain whitespace.')
@@ -87,6 +90,7 @@ export default function ProjectsPage() {
setProjects((prev) => [...prev, created]) setProjects((prev) => [...prev, created])
form.reset() form.reset()
setCreateDescription('') setCreateDescription('')
setCreateHomePage('info')
setCreateDescTab('write') setCreateDescTab('write')
setCreateOpen(false) setCreateOpen(false)
} catch (err) { } catch (err) {
@@ -102,6 +106,7 @@ export default function ProjectsPage() {
setEditSlug(project.slug) setEditSlug(project.slug)
setEditName(project.name) setEditName(project.name)
setEditDescription(project.description || '') setEditDescription(project.description || '')
setEditHomePage(project.home_page || 'info')
setEditDescTab('write') setEditDescTab('write')
setEditError(null) setEditError(null)
} }
@@ -115,7 +120,8 @@ export default function ProjectsPage() {
const payload = { const payload = {
slug: editSlug.trim(), slug: editSlug.trim(),
name: editName.trim(), name: editName.trim(),
description: editDescription.trim() description: editDescription.trim(),
home_page: editHomePage
} }
setEditError(null) setEditError(null)
setSavingEdit(true) setSavingEdit(true)
@@ -126,6 +132,7 @@ export default function ProjectsPage() {
setEditSlug('') setEditSlug('')
setEditName('') setEditName('')
setEditDescription('') setEditDescription('')
setEditHomePage('info')
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update project' const message = err instanceof Error ? err.message : 'Failed to update project'
setEditError(message) setEditError(message)
@@ -190,6 +197,7 @@ export default function ProjectsPage() {
setCreateOpen(false) setCreateOpen(false)
setCreateError(null) setCreateError(null)
setCreateDescription('') setCreateDescription('')
setCreateHomePage('info')
setCreateDescTab('write') setCreateDescTab('write')
} }
@@ -339,6 +347,20 @@ export default function ProjectsPage() {
value={editName} value={editName}
onChange={(event) => setEditName(event.target.value)} 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 }}> <Box sx={{ mt: 1 }}>
<Tabs <Tabs
value={editDescTab} value={editDescTab}
@@ -393,6 +415,18 @@ export default function ProjectsPage() {
helperText={createError && createError.toLowerCase().includes('slug') ? createError : ''} helperText={createError && createError.toLowerCase().includes('slug') ? createError : ''}
/> />
<TextField name="name" label="Name" /> <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 }}> <Box sx={{ mt: 1 }}>
<Tabs <Tabs
value={createDescTab} value={createDescTab}

View File

@@ -22,6 +22,7 @@ import { api, DockerManifestDetail, DockerTagInfo, Project, Repo } from '../api'
import ProjectNavBar from '../components/ProjectNavBar' import ProjectNavBar from '../components/ProjectNavBar'
import RepoSubNav from '../components/RepoSubNav' import RepoSubNav from '../components/RepoSubNav'
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline' import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
type RepoDockerDetailPageProps = { type RepoDockerDetailPageProps = {
initialRepo?: Repo initialRepo?: Repo
@@ -51,6 +52,15 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
const [deleteImageConfirm, setDeleteImageConfirm] = useState('') const [deleteImageConfirm, setDeleteImageConfirm] = useState('')
const [deleteError, setDeleteError] = useState<string | null>(null) const [deleteError, setDeleteError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false) 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 initRepoRef = useRef<string | null>(null)
const initProjectRef = 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 ( return (
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
@@ -309,6 +362,20 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
> >
<DeleteOutlineIcon fontSize="small" /> <DeleteOutlineIcon fontSize="small" />
</IconButton> </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> </ListItem>
))} ))}
{!images.length && !imagesError ? ( {!images.length && !imagesError ? (
@@ -356,6 +423,18 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
> >
<DeleteOutlineIcon fontSize="small" /> <DeleteOutlineIcon fontSize="small" />
</IconButton> </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> </ListItem>
))} ))}
{!tags.length && !tagsError ? ( {!tags.length && !tagsError ? (
@@ -455,6 +534,47 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </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> </Box>
) )
} }

View File

@@ -5,7 +5,7 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
proxy: { proxy: {
'/api': 'http://localhost:1080' '^/api(?:/|$)': 'http://localhost:1080'
} }
} }
}) })