package handlers import "crypto/rand" import "database/sql" import "encoding/hex" import "errors" import "fmt" import "io" import "mime/multipart" import "net/http" import "os" import "path/filepath" import "strconv" import "strings" import "time" import "unicode" import "codit/internal/auth" import "codit/internal/config" import "codit/internal/db" import "codit/internal/docker" import "codit/internal/git" import "codit/internal/middleware" import "codit/internal/models" import "codit/internal/rpm" import "codit/internal/storage" import "codit/internal/util" type API struct { Cfg config.Config Store *db.Store Repos git.RepoManager RpmBase string RpmMeta *rpm.MetaManager RpmMirror *rpm.MirrorManager DockerBase string Uploads storage.FileStore Logger *util.Logger OnTLSListenersChanged func() OnTLSListenerRuntimeStatus func() map[string]int } type loginRequest struct { Username string `json:"username"` Password string `json:"password"` } type createUserRequest struct { Username string `json:"username"` DisplayName string `json:"display_name"` Email string `json:"email"` Password string `json:"password"` 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 updateTLSSettingsRequest struct { HTTPAddrs []string `json:"http_addrs"` HTTPSAddrs []string `json:"https_addrs"` TLSServerCertSource string `json:"tls_server_cert_source"` TLSCertFile string `json:"tls_cert_file"` TLSKeyFile string `json:"tls_key_file"` TLSPKIServerCertID string `json:"tls_pki_server_cert_id"` TLSClientAuth string `json:"tls_client_auth"` TLSClientCAFile string `json:"tls_client_ca_file"` TLSPKIClientCAID string `json:"tls_pki_client_ca_id"` TLSMinVersion string `json:"tls_min_version"` } type tlsListenerRequest struct { Name string `json:"name"` Enabled bool `json:"enabled"` HTTPAddrs []string `json:"http_addrs"` HTTPSAddrs []string `json:"https_addrs"` AuthPolicy string `json:"auth_policy"` ApplyPolicyAPI bool `json:"apply_policy_api"` ApplyPolicyGit bool `json:"apply_policy_git"` ApplyPolicyRPM bool `json:"apply_policy_rpm"` ApplyPolicyV2 bool `json:"apply_policy_v2"` ClientCertAllowlist []string `json:"client_cert_allowlist"` TLSServerCertSource string `json:"tls_server_cert_source"` TLSCertFile string `json:"tls_cert_file"` TLSKeyFile string `json:"tls_key_file"` TLSPKIServerCertID string `json:"tls_pki_server_cert_id"` TLSClientAuth string `json:"tls_client_auth"` TLSClientCAFile string `json:"tls_client_ca_file"` TLSPKIClientCAID string `json:"tls_pki_client_ca_id"` TLSMinVersion string `json:"tls_min_version"` } type servicePrincipalRequest struct { Name string `json:"name"` Description string `json:"description"` IsAdmin bool `json:"is_admin"` Disabled bool `json:"disabled"` } type certPrincipalBindingRequest struct { Fingerprint string `json:"fingerprint"` PrincipalID string `json:"principal_id"` Enabled bool `json:"enabled"` } type principalProjectRoleRequest struct { ProjectID string `json:"project_id"` Role string `json:"role"` } type createProjectRequest struct { Slug string `json:"slug"` Name string `json:"name"` Description string `json:"description"` HomePage string `json:"home_page"` } type createRepoRequest struct { Name string `json:"name"` Type string `json:"type"` } type updateRepoRequest struct { Name string `json:"name"` } type attachRepoRequest struct { RepoID string `json:"repo_id"` } type repoResponse struct { models.Repo CloneURL string `json:"clone_url"` RPMURL string `json:"rpm_url"` DockerURL string `json:"docker_url"` } type repoListItem struct { models.Repo CloneURL string `json:"clone_url"` RPMURL string `json:"rpm_url"` DockerURL string `json:"docker_url"` OwnerProject string `json:"owner_project"` OwnerSlug string `json:"owner_slug"` } type availableRepoItem struct { models.Repo OwnerProject string `json:"owner_project"` OwnerSlug string `json:"owner_slug"` } type repoStatsResponse struct { RepoID string `json:"repo_id"` Branches int `json:"branches"` DefaultRef string `json:"default_ref"` LastCommit string `json:"last_commit"` LastAuthor string `json:"last_author"` LastWhen string `json:"last_when"` LastMessage string `json:"last_message"` CloneURL string `json:"clone_url"` ProjectSlug string `json:"project_slug"` Repository string `json:"repository"` } type repoBranchesInfo struct { Default string `json:"default"` Branches []git.BranchInfo `json:"branches"` } type repoTypeItem struct { Value string `json:"value"` Label string `json:"label"` } type repoBranchRequest struct { Name string `json:"name"` } type repoBranchRenameRequest struct { From string `json:"from"` To string `json:"to"` } type repoBranchCreateRequest struct { Name string `json:"name"` From string `json:"from"` } type repoRPMCreateRequest struct { Name string `json:"name"` Type string `json:"type"` Parent string `json:"parent"` Mode string `json:"mode"` AllowDelete bool `json:"allow_delete"` RemoteURL string `json:"remote_url"` ConnectHost string `json:"connect_host"` HostHeader string `json:"host_header"` TLSServerName string `json:"tls_server_name"` TLSInsecureSkipVerify bool `json:"tls_insecure_skip_verify"` SyncIntervalSec int64 `json:"sync_interval_sec"` } type repoRPMUpdateRequest struct { Path *string `json:"path"` Name *string `json:"name"` Mode *string `json:"mode"` AllowDelete *bool `json:"allow_delete"` RemoteURL *string `json:"remote_url"` ConnectHost *string `json:"connect_host"` HostHeader *string `json:"host_header"` TLSServerName *string `json:"tls_server_name"` TLSInsecureSkipVerify *bool `json:"tls_insecure_skip_verify"` SyncIntervalSec *int64 `json:"sync_interval_sec"` } type createAPIKeyRequest struct { Name string `json:"name"` ExpiresAt int64 `json:"expires_at"` } type apiKeyResponse struct { models.APIKey Token string `json:"token,omitempty"` } type repoDockerRenameTagRequest struct { Image string `json:"image"` From string `json:"from"` To string `json:"to"` } type repoDockerRenameImageRequest struct { From string `json:"from"` To string `json:"to"` } type repoRPMUploadResponse struct { Filename string `json:"filename"` Size int64 `json:"size"` } type createIssueRequest struct { Title string `json:"title"` Body string `json:"body"` AssigneeID string `json:"assignee_id"` } type updateIssueRequest struct { Title string `json:"title"` Body string `json:"body"` Status string `json:"status"` AssigneeID string `json:"assignee_id"` } type createWikiRequest struct { Title string `json:"title"` Slug string `json:"slug"` Body string `json:"body"` } type updateWikiRequest struct { Title string `json:"title"` Body string `json:"body"` } type issueCommentRequest struct { Body string `json:"body"` } type projectMemberRequest struct { UserID string `json:"user_id"` Role string `json:"role"` } func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]string) { var req loginRequest var err error var user models.User var storedHash string var ldapUser auth.LDAPUser var newUser models.User var created models.User var authCfg config.Config err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.Username == "" || req.Password == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "username and password required"}) return } user, storedHash, err = api.Store.GetUserByUsername(req.Username) if err == nil && user.Disabled { WriteJSON(w, http.StatusForbidden, map[string]string{"error": "user disabled"}) return } if err == nil && storedHash != "" && auth.ComparePassword(storedHash, req.Password) == nil { api.issueSession(w, user) WriteJSON(w, http.StatusOK, user) return } authCfg, _ = api.effectiveAuthConfig() if authCfg.AuthMode == "ldap" || authCfg.AuthMode == "hybrid" { if api.Logger != nil { api.Logger.Write("auth", util.LOG_INFO, "ldap login attempt username=%s mode=%s", req.Username, authCfg.AuthMode) } ldapUser, err = auth.LDAPAuthenticateContext(r.Context(), authCfg, req.Username, req.Password) if err == nil { if api.Logger != nil { api.Logger.Write("auth", util.LOG_INFO, "ldap login success username=%s", req.Username) } if user.ID != "" && user.Disabled { WriteJSON(w, http.StatusForbidden, map[string]string{"error": "user disabled"}) return } if user.ID == "" { newUser = models.User{ Username: ldapUser.Username, DisplayName: ldapUser.DisplayName, Email: ldapUser.Email, AuthSource: "ldap", } if newUser.Email == "" { newUser.Email = ldapUser.Username + "@local" } created, err = api.Store.CreateUser(newUser, "") if err == nil { user = created } } api.issueSession(w, user) WriteJSON(w, http.StatusOK, user) return } if api.Logger != nil { api.Logger.Write("auth", util.LOG_WARN, "ldap login failed username=%s err=%v", req.Username, err) } } WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) } func (api *API) issueSession(w http.ResponseWriter, user models.User) { var token string var err error var expires time.Time var cookie *http.Cookie token, err = auth.NewSessionToken() if err != nil { return } expires = auth.SessionExpiry(api.Cfg) _ = api.Store.CreateSession(user.ID, token, expires) cookie = &http.Cookie{ Name: "codit_session", Value: token, HttpOnly: true, Path: "/", Expires: expires, } http.SetCookie(w, cookie) } func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]string) { var cookie *http.Cookie var err error cookie, err = r.Cookie("codit_session") if err == nil { _ = api.Store.DeleteSession(cookie.Value) } cookie = &http.Cookie{ Name: "codit_session", Value: "", Path: "/", Expires: time.Unix(0, 0), HttpOnly: true, } http.SetCookie(w, cookie) w.WriteHeader(http.StatusNoContent) } func (api *API) Me(w http.ResponseWriter, r *http.Request, _ map[string]string) { var user models.User var ok bool user, ok = middleware.UserFromContext(r.Context()) if !ok { w.WriteHeader(http.StatusUnauthorized) return } WriteJSON(w, http.StatusOK, user) } func (api *API) UpdateMe(w http.ResponseWriter, r *http.Request, _ map[string]string) { var ctxUser models.User var ok bool var user models.User var req updateMeRequest var err error var hash string var newPassword bool ctxUser, ok = middleware.UserFromContext(r.Context()) if !ok { w.WriteHeader(http.StatusUnauthorized) return } user, err = api.Store.GetUserByID(ctxUser.ID) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"}) return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.DisplayName != "" { user.DisplayName = req.DisplayName } if req.Email != "" { user.Email = req.Email } newPassword = strings.TrimSpace(req.Password) != "" if newPassword && user.AuthSource != "db" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "password update not supported for non-db users"}) return } if newPassword { hash, err = auth.HashPassword(req.Password) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to hash password"}) return } err = api.Store.UpdateUserWithPassword(user, hash) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } } else { err = api.Store.UpdateUser(user) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } } WriteJSON(w, http.StatusOK, user) } func (api *API) ListUsers(w http.ResponseWriter, r *http.Request, _ map[string]string) { var users []models.User var err error if !api.requireAdmin(w, r) { return } users, err = api.Store.ListUsers() if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, users) } func (api *API) CreateUser(w http.ResponseWriter, r *http.Request, _ map[string]string) { var req createUserRequest var err error var hash string var user models.User var created models.User if !api.requireAdmin(w, r) { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.Username == "" || req.Password == "" || req.DisplayName == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "username, display_name, and password required"}) return } hash, err = auth.HashPassword(req.Password) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to hash password"}) return } user = models.User{ Username: req.Username, DisplayName: req.DisplayName, Email: req.Email, IsAdmin: req.IsAdmin, AuthSource: "db", } created, err = api.Store.CreateUser(user, hash) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusCreated, created) } func (api *API) UpdateUser(w http.ResponseWriter, r *http.Request, params map[string]string) { var user models.User var err error var payload createUserRequest var hash string var newPassword bool if !api.requireAdmin(w, r) { return } user, err = api.Store.GetUserByID(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"}) return } err = DecodeJSON(r, &payload) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if payload.DisplayName != "" { user.DisplayName = payload.DisplayName } if payload.Email != "" { user.Email = payload.Email } user.IsAdmin = payload.IsAdmin 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 { 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) DeleteUser(w http.ResponseWriter, r *http.Request, params map[string]string) { var err error if !api.requireAdmin(w, r) { return } err = api.Store.DeleteUser(params["id"]) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } 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) GetTLSSettings(w http.ResponseWriter, r *http.Request, _ map[string]string) { var settings models.TLSSettings var err error if !api.requireAdmin(w, r) { return } settings, err = api.getMergedTLSSettings() if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, settings) } func (api *API) UpdateTLSSettings(w http.ResponseWriter, r *http.Request, _ map[string]string) { var req updateTLSSettingsRequest var settings models.TLSSettings var err error if !api.requireAdmin(w, r) { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } settings = models.TLSSettings{ HTTPAddrs: normalizeAddrList(req.HTTPAddrs), HTTPSAddrs: normalizeAddrList(req.HTTPSAddrs), TLSServerCertSource: "pki", TLSCertFile: "", TLSKeyFile: "", TLSPKIServerCertID: strings.TrimSpace(req.TLSPKIServerCertID), TLSClientAuth: normalizeTLSClientAuth(req.TLSClientAuth), TLSClientCAFile: "", TLSPKIClientCAID: strings.TrimSpace(req.TLSPKIClientCAID), TLSMinVersion: normalizeTLSMinVersion(req.TLSMinVersion), } if len(settings.HTTPAddrs) == 0 && len(settings.HTTPSAddrs) == 0 { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one of http_addrs or https_addrs is required"}) return } if len(settings.HTTPSAddrs) > 0 && strings.TrimSpace(settings.TLSPKIServerCertID) == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tls_pki_server_cert_id is required when https_addrs is configured"}) return } if tlsClientAuthNeedsCA(settings.TLSClientAuth) && !tlsClientCAConfigured(settings.TLSPKIClientCAID) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tls_pki_client_ca_id is required for selected tls_client_auth"}) return } err = api.Store.SetTLSSettings(settings) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, settings) } func (api *API) ListTLSListeners(w http.ResponseWriter, r *http.Request, _ map[string]string) { var items []models.TLSListener var err error if !api.requireAdmin(w, r) { return } items, err = api.Store.ListTLSListeners() if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, items) } func (api *API) CreateTLSListener(w http.ResponseWriter, r *http.Request, _ map[string]string) { var req tlsListenerRequest var item models.TLSListener var created models.TLSListener var err error if !api.requireAdmin(w, r) { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } item = normalizeTLSListenerRequest(req) if item.Name == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) return } if len(item.HTTPAddrs) == 0 && len(item.HTTPSAddrs) == 0 { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one of http_addrs or https_addrs is required"}) return } if len(item.HTTPSAddrs) > 0 && strings.TrimSpace(item.TLSPKIServerCertID) == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tls_pki_server_cert_id is required when https_addrs is configured"}) return } if tlsClientAuthNeedsCA(item.TLSClientAuth) && !tlsClientCAConfigured(item.TLSPKIClientCAID) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tls_pki_client_ca_id is required for selected tls_client_auth"}) return } created, err = api.Store.CreateTLSListener(item) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } if api.OnTLSListenersChanged != nil { api.OnTLSListenersChanged() } WriteJSON(w, http.StatusCreated, created) } func (api *API) UpdateTLSListener(w http.ResponseWriter, r *http.Request, params map[string]string) { var req tlsListenerRequest var item models.TLSListener var err error if !api.requireAdmin(w, r) { return } item, err = api.Store.GetTLSListener(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "listener not found"}) return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } item = mergeTLSListener(item, normalizeTLSListenerRequest(req)) if item.Name == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) return } if len(item.HTTPAddrs) == 0 && len(item.HTTPSAddrs) == 0 { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one of http_addrs or https_addrs is required"}) return } if len(item.HTTPSAddrs) > 0 && strings.TrimSpace(item.TLSPKIServerCertID) == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tls_pki_server_cert_id is required when https_addrs is configured"}) return } if tlsClientAuthNeedsCA(item.TLSClientAuth) && !tlsClientCAConfigured(item.TLSPKIClientCAID) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tls_pki_client_ca_id is required for selected tls_client_auth"}) return } err = api.Store.UpdateTLSListener(item) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } if api.OnTLSListenersChanged != nil { api.OnTLSListenersChanged() } WriteJSON(w, http.StatusOK, item) } func (api *API) DeleteTLSListener(w http.ResponseWriter, r *http.Request, params map[string]string) { var err error if !api.requireAdmin(w, r) { return } err = api.Store.DeleteTLSListener(params["id"]) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if api.OnTLSListenersChanged != nil { api.OnTLSListenersChanged() } w.WriteHeader(http.StatusNoContent) } func (api *API) GetTLSListenerRuntimeStatus(w http.ResponseWriter, r *http.Request, _ map[string]string) { var status map[string]int if !api.requireAdmin(w, r) { return } status = make(map[string]int) if api.OnTLSListenerRuntimeStatus != nil { status = api.OnTLSListenerRuntimeStatus() } WriteJSON(w, http.StatusOK, status) } func (api *API) ListServicePrincipals(w http.ResponseWriter, r *http.Request, _ map[string]string) { var items []models.ServicePrincipal var err error if !api.requireAdmin(w, r) { return } items, err = api.Store.ListServicePrincipals() if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, items) } func (api *API) CreateServicePrincipal(w http.ResponseWriter, r *http.Request, _ map[string]string) { var req servicePrincipalRequest var item models.ServicePrincipal var created models.ServicePrincipal var err error if !api.requireAdmin(w, r) { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } item = models.ServicePrincipal{ Name: strings.TrimSpace(req.Name), Description: strings.TrimSpace(req.Description), IsAdmin: req.IsAdmin, Disabled: req.Disabled, } if item.Name == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) return } created, err = api.Store.CreateServicePrincipal(item) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusCreated, created) } func (api *API) UpdateServicePrincipal(w http.ResponseWriter, r *http.Request, params map[string]string) { var req servicePrincipalRequest var item models.ServicePrincipal var err error if !api.requireAdmin(w, r) { return } item, err = api.Store.GetServicePrincipal(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "principal not found"}) return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } item.Name = strings.TrimSpace(req.Name) item.Description = strings.TrimSpace(req.Description) item.IsAdmin = req.IsAdmin item.Disabled = req.Disabled if item.Name == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) return } err = api.Store.UpdateServicePrincipal(item) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, item) } func (api *API) DeleteServicePrincipal(w http.ResponseWriter, r *http.Request, params map[string]string) { var err error if !api.requireAdmin(w, r) { return } err = api.Store.DeleteServicePrincipal(params["id"]) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) } func (api *API) ListCertPrincipalBindings(w http.ResponseWriter, r *http.Request, _ map[string]string) { var items []models.CertPrincipalBinding var err error if !api.requireAdmin(w, r) { return } items, err = api.Store.ListCertPrincipalBindings() if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, items) } func (api *API) UpsertCertPrincipalBinding(w http.ResponseWriter, r *http.Request, _ map[string]string) { var req certPrincipalBindingRequest var item models.CertPrincipalBinding var updated models.CertPrincipalBinding var err error if !api.requireAdmin(w, r) { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } item = models.CertPrincipalBinding{ Fingerprint: strings.ToLower(strings.TrimSpace(req.Fingerprint)), PrincipalID: strings.TrimSpace(req.PrincipalID), Enabled: req.Enabled, } if item.Fingerprint == "" || item.PrincipalID == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "fingerprint and principal_id are required"}) return } updated, err = api.Store.UpsertCertPrincipalBinding(item) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, updated) } func (api *API) DeleteCertPrincipalBinding(w http.ResponseWriter, r *http.Request, params map[string]string) { var err error if !api.requireAdmin(w, r) { return } err = api.Store.DeleteCertPrincipalBinding(params["fingerprint"]) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) } func (api *API) ListPrincipalProjectRoles(w http.ResponseWriter, r *http.Request, params map[string]string) { var items []models.PrincipalProjectRole var err error if !api.requireAdmin(w, r) { return } items, err = api.Store.ListPrincipalProjectRoles(params["id"]) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, items) } func (api *API) UpsertPrincipalProjectRole(w http.ResponseWriter, r *http.Request, params map[string]string) { var req principalProjectRoleRequest var item models.PrincipalProjectRole var saved models.PrincipalProjectRole var err error if !api.requireAdmin(w, r) { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } item = models.PrincipalProjectRole{ PrincipalID: strings.TrimSpace(params["id"]), ProjectID: strings.TrimSpace(req.ProjectID), Role: normalizeRole(req.Role), } if item.PrincipalID == "" || item.ProjectID == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "principal id and project id are required"}) return } saved, err = api.Store.UpsertPrincipalProjectRole(item) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, saved) } func (api *API) DeletePrincipalProjectRole(w http.ResponseWriter, r *http.Request, params map[string]string) { var err error if !api.requireAdmin(w, r) { return } err = api.Store.DeletePrincipalProjectRole(params["id"], params["projectId"]) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) } func (api *API) ListProjects(w http.ResponseWriter, r *http.Request, _ map[string]string) { var projects []models.Project var err error var limit int var offset int var query string var v string var i int var user models.User var ok bool limit = 0 offset = 0 query = strings.TrimSpace(r.URL.Query().Get("q")) user, ok = middleware.UserFromContext(r.Context()) if !ok { w.WriteHeader(http.StatusUnauthorized) return } v = r.URL.Query().Get("limit") if v != "" { i, err = strconv.Atoi(v) if err == nil && i > 0 { limit = i } } v = r.URL.Query().Get("offset") if v != "" { i, err = strconv.Atoi(v) if err == nil && i >= 0 { offset = i } } if user.IsAdmin { if query != "" || limit > 0 || offset > 0 { projects, err = api.Store.ListProjectsFiltered(limit, offset, query) } else { 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 { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, projects) } func (api *API) GetProject(w http.ResponseWriter, r *http.Request, params map[string]string) { var project models.Project var err error project, err = api.Store.GetProject(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) return } if !api.requireProjectRole(w, r, project.ID, "viewer") { return } WriteJSON(w, http.StatusOK, project) } func (api *API) CreateProject(w http.ResponseWriter, r *http.Request, _ map[string]string) { var req createProjectRequest var err error var user models.User var project models.Project var created models.Project err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.Slug == "" || req.Name == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "slug and name required"}) return } if slugHasWhitespace(req.Slug) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "slug cannot contain whitespace"}) return } user, _ = middleware.UserFromContext(r.Context()) project = models.Project{ Slug: req.Slug, Name: req.Name, Description: req.Description, HomePage: normalizeProjectHomePage(req.HomePage), CreatedBy: user.ID, UpdatedBy: user.ID, } created, err = api.Store.CreateProject(project) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusCreated, created) } func (api *API) UpdateProject(w http.ResponseWriter, r *http.Request, params map[string]string) { var project models.Project var err error var req createProjectRequest var user models.User if !api.requireProjectRole(w, r, params["id"], "admin") { return } project, err = api.Store.GetProject(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.Name != "" { project.Name = req.Name } if req.Slug != "" { if slugHasWhitespace(req.Slug) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "slug cannot contain whitespace"}) return } project.Slug = req.Slug } project.Description = req.Description if req.HomePage != "" { project.HomePage = normalizeProjectHomePage(req.HomePage) } if project.HomePage == "" { project.HomePage = "info" } user, _ = middleware.UserFromContext(r.Context()) project.UpdatedBy = user.ID project.UpdatedAt = time.Now().UTC().Unix() err = api.Store.UpdateProject(project) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, project) } func (api *API) DeleteProject(w http.ResponseWriter, r *http.Request, params map[string]string) { var err error var repos []models.Repo var running bool var i int var tempPaths []string var sourcePaths []string var temp string if !api.requireProjectRole(w, r, params["id"], "admin") { return } repos, err = api.Store.ListReposOwned(params["id"]) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } for i = 0; i < len(repos); i++ { if repos[i].Type != "rpm" { continue } running, err = api.Store.HasRunningRPMMirrorTask(repos[i].ID) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if running { WriteJSON(w, http.StatusConflict, map[string]string{"error": "cannot delete project while rpm mirror sync is running", "repo_id": repos[i].ID, "repo_name": repos[i].Name}) return } } tempPaths = make([]string, 0, len(repos)) sourcePaths = make([]string, 0, len(repos)) for i = 0; i < len(repos); i++ { if repos[i].Path == "" { continue } temp, err = moveToTrash(repos[i].Path) if err != nil { restoreFromTrash(tempPaths, sourcePaths) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } tempPaths = append(tempPaths, temp) sourcePaths = append(sourcePaths, repos[i].Path) } err = api.Store.DeleteProject(params["id"]) if err != nil { restoreFromTrash(tempPaths, sourcePaths) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } removeTrash(tempPaths) w.WriteHeader(http.StatusNoContent) } func (api *API) ListProjectMembers(w http.ResponseWriter, r *http.Request, params map[string]string) { var members []models.ProjectMember var err error if !api.requireProjectRole(w, r, params["projectId"], "viewer") { return } members, err = api.Store.ListProjectMembers(params["projectId"]) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, members) } func (api *API) AddProjectMember(w http.ResponseWriter, r *http.Request, params map[string]string) { var req projectMemberRequest var err error var member models.ProjectMember if !api.requireProjectRole(w, r, params["projectId"], "admin") { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.UserID == "" || req.Role == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id and role required"}) return } member, err = api.Store.AddProjectMember(params["projectId"], req.UserID, normalizeRole(req.Role)) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusCreated, member) } func (api *API) UpdateProjectMember(w http.ResponseWriter, r *http.Request, params map[string]string) { var req projectMemberRequest var err error if !api.requireProjectRole(w, r, params["projectId"], "admin") { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.UserID == "" || req.Role == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id and role required"}) return } err = api.Store.UpdateProjectMemberRole(params["projectId"], req.UserID, normalizeRole(req.Role)) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) } func (api *API) RemoveProjectMember(w http.ResponseWriter, r *http.Request, params map[string]string) { var err error if !api.requireProjectRole(w, r, params["projectId"], "admin") { return } err = api.Store.RemoveProjectMember(params["projectId"], params["userId"]) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) } func (api *API) ListProjectMemberCandidates(w http.ResponseWriter, r *http.Request, params map[string]string) { var users []models.User var err error var query string var filtered []models.User var i int var username string var displayName string var email string if !api.requireProjectRole(w, r, params["projectId"], "admin") { return } users, err = api.Store.ListUsers() if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } query = strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) if query == "" { WriteJSON(w, http.StatusOK, users) return } filtered = make([]models.User, 0, len(users)) for i = 0; i < len(users); i++ { username = strings.ToLower(users[i].Username) displayName = strings.ToLower(users[i].DisplayName) email = strings.ToLower(users[i].Email) if strings.Contains(username, query) || strings.Contains(displayName, query) || strings.Contains(email, query) { filtered = append(filtered, users[i]) } } WriteJSON(w, http.StatusOK, filtered) } func (api *API) ListRepos(w http.ResponseWriter, r *http.Request, params map[string]string) { var err error var repos []models.Repo var resp []repoListItem var i int var repo models.Repo var cache map[string]models.Project var owner models.Project var ok bool if !api.requireProjectRole(w, r, params["projectId"], "viewer") { return } repos, err = api.Store.ListRepos(params["projectId"]) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } resp = make([]repoListItem, 0, len(repos)) cache = map[string]models.Project{} for i = 0; i < len(repos); i++ { repo = repos[i] owner, ok = cache[repo.ProjectID] if !ok { owner, err = api.Store.GetProject(repo.ProjectID) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) return } cache[repo.ProjectID] = owner } var cloneURL string var rpmURL string var dockerURL string if repo.Type == "git" { cloneURL = api.cloneURL(owner.Slug, repo.Name) } if repo.Type == "rpm" { rpmURL = api.rpmURL(owner.Slug, repo.Name) } if repo.Type == "docker" { dockerURL = api.dockerURL(owner.Slug, repo.Name) } resp = append(resp, repoListItem{ Repo: repo, CloneURL: cloneURL, RPMURL: rpmURL, DockerURL: dockerURL, OwnerProject: owner.ID, OwnerSlug: owner.Slug, }) } WriteJSON(w, http.StatusOK, resp) } func (api *API) ListAllRepos(w http.ResponseWriter, r *http.Request, _ map[string]string) { var err error var repos []models.Repo var resp []repoListItem var i int var repo models.Repo var cache map[string]models.Project var owner models.Project var ok bool var user models.User var projectIDs []string var query string var repoType string var filtered []models.Repo var okUser bool user, okUser = middleware.UserFromContext(r.Context()) if !okUser { w.WriteHeader(http.StatusUnauthorized) return } if user.IsAdmin { repos, err = api.Store.ListAllRepos() } else { projectIDs, err = api.Store.ListProjectIDsForUser(user.ID) if err == nil { repos, err = api.Store.ListReposByProjectIDs(projectIDs) } } if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } query = strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) repoType = strings.ToLower(strings.TrimSpace(r.URL.Query().Get("type"))) if query != "" || (repoType != "" && repoType != "all") { filtered = make([]models.Repo, 0, len(repos)) for i = 0; i < len(repos); i++ { repo = repos[i] if repoType != "" && repoType != "all" && repo.Type != repoType { continue } if query != "" { if !strings.Contains(strings.ToLower(repo.Name), query) && !strings.Contains(strings.ToLower(repo.ID), query) { continue } } filtered = append(filtered, repo) } repos = filtered } resp = make([]repoListItem, 0, len(repos)) cache = map[string]models.Project{} for i = 0; i < len(repos); i++ { repo = repos[i] owner, ok = cache[repo.ProjectID] if !ok { owner, err = api.Store.GetProject(repo.ProjectID) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) return } cache[repo.ProjectID] = owner } var cloneURL string var rpmURL string var dockerURL string if repo.Type == "git" { cloneURL = api.cloneURL(owner.Slug, repo.Name) } if repo.Type == "rpm" { rpmURL = api.rpmURL(owner.Slug, repo.Name) } if repo.Type == "docker" { dockerURL = api.dockerURL(owner.Slug, repo.Name) } resp = append(resp, repoListItem{ Repo: repo, CloneURL: cloneURL, RPMURL: rpmURL, DockerURL: dockerURL, OwnerProject: owner.ID, OwnerSlug: owner.Slug, }) } WriteJSON(w, http.StatusOK, resp) } func (api *API) GetRepo(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var project models.Project repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } project, err = api.Store.GetProject(repo.ProjectID) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) return } var cloneURL string var rpmURL string var dockerURL string if repo.Type == "git" { cloneURL = api.cloneURL(project.Slug, repo.Name) } if repo.Type == "rpm" { rpmURL = api.rpmURL(project.Slug, repo.Name) } if repo.Type == "docker" { dockerURL = api.dockerURL(project.Slug, repo.Name) } WriteJSON(w, http.StatusOK, repoResponse{Repo: repo, CloneURL: cloneURL, RPMURL: rpmURL, DockerURL: dockerURL}) } func (api *API) CreateRepo(w http.ResponseWriter, r *http.Request, params map[string]string) { var req createRepoRequest var err error var user models.User var repoID string var project models.Project var repoPath string var repo models.Repo var created models.Repo var repoType string var projectStorageID int64 var repoStorageID int64 var ok bool var exists bool if !api.requireProjectRole(w, r, params["projectId"], "writer") { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.Name == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name required"}) return } if nameHasWhitespace(req.Name) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name cannot contain whitespace"}) return } repoType, ok = normalizeRepoType(req.Type) if !ok { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported repo type"}) return } exists, err = api.Store.RepoNameExists(params["projectId"], req.Name, repoType) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if exists { WriteJSON(w, http.StatusConflict, map[string]string{"error": "repository name already exists"}) return } user, _ = middleware.UserFromContext(r.Context()) repoID, err = util.NewID() if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create repo"}) return } project, err = api.Store.GetProject(params["projectId"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) return } repo = models.Repo{ ID: repoID, ProjectID: params["projectId"], Name: req.Name, Type: repoType, Path: "", CreatedBy: user.ID, } created, err = api.Store.CreateRepo(repo) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } projectStorageID, repoStorageID, err = api.Store.GetRepoStorageIDs(created.ID) if err != nil { _ = api.Store.DeleteRepo(created.ID) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } repoPath = api.repoStoragePathByType(created.Type, projectStorageID, repoStorageID) if created.Type == "git" { err = os.MkdirAll(filepath.Dir(repoPath), 0o755) if err == nil { err = api.Repos.InitRepo(repoPath, true) } } else { err = os.MkdirAll(repoPath, 0o755) } if err != nil { _ = api.Store.DeleteRepo(created.ID) _ = os.RemoveAll(repoPath) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } created.Path = repoPath err = api.Store.UpdateRepo(created) if err != nil { _ = api.Store.DeleteRepo(created.ID) _ = os.RemoveAll(repoPath) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } var cloneURL string var rpmURL string var dockerURL string if created.Type == "git" { cloneURL = api.cloneURL(project.Slug, created.Name) } if created.Type == "rpm" { rpmURL = api.rpmURL(project.Slug, created.Name) } if created.Type == "docker" { dockerURL = api.dockerURL(project.Slug, created.Name) } WriteJSON(w, http.StatusCreated, repoResponse{Repo: created, CloneURL: cloneURL, RPMURL: rpmURL, DockerURL: dockerURL}) } func (api *API) ListAvailableRepos(w http.ResponseWriter, r *http.Request, params map[string]string) { var repos []models.Repo var err error var query string var limit int var offset int var v string var i int var resp []availableRepoItem var repo models.Repo var cache map[string]models.Project var owner models.Project var ok bool if !api.requireProjectRole(w, r, params["projectId"], "admin") { return } query = strings.TrimSpace(r.URL.Query().Get("q")) limit = 0 offset = 0 v = r.URL.Query().Get("limit") if v != "" { i, err = strconv.Atoi(v) if err == nil && i > 0 { limit = i } } v = r.URL.Query().Get("offset") if v != "" { i, err = strconv.Atoi(v) if err == nil && i >= 0 { offset = i } } repos, err = api.Store.ListAvailableReposForProject(params["projectId"], query, limit, offset) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } resp = make([]availableRepoItem, 0, len(repos)) cache = map[string]models.Project{} for i = 0; i < len(repos); i++ { repo = repos[i] owner, ok = cache[repo.ProjectID] if !ok { owner, err = api.Store.GetProject(repo.ProjectID) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) return } cache[repo.ProjectID] = owner } resp = append(resp, availableRepoItem{ Repo: repo, OwnerProject: owner.ID, OwnerSlug: owner.Slug, }) } WriteJSON(w, http.StatusOK, resp) } func (api *API) AttachForeignRepo(w http.ResponseWriter, r *http.Request, params map[string]string) { var req attachRepoRequest var err error var repo models.Repo var project models.Project if !api.requireProjectRole(w, r, params["projectId"], "admin") { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.RepoID == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo_id required"}) return } repo, err = api.getRepoResolved(req.RepoID) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if repo.ProjectID == params["projectId"] { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo already belongs to this project"}) return } project, err = api.Store.GetProject(repo.ProjectID) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) return } err = api.Store.AttachRepoToProject(params["projectId"], repo.ID) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } repo.IsForeign = true var cloneURL string var rpmURL string var dockerURL string if repo.Type == "git" { cloneURL = api.cloneURL(project.Slug, repo.Name) } if repo.Type == "rpm" { rpmURL = api.rpmURL(project.Slug, repo.Name) } if repo.Type == "docker" { dockerURL = api.dockerURL(project.Slug, repo.Name) } WriteJSON(w, http.StatusCreated, repoListItem{ Repo: repo, CloneURL: cloneURL, RPMURL: rpmURL, DockerURL: dockerURL, OwnerProject: project.ID, OwnerSlug: project.Slug, }) } func (api *API) DetachForeignRepo(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error if !api.requireProjectRole(w, r, params["projectId"], "admin") { return } repo, err = api.getRepoResolved(params["repoId"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if repo.ProjectID == params["projectId"] { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot detach owned repo"}) return } err = api.Store.DetachRepoFromProject(params["projectId"], repo.ID) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) } func (api *API) UpdateRepo(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var project models.Project var req updateRepoRequest var err error var newPath string var projectStorageID int64 var repoStorageID int64 repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireProjectRole(w, r, repo.ProjectID, "writer") { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if strings.TrimSpace(req.Name) == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name required"}) return } if nameHasWhitespace(req.Name) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name cannot contain whitespace"}) return } project, err = api.Store.GetProject(repo.ProjectID) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) return } projectStorageID, repoStorageID, err = api.Store.GetRepoStorageIDs(repo.ID) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } newPath = api.repoStoragePathByType(repo.Type, projectStorageID, repoStorageID) repo.Name = req.Name repo.Path = newPath err = api.Store.UpdateRepo(repo) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } var cloneURL string var rpmURL string var dockerURL string if repo.Type == "git" { cloneURL = api.cloneURL(project.Slug, repo.Name) } if repo.Type == "rpm" { rpmURL = api.rpmURL(project.Slug, repo.Name) } if repo.Type == "docker" { dockerURL = api.dockerURL(project.Slug, repo.Name) } WriteJSON(w, http.StatusOK, repoResponse{Repo: repo, CloneURL: cloneURL, RPMURL: rpmURL, DockerURL: dockerURL}) } func (api *API) DeleteRepo(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var project models.Project var running bool var err error var temp string repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireProjectRole(w, r, repo.ProjectID, "writer") { return } if repo.Type == "rpm" { running, err = api.Store.HasRunningRPMMirrorTask(repo.ID) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if running { WriteJSON(w, http.StatusConflict, map[string]string{"error": "cannot delete repository while rpm mirror sync is running"}) return } } project, err = api.Store.GetProject(repo.ProjectID) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) return } if repo.Path != "" { temp, err = moveToTrash(repo.Path) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } } err = api.Store.DeleteRepo(repo.ID) if err != nil { if temp != "" && repo.Path != "" { _ = os.Rename(temp, repo.Path) } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if temp != "" { _ = os.RemoveAll(temp) } var cloneURL string var rpmURL string var dockerURL string if repo.Type == "git" { cloneURL = api.cloneURL(project.Slug, repo.Name) } if repo.Type == "rpm" { rpmURL = api.rpmURL(project.Slug, repo.Name) } if repo.Type == "docker" { dockerURL = api.dockerURL(project.Slug, repo.Name) } WriteJSON(w, http.StatusOK, repoResponse{Repo: repo, CloneURL: cloneURL, RPMURL: rpmURL, DockerURL: dockerURL}) } func moveToTrash(path string) (string, error) { var dir string var base string var temp string var err error dir = filepath.Dir(path) base = filepath.Base(path) temp = filepath.Join(dir, base+"__deleted__"+strconv.FormatInt(time.Now().UnixNano(), 10)) err = os.Rename(path, temp) if err != nil { return "", err } return temp, nil } func restoreFromTrash(tempPaths []string, sourcePaths []string) { var i int if len(tempPaths) != len(sourcePaths) { return } for i = len(tempPaths) - 1; i >= 0; i-- { if tempPaths[i] == "" || sourcePaths[i] == "" { continue } _ = os.Rename(tempPaths[i], sourcePaths[i]) } } func removeTrash(tempPaths []string) { var i int for i = 0; i < len(tempPaths); i++ { if tempPaths[i] == "" { continue } _ = os.RemoveAll(tempPaths[i]) } } func (api *API) RepoCommits(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var limit int var offset int var v string var i int var query string var ref string var commits []git.Commit repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } limit = 20 v = r.URL.Query().Get("limit") if v != "" { i, err = strconv.Atoi(v) if err == nil { limit = i } } offset = 0 v = r.URL.Query().Get("offset") if v != "" { i, err = strconv.Atoi(v) if err == nil { offset = i } } query = r.URL.Query().Get("q") ref = r.URL.Query().Get("ref") commits, err = git.ListCommits(repo.Path, ref, limit, offset, query) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, commits) } func (api *API) RepoBranches(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var branches []string repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } branches, err = git.ListBranches(repo.Path) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, branches) } func (api *API) RepoBranchesInfo(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var branches []git.BranchInfo var info repoBranchesInfo var ref string var query string var i int repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } branches, err = git.ListBranchInfos(repo.Path) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } ref, _ = git.GetDefaultBranch(repo.Path) query = strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) if query != "" { var filtered []git.BranchInfo for i = 0; i < len(branches); i++ { if strings.Contains(strings.ToLower(branches[i].Name), query) { filtered = append(filtered, branches[i]) } } branches = filtered } info = repoBranchesInfo{Default: ref, Branches: branches} WriteJSON(w, http.StatusOK, info) } func (api *API) RepoSetDefaultBranch(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var req repoBranchRequest if !api.requireRepoRole(w, r, params["id"], "admin") { return } repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } err = DecodeJSON(r, &req) if err != nil || strings.TrimSpace(req.Name) == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "branch name required"}) return } err = git.SetDefaultBranch(repo.Path, strings.TrimSpace(req.Name)) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (api *API) RepoDeleteBranch(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var req repoBranchRequest var name string if !api.requireRepoRole(w, r, params["id"], "admin") { return } repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) 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": "branch name required"}) return } err = git.DeleteBranch(repo.Path, name) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (api *API) RepoRenameBranch(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var req repoBranchRenameRequest var from string var to string if !api.requireRepoRole(w, r, params["id"], "admin") { return } repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) 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 and to required"}) return } if from == to { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "new branch name must be different"}) return } err = git.RenameBranch(repo.Path, from, to) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (api *API) RepoCreateBranch(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var req repoBranchCreateRequest if !api.requireRepoRole(w, r, params["id"], "admin") { return } repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if strings.TrimSpace(req.Name) == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "branch name required"}) return } err = git.CreateBranch(repo.Path, strings.TrimSpace(req.Name), strings.TrimSpace(req.From)) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (api *API) RepoTree(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var ref string var path string var entries []git.TreeEntry repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } ref = r.URL.Query().Get("ref") path = r.URL.Query().Get("path") entries, err = git.ListTree(repo.Path, ref, path) if err != nil { if errors.Is(err, git.ErrPathNotFound) { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "path not found"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, entries) } func (api *API) RepoBlob(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var ref string var path string var content string repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } ref = r.URL.Query().Get("ref") path = r.URL.Query().Get("path") if path == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path required"}) return } content, err = git.ReadBlob(repo.Path, ref, path, 200*1024) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, map[string]string{"path": path, "content": content}) } func (api *API) RepoBlobRaw(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var ref string var path string var content []byte var contentType string repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } ref = r.URL.Query().Get("ref") path = r.URL.Query().Get("path") if path == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path required"}) return } content, err = git.ReadBlobBytes(repo.Path, ref, path, 5*1024*1024) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } contentType = http.DetectContentType(content) w.Header().Set("Content-Type", contentType) w.WriteHeader(http.StatusOK) _, _ = w.Write(content) } func (api *API) RepoFileHistory(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var ref string var path string var limit int var v string var i int var commits []git.Commit repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } ref = r.URL.Query().Get("ref") path = r.URL.Query().Get("path") if path == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path required"}) return } limit = 20 v = r.URL.Query().Get("limit") if v != "" { i, err = strconv.Atoi(v) if err == nil { limit = i } } commits, err = git.ListFileHistory(repo.Path, ref, path, limit) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, commits) } func (api *API) RepoFileDiff(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var ref string var path string var diff string repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } ref = r.URL.Query().Get("ref") path = r.URL.Query().Get("path") if path == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path required"}) return } diff, err = git.DiffFile(repo.Path, ref, path) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, map[string]string{"path": path, "diff": diff}) } func (api *API) RepoCommitDetail(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var hash string var detail git.CommitDetail repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } hash = r.URL.Query().Get("hash") if hash == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "hash required"}) return } detail, err = git.GetCommitDetail(repo.Path, hash) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, detail) } func (api *API) RepoCommitDiff(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var hash string var diff string repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } hash = r.URL.Query().Get("hash") if hash == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "hash required"}) return } diff, err = git.CommitDiff(repo.Path, hash) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, map[string]string{"hash": hash, "diff": diff}) } func (api *API) RepoCompare(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var base string var head string var limit int var v string var i int var commits []git.Commit repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } base = r.URL.Query().Get("base") head = r.URL.Query().Get("head") if base == "" || head == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "base and head required"}) return } limit = 50 v = r.URL.Query().Get("limit") if v != "" { i, err = strconv.Atoi(v) if err == nil { limit = i } } commits, err = git.ListCommitsBetween(repo.Path, base, head, limit) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, commits) } func (api *API) RepoStats(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var project models.Project var err error var branches []string var ref string var commits []git.Commit var stats repoStatsResponse repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } project, err = api.Store.GetProject(repo.ProjectID) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) return } if repo.Type != "git" { stats = repoStatsResponse{ RepoID: repo.ID, Branches: 0, DefaultRef: "", ProjectSlug: project.Slug, Repository: repo.Name, CloneURL: "", } WriteJSON(w, http.StatusOK, stats) return } branches, err = git.ListBranches(repo.Path) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } ref = "" if contains(branches, "main") { ref = "main" } else if contains(branches, "master") { ref = "master" } else if len(branches) > 0 { ref = branches[0] } if ref != "" { commits, err = git.ListCommits(repo.Path, ref, 1, 0, "") if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } } stats = repoStatsResponse{ RepoID: repo.ID, Branches: len(branches), DefaultRef: ref, ProjectSlug: project.Slug, Repository: repo.Name, CloneURL: api.cloneURL(project.Slug, repo.Name), } if len(commits) > 0 { stats.LastCommit = commits[0].Hash stats.LastAuthor = commits[0].Author stats.LastWhen = commits[0].When stats.LastMessage = commits[0].Message } WriteJSON(w, http.StatusOK, stats) } func (api *API) RepoRPMPackages(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var packages []rpm.PackageSummary var query string var filtered []rpm.PackageSummary var i int repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } if repo.Type != "rpm" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not rpm"}) return } packages, err = rpm.ListPackages(repo.Path) 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, packages) return } filtered = make([]rpm.PackageSummary, 0, len(packages)) for i = 0; i < len(packages); i++ { if strings.Contains(strings.ToLower(packages[i].Name), query) || strings.Contains(strings.ToLower(packages[i].Filename), query) { filtered = append(filtered, packages[i]) } } WriteJSON(w, http.StatusOK, filtered) } func (api *API) RepoRPMPackage(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var filename string var detail rpm.PackageDetail repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } if repo.Type != "rpm" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not rpm"}) return } filename = strings.TrimSpace(r.URL.Query().Get("file")) if filename == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "file is required"}) return } detail, err = rpm.GetPackageDetail(repo.Path, filename) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, detail) } func (api *API) RepoTypes(w http.ResponseWriter, r *http.Request, _ map[string]string) { var types []repoTypeItem types = []repoTypeItem{ {Value: "git", Label: "Git"}, {Value: "rpm", Label: "RPM"}, {Value: "docker", Label: "Docker"}, } WriteJSON(w, http.StatusOK, types) } func (api *API) RepoRPMCreateSubdir(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var dirConfig models.RPMRepoDir var writeBlocked bool var writeBlockedPath string var err error var req repoRPMCreateRequest var name string var dirType string var mode string var parent string var parentPath string var fullPath string var repodataPath string var fullRel string var fullRelLower string var absParent string var hasRepoAncestor bool var allowDelete bool var tlsInsecureSkipVerify bool var syncIntervalSec int64 repo, err = api.getRepoResolved(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 != "rpm" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not rpm"}) 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 } if strings.EqualFold(name, "repodata") { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repodata is reserved"}) return } if !isSafeSubdirName(name) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid subdirectory name"}) return } dirType = strings.ToLower(strings.TrimSpace(req.Type)) if dirType == "" { dirType = "container" } if dirType != "container" && dirType != "repo" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid subdirectory type"}) return } parent = strings.TrimSpace(req.Parent) if parent != "" { if !isSafeSubdirPath(parent) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid parent path"}) return } parentPath = filepath.FromSlash(parent) } parent = filepath.ToSlash(parentPath) if parent == "." { parent = "" } writeBlocked, writeBlockedPath, err = api.isRPMWriteBlocked(repo, parent) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if writeBlocked { WriteJSON(w, http.StatusForbidden, map[string]string{"error": "writes are disabled for mirror repo subtree", "mirror_root": writeBlockedPath}) return } fullRel = filepath.ToSlash(filepath.Join(parentPath, name)) fullRelLower = strings.ToLower(fullRel) if fullRelLower == "repodata" || strings.HasPrefix(fullRelLower, "repodata/") || strings.Contains(fullRelLower, "/repodata/") { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "subdirectories under repodata are not allowed"}) return } if dirType == "repo" { mode = normalizeRPMRepoDirMode(req.Mode) allowDelete = req.AllowDelete tlsInsecureSkipVerify = req.TLSInsecureSkipVerify syncIntervalSec = req.SyncIntervalSec if syncIntervalSec == 0 { syncIntervalSec = 300 } syncIntervalSec = normalizeRPMMirrorIntervalSec(syncIntervalSec) absParent = filepath.Join(repo.Path, parentPath) hasRepoAncestor, err = hasRepodataAncestor(repo.Path, absParent) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if hasRepoAncestor { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo directories cannot be created under another repo directory"}) return } err = validateRPMMirrorConfig(mode, strings.TrimSpace(req.RemoteURL), strings.TrimSpace(req.ConnectHost), strings.TrimSpace(req.HostHeader), strings.TrimSpace(req.TLSServerName)) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } } fullPath = filepath.Join(repo.Path, parentPath, name) err = os.MkdirAll(fullPath, 0o755) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if dirType == "repo" { repodataPath = filepath.Join(fullPath, "repodata") err = os.MkdirAll(repodataPath, 0o755) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } dirConfig = models.RPMRepoDir{ RepoID: repo.ID, Path: fullRel, Mode: mode, AllowDelete: allowDelete, RemoteURL: strings.TrimSpace(req.RemoteURL), ConnectHost: strings.TrimSpace(req.ConnectHost), HostHeader: strings.TrimSpace(req.HostHeader), TLSServerName: strings.TrimSpace(req.TLSServerName), TLSInsecureSkipVerify: tlsInsecureSkipVerify, SyncIntervalSec: syncIntervalSec, SyncEnabled: true, } err = api.Store.UpsertRPMRepoDir(dirConfig) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } } WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (api *API) RepoRPMGetSubdir(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var relPath string var normalizedPath string var config models.RPMRepoDir var err error repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } if repo.Type != "rpm" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not rpm"}) return } relPath = strings.TrimSpace(r.URL.Query().Get("path")) if relPath == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path required"}) return } if !isSafeSubdirPath(relPath) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) return } normalizedPath = normalizeRPMPath(relPath) config, err = api.Store.GetRPMRepoDir(repo.ID, normalizedPath) if err == nil { WriteJSON(w, http.StatusOK, config) return } if !errors.Is(err, sql.ErrNoRows) { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, models.RPMRepoDir{RepoID: repo.ID, Path: normalizedPath, Mode: "local"}) } func (api *API) RepoRPMSyncSubdir(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var relPath string var normalizedPath string var config models.RPMRepoDir var err error repo, err = api.getRepoResolved(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 != "rpm" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not rpm"}) return } relPath = strings.TrimSpace(r.URL.Query().Get("path")) if relPath == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path required"}) return } if !isSafeSubdirPath(relPath) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) return } normalizedPath = normalizeRPMPath(relPath) config, err = api.Store.GetRPMRepoDir(repo.ID, normalizedPath) if err != nil { if errors.Is(err, sql.ErrNoRows) { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo directory config not found"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if normalizeRPMRepoDirMode(config.Mode) != "mirror" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "sync is only supported for mirror mode"}) return } if !config.SyncEnabled { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "mirror sync is suspended"}) return } err = api.Store.MarkRPMMirrorTaskDirty(repo.ID, normalizedPath) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, map[string]string{"status": "scheduled"}) } func (api *API) RepoRPMSuspendSubdir(w http.ResponseWriter, r *http.Request, params map[string]string) { api.repoRPMSetSyncEnabled(w, r, params, false) } func (api *API) RepoRPMResumeSubdir(w http.ResponseWriter, r *http.Request, params map[string]string) { api.repoRPMSetSyncEnabled(w, r, params, true) } func (api *API) RepoRPMRebuildSubdirMetadata(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var relPath string var normalizedPath string var fullPath string var repodataPath string var err error repo, err = api.getRepoResolved(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 != "rpm" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not rpm"}) return } relPath = strings.TrimSpace(r.URL.Query().Get("path")) if relPath == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path required"}) return } if !isSafeSubdirPath(relPath) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) return } normalizedPath = normalizeRPMPath(relPath) fullPath = filepath.Join(repo.Path, filepath.FromSlash(normalizedPath)) repodataPath = filepath.Join(fullPath, "repodata") _, err = os.Stat(repodataPath) if err != nil { if os.IsNotExist(err) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path is not a repository directory"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if api.RpmMeta == nil { WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "metadata manager unavailable"}) return } api.RpmMeta.Schedule(fullPath) WriteJSON(w, http.StatusOK, map[string]string{"status": "scheduled"}) } func (api *API) RepoRPMCancelSubdirSync(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var relPath string var normalizedPath string var config models.RPMRepoDir var canceled bool var err error repo, err = api.getRepoResolved(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 != "rpm" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not rpm"}) return } relPath = strings.TrimSpace(r.URL.Query().Get("path")) if relPath == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path required"}) return } if !isSafeSubdirPath(relPath) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) return } normalizedPath = normalizeRPMPath(relPath) config, err = api.Store.GetRPMRepoDir(repo.ID, normalizedPath) if err != nil { if errors.Is(err, sql.ErrNoRows) { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo directory config not found"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if normalizeRPMRepoDirMode(config.Mode) != "mirror" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "sync control is only supported for mirror mode"}) return } if !config.SyncRunning { WriteJSON(w, http.StatusConflict, map[string]string{"error": "sync is not running"}) return } if api.RpmMirror == nil { WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "mirror manager unavailable"}) return } canceled = api.RpmMirror.CancelTask(repo.ID, normalizedPath) if !canceled { WriteJSON(w, http.StatusConflict, map[string]string{"error": "sync is not running"}) return } WriteJSON(w, http.StatusOK, map[string]string{"status": "cancel_requested"}) } func (api *API) repoRPMSetSyncEnabled(w http.ResponseWriter, r *http.Request, params map[string]string, enabled bool) { var repo models.Repo var relPath string var normalizedPath string var config models.RPMRepoDir var cancelRequested bool var err error repo, err = api.getRepoResolved(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 != "rpm" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not rpm"}) return } relPath = strings.TrimSpace(r.URL.Query().Get("path")) if relPath == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path required"}) return } if !isSafeSubdirPath(relPath) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) return } normalizedPath = normalizeRPMPath(relPath) config, err = api.Store.GetRPMRepoDir(repo.ID, normalizedPath) if err != nil { if errors.Is(err, sql.ErrNoRows) { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo directory config not found"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if normalizeRPMRepoDirMode(config.Mode) != "mirror" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "sync control is only supported for mirror mode"}) return } err = api.Store.SetRPMMirrorSyncEnabled(repo.ID, normalizedPath, enabled) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } cancelRequested = false if !enabled && api.RpmMirror != nil { cancelRequested = api.RpmMirror.CancelTask(repo.ID, normalizedPath) } WriteJSON(w, http.StatusOK, map[string]any{"status": "ok", "sync_enabled": enabled, "cancel_requested": cancelRequested}) } func (api *API) RepoRPMMirrorRuns(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var relPath string var normalizedPath string var limit int var runs []models.RPMMirrorRun var err error repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } if repo.Type != "rpm" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not rpm"}) return } relPath = strings.TrimSpace(r.URL.Query().Get("path")) if relPath == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path required"}) return } if !isSafeSubdirPath(relPath) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) return } normalizedPath = normalizeRPMPath(relPath) limit = 20 if strings.TrimSpace(r.URL.Query().Get("limit")) != "" { limit, err = strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("limit"))) if err != nil || limit <= 0 { limit = 20 } } runs, err = api.Store.ListRPMMirrorRuns(repo.ID, normalizedPath, limit) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, runs) } func (api *API) RepoRPMClearMirrorRuns(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var relPath string var normalizedPath string var count int64 var err error repo, err = api.getRepoResolved(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 != "rpm" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not rpm"}) return } relPath = strings.TrimSpace(r.URL.Query().Get("path")) if relPath == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path required"}) return } if !isSafeSubdirPath(relPath) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) return } normalizedPath = normalizeRPMPath(relPath) count, err = api.Store.DeleteRPMMirrorRuns(repo.ID, normalizedPath) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, map[string]any{"status": "ok", "deleted_count": count}) } func (api *API) RepoRPMDeleteSubdir(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var writeBlocked bool var writeBlockedPath string var targetConfig models.RPMRepoDir var targetHasConfig bool var mirrorRoot string var allowMirrorRootDelete bool var allowMirrorDelete bool var busy bool var busyPath string var busyReason string var err error var relPath string var fullPath string var info os.FileInfo var relPathClean string var parentPath string var repodataPath string repo, err = api.getRepoResolved(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 != "rpm" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not rpm"}) return } relPath = strings.TrimSpace(r.URL.Query().Get("path")) if relPath == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path required"}) return } if !isSafeSubdirPath(relPath) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) return } if isRepodataPath(relPath) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repodata cannot be deleted directly"}) return } relPathClean = filepath.ToSlash(filepath.Clean(filepath.FromSlash(relPath))) relPathClean = strings.TrimPrefix(relPathClean, "./") busy, busyPath, busyReason, err = api.hasBusyMirrorRootUnder(repo, relPathClean) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if busy { WriteJSON(w, http.StatusConflict, map[string]string{"error": "cannot delete directory while mirror activity is running", "mirror_root": busyPath, "reason": busyReason}) return } targetConfig, err = api.Store.GetRPMRepoDir(repo.ID, relPathClean) if err == nil { targetHasConfig = true } else if err != nil && !errors.Is(err, sql.ErrNoRows) { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeBlocked, writeBlockedPath, err = api.isRPMWriteBlocked(repo, relPathClean) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if writeBlocked { mirrorRoot = normalizeRPMPath(writeBlockedPath) allowMirrorRootDelete = targetHasConfig && normalizeRPMRepoDirMode(targetConfig.Mode) == "mirror" && normalizeRPMPath(targetConfig.Path) == normalizeRPMPath(relPathClean) && normalizeRPMPath(relPathClean) == mirrorRoot if !allowMirrorRootDelete { allowMirrorDelete, err = api.allowRPMMirrorDelete(repo, relPathClean, true) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if !allowMirrorDelete { WriteJSON(w, http.StatusForbidden, map[string]string{"error": "writes are disabled for mirror repo subtree", "mirror_root": writeBlockedPath}) return } } if targetConfig.SyncRunning { WriteJSON(w, http.StatusConflict, map[string]string{"error": "cannot delete mirror repo directory while sync is running", "mirror_root": writeBlockedPath}) return } } fullPath = filepath.Join(repo.Path, filepath.FromSlash(relPathClean)) if writeBlocked && allowMirrorRootDelete && api.RpmMeta != nil && api.RpmMeta.IsRunning(fullPath) { WriteJSON(w, http.StatusConflict, map[string]string{"error": "cannot delete mirror repo directory while metadata update is running", "mirror_root": writeBlockedPath}) return } info, err = os.Stat(fullPath) if err != nil { if os.IsNotExist(err) { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "path not found"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if info == nil || !info.IsDir() { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path is not a directory"}) return } if writeBlocked && !allowMirrorRootDelete && allowMirrorDelete { repodataPath = filepath.Join(fullPath, "repodata") _, err = os.Stat(repodataPath) if err == nil { WriteJSON(w, http.StatusForbidden, map[string]string{"error": "only container directories can be deleted in mirror subtree", "mirror_root": writeBlockedPath}) return } if err != nil && !os.IsNotExist(err) { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } } err = os.RemoveAll(fullPath) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } err = api.Store.DeleteRPMRepoDirSubtree(repo.ID, relPathClean) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } parentPath = filepath.Dir(fullPath) repodataPath = filepath.Join(parentPath, "repodata") _, err = os.Stat(repodataPath) if err == nil && api.RpmMeta != nil { api.RpmMeta.Schedule(parentPath) } WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (api *API) RepoRPMRenameSubdir(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var existingConfig models.RPMRepoDir var dirConfig models.RPMRepoDir var writeBlocked bool var writeBlockedPath string var newMode string var renamed bool var isRepoDir bool var err error var req repoRPMUpdateRequest var relPath string var relPathClean string var newName string var fullPath string var info os.FileInfo var parentRel string var parentPath string var newPath string var newRelPath string var repodataPath string var hasAncestor bool var absParent string var existingConfigLoaded bool var targetConfigExists bool var allowDelete bool var tlsInsecureSkipVerify bool var syncIntervalSec int64 var modeValue string var remoteURL string var connectHost string var hostHeader string var tlsServerName string var busy bool var busyPath string var busyReason string repo, err = api.getRepoResolved(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 != "rpm" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not rpm"}) return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.Path == nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path required"}) return } relPath = strings.TrimSpace(*req.Path) newName = "" if req.Name != nil { newName = strings.TrimSpace(*req.Name) } if relPath == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path required"}) return } if !isSafeSubdirPath(relPath) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) return } if isRepodataPath(relPath) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repodata cannot be renamed"}) return } relPathClean = filepath.ToSlash(filepath.Clean(filepath.FromSlash(relPath))) relPathClean = strings.TrimPrefix(relPathClean, "./") busy, busyPath, busyReason, err = api.hasBusyMirrorRootUnder(repo, relPathClean) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if busy { WriteJSON(w, http.StatusConflict, map[string]string{"error": "cannot rename/update directory while mirror activity is running", "mirror_root": busyPath, "reason": busyReason}) return } if newName == "" { newName = filepath.Base(filepath.FromSlash(relPathClean)) } if strings.EqualFold(newName, "repodata") { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repodata is reserved"}) return } if !isSafeSubdirName(newName) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid name"}) return } writeBlocked, writeBlockedPath, err = api.isRPMWriteBlocked(repo, relPathClean) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if writeBlocked && writeBlockedPath != relPathClean { WriteJSON(w, http.StatusForbidden, map[string]string{"error": "writes are disabled for mirror repo subtree", "mirror_root": writeBlockedPath}) return } fullPath = filepath.Join(repo.Path, filepath.FromSlash(relPathClean)) info, err = os.Stat(fullPath) if err != nil { if os.IsNotExist(err) { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "path not found"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if info == nil || !info.IsDir() { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path is not a directory"}) return } parentRel = filepath.Dir(filepath.FromSlash(relPathClean)) if parentRel == "." { parentRel = "" } parentPath = filepath.FromSlash(parentRel) newPath = filepath.Join(repo.Path, parentPath, newName) newRelPath = filepath.ToSlash(filepath.Join(parentRel, newName)) _, err = api.Store.GetRPMRepoDir(repo.ID, newRelPath) if err == nil { targetConfigExists = true } else if err != nil && !errors.Is(err, sql.ErrNoRows) { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } repodataPath = filepath.Join(fullPath, "repodata") _, err = os.Stat(repodataPath) if err == nil { isRepoDir = true existingConfig, err = api.Store.GetRPMRepoDir(repo.ID, relPathClean) if err == nil { existingConfigLoaded = true if normalizeRPMRepoDirMode(existingConfig.Mode) == "mirror" && existingConfig.SyncRunning { WriteJSON(w, http.StatusConflict, map[string]string{"error": "cannot rename mirror repo directory while sync is running"}) return } if normalizeRPMRepoDirMode(existingConfig.Mode) == "mirror" && api.RpmMeta != nil && api.RpmMeta.IsRunning(fullPath) { WriteJSON(w, http.StatusConflict, map[string]string{"error": "cannot update mirror repo directory while metadata update is running"}) return } } else if err != nil && !errors.Is(err, sql.ErrNoRows) { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } modeValue = "" if req.Mode != nil { modeValue = *req.Mode } newMode = normalizeRPMRepoDirMode(modeValue) if newMode == "" { if existingConfigLoaded { newMode = normalizeRPMRepoDirMode(existingConfig.Mode) } else { newMode = "local" } } if req.AllowDelete != nil { allowDelete = *req.AllowDelete } else if existingConfigLoaded { allowDelete = existingConfig.AllowDelete } else { allowDelete = false } if req.TLSInsecureSkipVerify != nil { tlsInsecureSkipVerify = *req.TLSInsecureSkipVerify } else if existingConfigLoaded { tlsInsecureSkipVerify = existingConfig.TLSInsecureSkipVerify } else { tlsInsecureSkipVerify = false } if req.SyncIntervalSec != nil { syncIntervalSec = *req.SyncIntervalSec } else if existingConfigLoaded { syncIntervalSec = existingConfig.SyncIntervalSec } else { syncIntervalSec = 300 } syncIntervalSec = normalizeRPMMirrorIntervalSec(syncIntervalSec) if req.RemoteURL != nil { remoteURL = strings.TrimSpace(*req.RemoteURL) } else if existingConfigLoaded { remoteURL = existingConfig.RemoteURL } else { remoteURL = "" } if req.ConnectHost != nil { connectHost = strings.TrimSpace(*req.ConnectHost) } else if existingConfigLoaded { connectHost = existingConfig.ConnectHost } else { connectHost = "" } if req.HostHeader != nil { hostHeader = strings.TrimSpace(*req.HostHeader) } else if existingConfigLoaded { hostHeader = existingConfig.HostHeader } else { hostHeader = "" } if req.TLSServerName != nil { tlsServerName = strings.TrimSpace(*req.TLSServerName) } else if existingConfigLoaded { tlsServerName = existingConfig.TLSServerName } else { tlsServerName = "" } err = validateRPMMirrorConfig(newMode, remoteURL, connectHost, hostHeader, tlsServerName) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } absParent = filepath.Join(repo.Path, parentPath) hasAncestor, err = hasRepodataAncestor(repo.Path, absParent) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if hasAncestor { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo directories cannot be renamed under another repo directory"}) return } } renamed = newPath != fullPath if renamed { if targetConfigExists { WriteJSON(w, http.StatusConflict, map[string]string{"error": "target repo directory config already exists"}) return } _, err = os.Stat(newPath) if err == nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "target already exists"}) return } if err != nil && !os.IsNotExist(err) { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } err = os.Rename(fullPath, newPath) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } err = api.Store.MoveRPMRepoDir(repo.ID, relPathClean, newRelPath) if err != nil { _ = os.Rename(newPath, fullPath) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } } if isRepoDir { dirConfig = models.RPMRepoDir{ RepoID: repo.ID, Path: newRelPath, Mode: newMode, AllowDelete: allowDelete, RemoteURL: remoteURL, ConnectHost: connectHost, HostHeader: hostHeader, TLSServerName: tlsServerName, TLSInsecureSkipVerify: tlsInsecureSkipVerify, SyncIntervalSec: syncIntervalSec, } if existingConfigLoaded { dirConfig.SyncEnabled = existingConfig.SyncEnabled } else if errors.Is(err, sql.ErrNoRows) { dirConfig.SyncEnabled = true } else { if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } dirConfig.SyncEnabled = true } err = api.Store.UpsertRPMRepoDir(dirConfig) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } } repodataPath = filepath.Join(filepath.Join(repo.Path, parentPath), "repodata") _, err = os.Stat(repodataPath) if err == nil && api.RpmMeta != nil { api.RpmMeta.Schedule(filepath.Join(repo.Path, parentPath)) } WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (api *API) RepoRPMDeleteFile(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var writeBlocked bool var writeBlockedPath string var allowMirrorDelete bool var err error var relPath string var relPathClean string var fullPath string var info os.FileInfo var parentPath string var repodataPath string var lower string repo, err = api.getRepoResolved(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 != "rpm" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not rpm"}) return } relPath = strings.TrimSpace(r.URL.Query().Get("path")) if relPath == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path required"}) return } if !isSafeSubdirPath(relPath) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) return } if isRepodataPath(relPath) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repodata files cannot be deleted"}) return } relPathClean = filepath.ToSlash(filepath.Clean(filepath.FromSlash(relPath))) relPathClean = strings.TrimPrefix(relPathClean, "./") writeBlocked, writeBlockedPath, err = api.isRPMWriteBlocked(repo, relPathClean) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if writeBlocked { allowMirrorDelete, err = api.allowRPMMirrorDelete(repo, relPathClean, false) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if !allowMirrorDelete { WriteJSON(w, http.StatusForbidden, map[string]string{"error": "writes are disabled for mirror repo subtree", "mirror_root": writeBlockedPath}) return } } lower = strings.ToLower(relPathClean) if !strings.HasSuffix(lower, ".rpm") { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "only rpm files can be deleted"}) return } fullPath = filepath.Join(repo.Path, filepath.FromSlash(relPathClean)) info, err = os.Stat(fullPath) if err != nil { if os.IsNotExist(err) { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "path not found"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if info == nil || info.IsDir() { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path is not a file"}) return } err = os.Remove(fullPath) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } parentPath = filepath.Dir(fullPath) repodataPath = filepath.Join(parentPath, "repodata") _, err = os.Stat(repodataPath) if err == nil && api.RpmMeta != nil { api.RpmMeta.Schedule(parentPath) } WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (api *API) RepoRPMFile(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var relPath string var cleanPath string var fullPath string var lower string var allowed bool var file *os.File var size int64 repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } if repo.Type != "rpm" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not rpm"}) return } relPath = strings.TrimSpace(r.URL.Query().Get("path")) if relPath == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path required"}) return } if !isSafeSubdirPath(relPath) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) return } cleanPath = filepath.ToSlash(relPath) cleanPath = strings.TrimPrefix(cleanPath, "./") lower = strings.ToLower(cleanPath) allowed = strings.HasSuffix(lower, "repodata/repomd.xml") || strings.HasSuffix(lower, "repodata/primary.xml.gz") || strings.HasSuffix(lower, "repodata/other.xml.gz") || strings.HasSuffix(lower, "repodata/filelists.xml.gz") if !allowed { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "file not allowed"}) return } fullPath = filepath.Join(repo.Path, filepath.FromSlash(cleanPath)) file, err = os.Open(fullPath) if err != nil { if os.IsNotExist(err) { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } defer file.Close() if strings.HasSuffix(lower, ".gz") { w.Header().Set("Content-Type", "application/gzip") } else { w.Header().Set("Content-Type", "application/xml") } size, err = io.Copy(w, file) _ = size if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } } func (api *API) RepoRPMTree(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var relPath string var entries []rpm.TreeEntry var repoDirs []models.RPMRepoDir var modeByPath map[string]string var i int var entryPath string var repodataPath string repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } if repo.Type != "rpm" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not rpm"}) return } relPath = strings.TrimSpace(r.URL.Query().Get("path")) if strings.Contains(relPath, "..") { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) return } entries, err = rpm.ListTree(repo.Path, relPath) if err != nil { if errors.Is(err, rpm.ErrPathNotFound) { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "path not found"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } repoDirs, err = api.Store.ListRPMRepoDirs(repo.ID) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } modeByPath = make(map[string]string) for i = 0; i < len(repoDirs); i++ { modeByPath[normalizeRPMPath(repoDirs[i].Path)] = normalizeRPMRepoDirMode(repoDirs[i].Mode) } for i = 0; i < len(entries); i++ { if entries[i].Type != "dir" { continue } entryPath = normalizeRPMPath(entries[i].Path) if modeByPath[entryPath] != "" { entries[i].IsRepoDir = true entries[i].RepoMode = modeByPath[entryPath] continue } repodataPath = filepath.Join(repo.Path, filepath.FromSlash(entryPath), "repodata") _, err = os.Stat(repodataPath) if err == nil { entries[i].IsRepoDir = true entries[i].RepoMode = "local" continue } if err != nil && !os.IsNotExist(err) { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } } WriteJSON(w, http.StatusOK, entries) } func (api *API) RepoRPMUpload(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var writeBlocked bool var writeBlockedPath string var err error var relPath string var relPathClean string var dirPath string var repodataDir string var file multipart.File var header *multipart.FileHeader var filename string var lower string var fullPath string var tempPath string var out *os.File var size int64 var detail rpm.PackageDetail var overwriteParam string var overwrite bool var info os.FileInfo var oldTemp string var ts string repo, err = api.getRepoResolved(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 != "rpm" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not rpm"}) return } relPath = strings.TrimSpace(r.URL.Query().Get("path")) overwriteParam = strings.TrimSpace(r.URL.Query().Get("overwrite")) overwriteParam = strings.ToLower(overwriteParam) overwrite = overwriteParam == "1" || overwriteParam == "true" || overwriteParam == "yes" if relPath != "" && !isSafeSubdirPath(relPath) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) return } relPathClean = filepath.ToSlash(filepath.Clean(filepath.FromSlash(relPath))) relPathClean = strings.TrimPrefix(relPathClean, "./") if relPath == "" { relPathClean = "" } if isRepodataPath(relPathClean) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "uploads are not allowed in repodata"}) return } writeBlocked, writeBlockedPath, err = api.isRPMWriteBlocked(repo, relPathClean) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if writeBlocked { WriteJSON(w, http.StatusForbidden, map[string]string{"error": "writes are disabled for mirror repo subtree", "mirror_root": writeBlockedPath}) return } dirPath = filepath.Join(repo.Path, filepath.FromSlash(relPathClean)) repodataDir, err = nearestRepodataDir(repo.Path, dirPath) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if repodataDir == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repodata not found in ancestor directories"}) return } file, header, err = r.FormFile("file") if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "file required"}) return } defer file.Close() filename = sanitizeFilename(header.Filename) filename = filepath.Base(filename) if filename == "" || filename == "." || filename == string(filepath.Separator) { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"}) return } lower = strings.ToLower(filename) if !strings.HasSuffix(lower, ".rpm") { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "only rpm files are allowed"}) return } fullPath = filepath.Join(dirPath, filename) info, err = os.Stat(fullPath) if err == nil { if !overwrite { WriteJSON(w, http.StatusConflict, map[string]string{"error": "file already exists"}) return } if info != nil && info.IsDir() { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path is a directory"}) return } oldTemp, err = moveToTrash(fullPath) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } } if err != nil && !os.IsNotExist(err) { if oldTemp != "" { _ = os.Rename(oldTemp, fullPath) } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } ts = strconv.FormatInt(time.Now().UnixNano(), 10) tempPath = fullPath + ".uploading-" + ts out, err = os.Create(tempPath) if err != nil { if oldTemp != "" { _ = os.Rename(oldTemp, fullPath) } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } defer out.Close() size, err = io.Copy(out, file) if err != nil { _ = os.Remove(tempPath) if oldTemp != "" { _ = os.Rename(oldTemp, fullPath) } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } err = os.Rename(tempPath, fullPath) if err != nil { _ = os.Remove(tempPath) if oldTemp != "" { _ = os.Rename(oldTemp, fullPath) } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } detail, err = rpm.GetPackageDetail(repo.Path, filepath.ToSlash(filepath.Join(relPathClean, filename))) if err != nil { _ = os.Remove(fullPath) if oldTemp != "" { _ = os.Rename(oldTemp, fullPath) } WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid rpm file"}) return } _ = detail if oldTemp != "" { _ = os.RemoveAll(oldTemp) } if api.RpmMeta != nil { api.RpmMeta.Schedule(repodataDir) } WriteJSON(w, http.StatusOK, repoRPMUploadResponse{Filename: filename, Size: size}) } func (api *API) RepoDockerTags(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var tags []docker.TagInfo var image string var imagePath string repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } if repo.Type != "docker" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not docker"}) return } image = strings.TrimSpace(r.URL.Query().Get("image")) imagePath = docker.ImagePath(repo.Path, image) tags, err = docker.ListTags(imagePath) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, tags) } func (api *API) RepoDockerManifest(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var ref string var detail docker.ManifestDetail var image string var imagePath string repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } if repo.Type != "docker" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not docker"}) return } ref = strings.TrimSpace(r.URL.Query().Get("ref")) if ref == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "ref required"}) return } image = strings.TrimSpace(r.URL.Query().Get("image")) imagePath = docker.ImagePath(repo.Path, image) detail, err = docker.GetManifestDetail(imagePath, ref) if err != nil { if err == docker.ErrNotFound { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "manifest not found"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, detail) } func (api *API) RepoDockerImages(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var images []string repo, err = api.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "viewer") { return } if repo.Type != "docker" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not docker"}) return } images, err = docker.ListImages(repo.Path) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, images) } func (api *API) ListAPIKeys(w http.ResponseWriter, r *http.Request, _ map[string]string) { var user models.User var ok bool var keys []models.APIKey var err error user, ok = middleware.UserFromContext(r.Context()) if !ok { w.WriteHeader(http.StatusUnauthorized) return } keys, err = api.Store.ListAPIKeys(user.ID) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, keys) } func (api *API) CreateAPIKey(w http.ResponseWriter, r *http.Request, _ map[string]string) { var user models.User var ok bool var req createAPIKeyRequest var err error var name string var buf []byte var token string var prefix string var hash string var key models.APIKey var nowUnix int64 var expiresAt int64 user, ok = middleware.UserFromContext(r.Context()) if !ok { w.WriteHeader(http.StatusUnauthorized) return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } name = strings.TrimSpace(req.Name) if name == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name required"}) return } expiresAt = req.ExpiresAt if expiresAt < 0 { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "expires_at must be 0 or a future unix timestamp"}) return } if expiresAt > 0 { nowUnix = time.Now().UTC().Unix() if expiresAt <= nowUnix { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "expires_at must be in the future"}) return } } buf = make([]byte, 32) _, err = rand.Read(buf) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to generate token"}) return } token = "ck_" + hex.EncodeToString(buf) if len(token) > 8 { prefix = token[:8] } else { prefix = token } hash = util.HashToken(token) key, err = api.Store.CreateAPIKey(user.ID, name, hash, prefix, expiresAt) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusCreated, apiKeyResponse{APIKey: key, Token: token}) } func (api *API) DeleteAPIKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var user models.User var ok bool var err error user, ok = middleware.UserFromContext(r.Context()) if !ok { w.WriteHeader(http.StatusUnauthorized) return } err = api.Store.DeleteAPIKey(user.ID, params["id"]) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) } func (api *API) DisableAPIKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var user models.User var ok bool var err error user, ok = middleware.UserFromContext(r.Context()) if !ok { w.WriteHeader(http.StatusUnauthorized) return } err = api.Store.SetAPIKeyDisabled(user.ID, params["id"], true) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) } func (api *API) EnableAPIKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var user models.User var ok bool var err error user, ok = middleware.UserFromContext(r.Context()) if !ok { w.WriteHeader(http.StatusUnauthorized) return } err = api.Store.SetAPIKeyDisabled(user.ID, params["id"], false) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) } func (api *API) ListAdminAPIKeys(w http.ResponseWriter, r *http.Request, _ map[string]string) { var keys []models.AdminAPIKey var err error var userID string var query string if !api.requireAdmin(w, r) { return } userID = strings.TrimSpace(r.URL.Query().Get("user_id")) query = strings.TrimSpace(r.URL.Query().Get("q")) keys, err = api.Store.ListAPIKeysAdmin(userID, query) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, keys) } func (api *API) DeleteAdminAPIKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var err error if !api.requireAdmin(w, r) { return } err = api.Store.DeleteAPIKeyByID(params["id"]) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) } func (api *API) DisableAdminAPIKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var err error if !api.requireAdmin(w, r) { return } err = api.Store.SetAPIKeyDisabledByID(params["id"], true) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) } func (api *API) EnableAdminAPIKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var err error if !api.requireAdmin(w, r) { return } err = api.Store.SetAPIKeyDisabledByID(params["id"], false) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } w.WriteHeader(http.StatusNoContent) } func (api *API) RepoDockerDeleteTag(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var image string var tag string var imagePath string repo, err = api.getRepoResolved(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 } image = strings.TrimSpace(r.URL.Query().Get("image")) tag = strings.TrimSpace(r.URL.Query().Get("tag")) if tag == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tag required"}) return } imagePath = docker.ImagePath(repo.Path, image) err = docker.DeleteTag(imagePath, tag) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (api *API) RepoDockerDeleteImage(w http.ResponseWriter, r *http.Request, params map[string]string) { var repo models.Repo var err error var image string repo, err = api.getRepoResolved(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 } image = strings.TrimSpace(r.URL.Query().Get("image")) err = docker.DeleteImage(repo.Path, image) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } 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.getRepoResolved(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.getRepoResolved(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) return } if !api.requireRepoRole(w, r, repo.ID, "writer") { return } if repo.Type != "docker" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not docker"}) return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } from = strings.TrimSpace(req.From) to = strings.TrimSpace(req.To) if from == "" && to == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "from or to required"}) return } err = docker.RenameImage(repo.Path, from, to) if err != nil { if err == docker.ErrNotFound { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "image not found"}) return } WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (api *API) ListIssues(w http.ResponseWriter, r *http.Request, params map[string]string) { var issues []models.Issue var err error if !api.requireProjectRole(w, r, params["projectId"], "viewer") { return } issues, err = api.Store.ListIssues(params["projectId"]) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, issues) } func (api *API) CreateIssue(w http.ResponseWriter, r *http.Request, params map[string]string) { var req createIssueRequest var err error var user models.User var issue models.Issue var created models.Issue if !api.requireProjectRole(w, r, params["projectId"], "writer") { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.Title == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "title required"}) return } user, _ = middleware.UserFromContext(r.Context()) issue = models.Issue{ ProjectID: params["projectId"], Title: req.Title, Body: req.Body, AssigneeID: req.AssigneeID, CreatedBy: user.ID, } created, err = api.Store.CreateIssue(issue) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusCreated, created) } func (api *API) UpdateIssue(w http.ResponseWriter, r *http.Request, params map[string]string) { var issue models.Issue var err error var req updateIssueRequest issue, err = api.Store.GetIssue(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "issue not found"}) return } if !api.requireProjectRole(w, r, issue.ProjectID, "writer") { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.Title != "" { issue.Title = req.Title } if req.Body != "" { issue.Body = req.Body } if req.Status != "" { issue.Status = req.Status } issue.AssigneeID = req.AssigneeID err = api.Store.UpdateIssue(issue) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, issue) } func (api *API) AddIssueComment(w http.ResponseWriter, r *http.Request, params map[string]string) { var req issueCommentRequest var err error var user models.User var comment models.IssueComment var created models.IssueComment err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.Body == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "comment body required"}) return } user, _ = middleware.UserFromContext(r.Context()) comment = models.IssueComment{IssueID: params["id"], Body: req.Body, CreatedBy: user.ID} created, err = api.Store.AddIssueComment(comment) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusCreated, created) } func (api *API) ListWikiPages(w http.ResponseWriter, r *http.Request, params map[string]string) { var pages []models.WikiPage var err error if !api.requireProjectRole(w, r, params["projectId"], "viewer") { return } pages, err = api.Store.ListWikiPages(params["projectId"]) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, pages) } func (api *API) CreateWikiPage(w http.ResponseWriter, r *http.Request, params map[string]string) { var req createWikiRequest var err error var user models.User var page models.WikiPage var created models.WikiPage if !api.requireProjectRole(w, r, params["projectId"], "writer") { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.Title == "" || req.Slug == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "title and slug required"}) return } user, _ = middleware.UserFromContext(r.Context()) page = models.WikiPage{ ProjectID: params["projectId"], Title: req.Title, Slug: req.Slug, Body: req.Body, CreatedBy: user.ID, } created, err = api.Store.CreateWikiPage(page) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusCreated, created) } func (api *API) UpdateWikiPage(w http.ResponseWriter, r *http.Request, params map[string]string) { var page models.WikiPage var err error var req updateWikiRequest page, err = api.Store.GetWikiPage(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "page not found"}) return } if !api.requireProjectRole(w, r, page.ProjectID, "writer") { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if req.Title != "" { page.Title = req.Title } page.Body = req.Body err = api.Store.UpdateWikiPage(page) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, page) } func (api *API) UploadFile(w http.ResponseWriter, r *http.Request, params map[string]string) { var user models.User var file multipart.File var header *multipart.FileHeader var err error var id string var storedName string var tempName string var tempPath string var finalPath string var size int64 var contentType string var upload models.Upload var created models.Upload var ts string if !api.requireProjectRole(w, r, params["projectId"], "writer") { return } user, _ = middleware.UserFromContext(r.Context()) file, header, err = r.FormFile("file") if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "file required"}) return } defer file.Close() id, err = util.NewID() if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to generate id"}) return } storedName = id + filepath.Ext(header.Filename) ts = strconv.FormatInt(time.Now().UnixNano(), 10) tempName = storedName + ".uploading-" + ts tempPath, size, err = api.Uploads.Save(tempName, file) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } finalPath = filepath.Join(api.Uploads.BaseDir, storedName) err = os.Rename(tempPath, finalPath) if err != nil { _ = os.Remove(tempPath) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } contentType = header.Header.Get("Content-Type") if contentType == "" { contentType = "application/octet-stream" } upload = models.Upload{ ID: id, ProjectID: params["projectId"], Filename: header.Filename, ContentType: contentType, Size: size, StoragePath: finalPath, CreatedBy: user.ID, } created, err = api.Store.CreateUpload(upload) if err != nil { _ = os.Remove(finalPath) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusCreated, created) } func (api *API) ListUploads(w http.ResponseWriter, r *http.Request, params map[string]string) { var uploads []models.Upload var err error if !api.requireProjectRole(w, r, params["projectId"], "viewer") { return } uploads, err = api.Store.ListUploads(params["projectId"]) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, uploads) } func (api *API) DownloadFile(w http.ResponseWriter, r *http.Request, params map[string]string) { var upload models.Upload var err error var file *os.File upload, err = api.Store.GetUpload(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "upload not found"}) return } if !api.requireProjectRole(w, r, upload.ProjectID, "viewer") { return } file, err = api.Uploads.Open(upload.StoragePath) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } defer file.Close() w.Header().Set("Content-Type", upload.ContentType) w.Header().Set("Content-Disposition", "attachment; filename=\""+sanitizeFilename(upload.Filename)+"\"") _, _ = io.Copy(w, file) } func (api *API) Health(w http.ResponseWriter, _ *http.Request, _ map[string]string) { WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (api *API) requireAdmin(w http.ResponseWriter, r *http.Request) bool { var user models.User var ok bool var principal models.ServicePrincipal user, ok = middleware.UserFromContext(r.Context()) if ok && user.IsAdmin { return true } principal, ok = middleware.PrincipalFromContext(r.Context()) if ok && principal.IsAdmin && !principal.Disabled { return true } w.WriteHeader(http.StatusForbidden) return false } func (api *API) requireProjectRole(w http.ResponseWriter, r *http.Request, projectID, required string) bool { var user models.User var principal models.ServicePrincipal var ok bool user, ok = middleware.UserFromContext(r.Context()) if ok && user.IsAdmin { return true } var role string var err error if ok { role, err = api.Store.GetProjectMemberRole(projectID, user.ID) if err != nil { w.WriteHeader(http.StatusForbidden) return false } if !roleAllows(role, required) { w.WriteHeader(http.StatusForbidden) return false } return true } principal, ok = middleware.PrincipalFromContext(r.Context()) if !ok || principal.Disabled { w.WriteHeader(http.StatusUnauthorized) return false } if principal.IsAdmin { return true } role, err = api.Store.GetPrincipalProjectRole(principal.ID, projectID) if err != nil || !roleAllows(role, required) { w.WriteHeader(http.StatusForbidden) return false } return true } func (api *API) requireRepoRole(w http.ResponseWriter, r *http.Request, repoID, required string) bool { var user models.User var principal models.ServicePrincipal var ok bool var projectIDs []string var err error var i int var role string user, ok = middleware.UserFromContext(r.Context()) if ok && user.IsAdmin { return true } projectIDs, err = api.Store.GetRepoProjectIDs(repoID) if err != nil { w.WriteHeader(http.StatusForbidden) return false } if ok { for i = 0; i < len(projectIDs); i++ { role, err = api.Store.GetProjectMemberRole(projectIDs[i], user.ID) if err != nil { continue } if roleAllows(role, required) { return true } } w.WriteHeader(http.StatusForbidden) return false } principal, ok = middleware.PrincipalFromContext(r.Context()) if !ok || principal.Disabled { w.WriteHeader(http.StatusUnauthorized) return false } if principal.IsAdmin { return true } for i = 0; i < len(projectIDs); i++ { role, err = api.Store.GetPrincipalProjectRole(principal.ID, projectIDs[i]) if err != nil { continue } if roleAllows(role, required) { return true } } w.WriteHeader(http.StatusForbidden) return false } func normalizeRole(role string) string { switch strings.ToLower(strings.TrimSpace(role)) { case "admin": return "admin" case "writer", "write": return "writer" default: return "viewer" } } 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 (api *API) getMergedTLSSettings() (models.TLSSettings, error) { var settings models.TLSSettings var saved models.TLSSettings var err error settings = models.TLSSettings{ HTTPAddrs: normalizeAddrList(api.Cfg.HTTPAddrs), HTTPSAddrs: normalizeAddrList(api.Cfg.HTTPSAddrs), TLSServerCertSource: "pki", TLSCertFile: "", TLSKeyFile: "", TLSPKIServerCertID: api.Cfg.TLSPKIServerCertID, TLSClientAuth: normalizeTLSClientAuth(api.Cfg.TLSClientAuth), TLSClientCAFile: "", TLSPKIClientCAID: api.Cfg.TLSPKIClientCAID, TLSMinVersion: normalizeTLSMinVersion(api.Cfg.TLSMinVersion), } saved, err = api.Store.GetTLSSettings() if err != nil { return settings, err } if len(saved.HTTPAddrs) > 0 { settings.HTTPAddrs = normalizeAddrList(saved.HTTPAddrs) } if len(saved.HTTPSAddrs) > 0 { settings.HTTPSAddrs = normalizeAddrList(saved.HTTPSAddrs) } settings.TLSServerCertSource = "pki" settings.TLSCertFile = "" settings.TLSKeyFile = "" if strings.TrimSpace(saved.TLSPKIServerCertID) != "" { settings.TLSPKIServerCertID = saved.TLSPKIServerCertID } if strings.TrimSpace(saved.TLSClientAuth) != "" { settings.TLSClientAuth = normalizeTLSClientAuth(saved.TLSClientAuth) } settings.TLSClientCAFile = "" if strings.TrimSpace(saved.TLSPKIClientCAID) != "" { settings.TLSPKIClientCAID = saved.TLSPKIClientCAID } if strings.TrimSpace(saved.TLSMinVersion) != "" { settings.TLSMinVersion = normalizeTLSMinVersion(saved.TLSMinVersion) } return settings, nil } func normalizeRepoType(value string) (string, bool) { var v string v = strings.ToLower(strings.TrimSpace(value)) if v == "" { return "git", true } if v == "git" || v == "rpm" || v == "docker" { return v, true } return "", false } func normalizeAddrList(values []string) []string { var out []string var i int var v string for i = 0; i < len(values); i++ { v = strings.TrimSpace(values[i]) if v == "" { continue } out = append(out, v) } return out } func normalizeTLSServerCertSource(value string) string { _ = value return "pki" } func normalizeTLSClientAuth(value string) string { var v string v = strings.ToLower(strings.TrimSpace(value)) if v == "request" || v == "require" || v == "verify_if_given" || v == "require_and_verify" { return v } return "none" } func normalizeTLSMinVersion(value string) string { var v string v = strings.ToLower(strings.TrimSpace(value)) if v == "1.0" || v == "1.1" || v == "1.3" || v == "tls1.0" || v == "tls1.1" || v == "tls1.3" { return v } return "1.2" } func tlsClientAuthNeedsCA(value string) bool { var v string v = normalizeTLSClientAuth(value) return v == "require_and_verify" || v == "verify_if_given" } func tlsClientCAConfigured(pkiCAID string) bool { return strings.TrimSpace(pkiCAID) != "" } func normalizeTLSListenerRequest(req tlsListenerRequest) models.TLSListener { var item models.TLSListener var applyAPI bool var applyGit bool var applyRPM bool var applyV2 bool applyAPI = req.ApplyPolicyAPI applyGit = req.ApplyPolicyGit applyRPM = req.ApplyPolicyRPM applyV2 = req.ApplyPolicyV2 if !applyAPI && !applyGit && !applyRPM && !applyV2 { applyAPI = true applyGit = true applyRPM = true applyV2 = true } item = models.TLSListener{ Name: strings.TrimSpace(req.Name), Enabled: req.Enabled, HTTPAddrs: normalizeAddrList(req.HTTPAddrs), HTTPSAddrs: normalizeAddrList(req.HTTPSAddrs), AuthPolicy: normalizeTLSAuthPolicy(req.AuthPolicy), ApplyPolicyAPI: applyAPI, ApplyPolicyGit: applyGit, ApplyPolicyRPM: applyRPM, ApplyPolicyV2: applyV2, ClientCertAllowlist: normalizeTLSCertAllowlist(req.ClientCertAllowlist), TLSServerCertSource: "pki", TLSCertFile: "", TLSKeyFile: "", TLSPKIServerCertID: strings.TrimSpace(req.TLSPKIServerCertID), TLSClientAuth: normalizeTLSClientAuth(req.TLSClientAuth), TLSClientCAFile: "", TLSPKIClientCAID: strings.TrimSpace(req.TLSPKIClientCAID), TLSMinVersion: normalizeTLSMinVersion(req.TLSMinVersion), } return item } func mergeTLSListener(current models.TLSListener, updated models.TLSListener) models.TLSListener { var out models.TLSListener out = current out.Name = updated.Name out.Enabled = updated.Enabled out.HTTPAddrs = updated.HTTPAddrs out.HTTPSAddrs = updated.HTTPSAddrs out.AuthPolicy = updated.AuthPolicy out.ApplyPolicyAPI = updated.ApplyPolicyAPI out.ApplyPolicyGit = updated.ApplyPolicyGit out.ApplyPolicyRPM = updated.ApplyPolicyRPM out.ApplyPolicyV2 = updated.ApplyPolicyV2 out.ClientCertAllowlist = updated.ClientCertAllowlist out.TLSServerCertSource = updated.TLSServerCertSource out.TLSCertFile = updated.TLSCertFile out.TLSKeyFile = updated.TLSKeyFile out.TLSPKIServerCertID = updated.TLSPKIServerCertID out.TLSClientAuth = updated.TLSClientAuth out.TLSClientCAFile = updated.TLSClientCAFile out.TLSPKIClientCAID = updated.TLSPKIClientCAID out.TLSMinVersion = updated.TLSMinVersion return out } func normalizeTLSAuthPolicy(value string) string { var v string v = strings.ToLower(strings.TrimSpace(value)) if v == "read_open_write_cert" || v == "read_open_write_cert_or_auth" || v == "cert_only" || v == "read_only_public" { return v } return "default" } func normalizeTLSCertAllowlist(values []string) []string { var out []string var i int var v string for i = 0; i < len(values); i++ { v = strings.ToLower(strings.TrimSpace(values[i])) if v == "" { continue } out = append(out, v) } return out } func roleAllows(actual, required string) bool { actual = normalizeRole(actual) required = normalizeRole(required) if actual == "admin" { return true } if required == "admin" { return actual == "admin" } if required == "writer" { return actual == "writer" } return true } func sanitizeFilename(name string) string { name = strings.ReplaceAll(name, "\n", "") name = strings.ReplaceAll(name, "\r", "") return name } func isSafeSubdirName(name string) bool { if name == "" { return false } if strings.Contains(name, "/") || strings.Contains(name, "\\") { return false } if strings.Contains(name, "..") { return false } if strings.ContainsAny(name, "\r\n") { return false } return true } func isSafeSubdirPath(path string) bool { var parts []string var part string path = strings.ReplaceAll(path, "\\", "/") if strings.Contains(path, "..") { return false } parts = strings.Split(path, "/") for _, part = range parts { if part == "" { continue } if !isSafeSubdirName(part) { return false } } return true } func isRepodataPath(path string) bool { var normalized string var parts []string var part string normalized = strings.Trim(path, "/") normalized = strings.ReplaceAll(normalized, "\\", "/") if normalized == "" { return false } parts = strings.Split(normalized, "/") for _, part = range parts { if strings.EqualFold(part, "repodata") { return true } } return false } func hasRepodataAncestor(root string, parent string) (bool, error) { var current string var relative string var err error var repodata string current = filepath.Clean(parent) root = filepath.Clean(root) for { relative, err = filepath.Rel(root, current) if err != nil { return false, err } if strings.HasPrefix(relative, "..") { return false, nil } repodata = filepath.Join(current, "repodata") _, err = os.Stat(repodata) if err == nil { return true, nil } if err != nil && !os.IsNotExist(err) { return false, err } if current == root { return false, nil } current = filepath.Dir(current) } } func nearestRepodataDir(root string, target string) (string, error) { var current string var relative string var err error var repodata string current = filepath.Clean(target) root = filepath.Clean(root) for { relative, err = filepath.Rel(root, current) if err != nil { return "", err } if strings.HasPrefix(relative, "..") { return "", nil } repodata = filepath.Join(current, "repodata") _, err = os.Stat(repodata) if err == nil { return current, nil } if err != nil && !os.IsNotExist(err) { return "", err } if current == root { return "", nil } current = filepath.Dir(current) } } func normalizeRPMPath(path string) string { var cleaned string cleaned = filepath.ToSlash(filepath.Clean(filepath.FromSlash(strings.TrimSpace(path)))) cleaned = strings.TrimPrefix(cleaned, "./") if cleaned == "." { return "" } return cleaned } func normalizeRPMRepoDirMode(mode string) string { var v string v = strings.ToLower(strings.TrimSpace(mode)) if v == "mirror" { return "mirror" } return "local" } func normalizeRPMMirrorIntervalSec(value int64) int64 { if value <= 0 { return 300 } if value < 10 { return 10 } return value } func validateRPMMirrorConfig(mode string, remoteURL string, connectHost string, hostHeader string, tlsServerName string) error { if normalizeRPMRepoDirMode(mode) != "mirror" { return nil } if strings.TrimSpace(remoteURL) == "" { return errors.New("remote_url is required for mirror mode") } if strings.TrimSpace(connectHost) != "" { if !strings.Contains(strings.TrimSpace(connectHost), ":") { return errors.New("connect_host must include port") } } _ = hostHeader _ = tlsServerName return nil } func pathUnderRoot(path string, root string) bool { if root == "" { return true } if path == root { return true } return strings.HasPrefix(path, root+"/") } func (api *API) findRPMMirrorRoot(repo models.Repo, relPath string) (string, error) { var dirs []models.RPMRepoDir var normalizedPath string var i int var root string var longest string var found bool var err error dirs, err = api.Store.ListRPMRepoDirs(repo.ID) if err != nil { return "", err } normalizedPath = normalizeRPMPath(relPath) for i = 0; i < len(dirs); i++ { if normalizeRPMRepoDirMode(dirs[i].Mode) != "mirror" { continue } root = normalizeRPMPath(dirs[i].Path) if !pathUnderRoot(normalizedPath, root) { continue } if !found { longest = root found = true continue } if len(root) > len(longest) { longest = root } } if !found { return "", nil } return longest, nil } func (api *API) isRPMWriteBlocked(repo models.Repo, relPath string) (bool, string, error) { var root string var err error root, err = api.findRPMMirrorRoot(repo, relPath) if err != nil { return false, "", err } if root == "" { return false, "", nil } return true, root, nil } func (api *API) allowRPMMirrorDelete(repo models.Repo, relPath string, isDir bool) (bool, error) { var root string var cfg models.RPMRepoDir var normalizedPath string var err error root, err = api.findRPMMirrorRoot(repo, relPath) if err != nil { return false, err } if root == "" { return true, nil } normalizedPath = normalizeRPMPath(relPath) if normalizedPath == normalizeRPMPath(root) { return false, nil } cfg, err = api.Store.GetRPMRepoDir(repo.ID, normalizeRPMPath(root)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return false, nil } return false, err } if !cfg.AllowDelete { return false, nil } if !isDir { return true, nil } _, err = api.Store.GetRPMRepoDir(repo.ID, normalizedPath) if err == nil { return false, nil } if err != nil && !errors.Is(err, sql.ErrNoRows) { return false, err } return true, nil } func (api *API) hasBusyMirrorRootUnder(repo models.Repo, relPath string) (bool, string, string, error) { var dirs []models.RPMRepoDir var target string var root string var full string var i int var err error target = normalizeRPMPath(relPath) dirs, err = api.Store.ListRPMRepoDirs(repo.ID) if err != nil { return false, "", "", err } for i = 0; i < len(dirs); i++ { if normalizeRPMRepoDirMode(dirs[i].Mode) != "mirror" { continue } root = normalizeRPMPath(dirs[i].Path) if !pathUnderRoot(root, target) { continue } if dirs[i].SyncRunning { return true, root, "sync_running", nil } if api.RpmMeta != nil { full = filepath.Join(repo.Path, filepath.FromSlash(root)) if api.RpmMeta.IsRunning(full) { return true, root, "metadata_running", nil } } } return false, "", "", nil } func nameHasWhitespace(name string) bool { return strings.IndexFunc(name, unicode.IsSpace) >= 0 } func slugHasWhitespace(slug string) bool { return strings.IndexFunc(slug, unicode.IsSpace) >= 0 } func contains(list []string, value string) bool { var item string for _, item = range list { if item == value { return true } } return false } func (api *API) cloneURL(projectSlug, repoName string) string { var base string var prefix string base = strings.TrimRight(api.Cfg.PublicBaseURL, "/") prefix = api.Cfg.GitHTTPPrefix if !strings.HasPrefix(prefix, "/") { prefix = "/" + prefix } return base + prefix + "/" + projectSlug + "/" + repoName + ".git" } func (api *API) rpmURL(projectSlug, repoName string) string { var base string var prefix string base = strings.TrimRight(api.Cfg.PublicBaseURL, "/") prefix = api.Cfg.RPMHTTPPrefix if !strings.HasPrefix(prefix, "/") { prefix = "/" + prefix } return base + prefix + "/" + projectSlug + "/" + repoName } func (api *API) dockerURL(projectSlug, repoName string) string { var base string base = strings.TrimRight(api.Cfg.PublicBaseURL, "/") return base + "/v2/" + projectSlug + "/" + repoName } func (api *API) getRepoResolved(repoID string) (models.Repo, error) { var repo models.Repo var err error repo, err = api.Store.GetRepo(repoID) if err != nil { return repo, err } err = api.ensureRepoPath(&repo) if err != nil { return repo, err } return repo, nil } func (api *API) ensureRepoPath(repo *models.Repo) error { var err error var projectStorageID int64 var repoStorageID int64 projectStorageID, repoStorageID, err = api.Store.GetRepoStorageIDs(repo.ID) if err != nil { return err } repo.Path = api.repoStoragePathByType(repo.Type, projectStorageID, repoStorageID) return nil } func (api *API) repoStoragePathByType(repoType string, projectID int64, repoID int64) string { var projectPart string var repoPart string projectPart = fmt.Sprintf("%016x", projectID) repoPart = fmt.Sprintf("%016x", repoID) if repoType == "git" { return filepath.Join(api.Repos.BaseDir, projectPart, repoPart+".git") } if repoType == "rpm" { return filepath.Join(api.RpmBase, projectPart, repoPart) } if repoType == "docker" { return filepath.Join(api.DockerBase, projectPart, repoPart) } return "" }