mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2025-01-02 21:58:43 +00:00
Use id to access orgs (#1873)
closes #1743 fixes: setting secrets for own user namespace - create org in database - use orgID for org related APIs Co-authored-by: 6543 <6543@obermui.de>
This commit is contained in:
parent
aec2051071
commit
e5d5ec8b47
51 changed files with 1261 additions and 392 deletions
|
@ -72,3 +72,9 @@ var RepoFlag = &cli.StringFlag{
|
|||
Aliases: []string{"repo"},
|
||||
Usage: "repository id or full-name (e.g. 134 or octocat/hello-world)",
|
||||
}
|
||||
|
||||
var OrgFlag = &cli.StringFlag{
|
||||
Name: "organization",
|
||||
Aliases: []string{"org"},
|
||||
Usage: "organization id or full-name (e.g. 123 or octocat)",
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package secret
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
|
@ -24,9 +26,9 @@ var Command = &cli.Command{
|
|||
},
|
||||
}
|
||||
|
||||
func parseTargetArgs(client woodpecker.Client, c *cli.Context) (global bool, owner string, repoID int64, err error) {
|
||||
func parseTargetArgs(client woodpecker.Client, c *cli.Context) (global bool, orgID, repoID int64, err error) {
|
||||
if c.Bool("global") {
|
||||
return true, "", -1, nil
|
||||
return true, -1, -1, nil
|
||||
}
|
||||
|
||||
repoIDOrFullName := c.String("repository")
|
||||
|
@ -34,19 +36,36 @@ func parseTargetArgs(client woodpecker.Client, c *cli.Context) (global bool, own
|
|||
repoIDOrFullName = c.Args().First()
|
||||
}
|
||||
|
||||
orgName := c.String("organization")
|
||||
if orgName != "" && repoIDOrFullName == "" {
|
||||
return false, orgName, -1, err
|
||||
orgIDOrName := c.String("organization")
|
||||
if orgIDOrName == "" && repoIDOrFullName == "" {
|
||||
if err := cli.ShowSubcommandHelp(c); err != nil {
|
||||
return false, -1, -1, err
|
||||
}
|
||||
|
||||
return false, -1, -1, fmt.Errorf("missing arguments")
|
||||
}
|
||||
|
||||
if orgName != "" && !strings.Contains(repoIDOrFullName, "/") {
|
||||
repoIDOrFullName = orgName + "/" + repoIDOrFullName
|
||||
if orgIDOrName != "" && repoIDOrFullName == "" {
|
||||
if orgID, err := strconv.ParseInt(orgIDOrName, 10, 64); err == nil {
|
||||
return false, orgID, -1, nil
|
||||
}
|
||||
|
||||
org, err := client.OrgLookup(orgIDOrName)
|
||||
if err != nil {
|
||||
return false, -1, -1, err
|
||||
}
|
||||
|
||||
return false, org.ID, -1, nil
|
||||
}
|
||||
|
||||
if orgIDOrName != "" && !strings.Contains(repoIDOrFullName, "/") {
|
||||
repoIDOrFullName = orgIDOrName + "/" + repoIDOrFullName
|
||||
}
|
||||
|
||||
repoID, err = internal.ParseRepo(client, repoIDOrFullName)
|
||||
if err != nil {
|
||||
return false, "", -1, err
|
||||
return false, -1, -1, err
|
||||
}
|
||||
|
||||
return false, "", repoID, nil
|
||||
return false, -1, repoID, nil
|
||||
}
|
||||
|
|
|
@ -21,10 +21,7 @@ var secretCreateCmd = &cli.Command{
|
|||
Name: "global",
|
||||
Usage: "global secret",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "organization",
|
||||
Usage: "organization name (e.g. octocat)",
|
||||
},
|
||||
common.OrgFlag,
|
||||
common.RepoFlag,
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
|
@ -74,7 +71,7 @@ func secretCreate(c *cli.Context) error {
|
|||
secret.Value = string(out)
|
||||
}
|
||||
|
||||
global, owner, repoID, err := parseTargetArgs(client, c)
|
||||
global, orgID, repoID, err := parseTargetArgs(client, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -83,10 +80,12 @@ func secretCreate(c *cli.Context) error {
|
|||
_, err = client.GlobalSecretCreate(secret)
|
||||
return err
|
||||
}
|
||||
if owner != "" {
|
||||
_, err = client.OrgSecretCreate(owner, secret)
|
||||
|
||||
if orgID != -1 {
|
||||
_, err = client.OrgSecretCreate(orgID, secret)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = client.SecretCreate(repoID, secret)
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -21,10 +21,7 @@ var secretInfoCmd = &cli.Command{
|
|||
Name: "global",
|
||||
Usage: "global secret",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "organization",
|
||||
Usage: "organization name (e.g. octocat)",
|
||||
},
|
||||
common.OrgFlag,
|
||||
common.RepoFlag,
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
|
@ -44,7 +41,7 @@ func secretInfo(c *cli.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
global, owner, repoID, err := parseTargetArgs(client, c)
|
||||
global, orgID, repoID, err := parseTargetArgs(client, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -55,8 +52,8 @@ func secretInfo(c *cli.Context) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if owner != "" {
|
||||
secret, err = client.OrgSecret(owner, secretName)
|
||||
} else if orgID != -1 {
|
||||
secret, err = client.OrgSecret(orgID, secretName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -22,10 +22,7 @@ var secretListCmd = &cli.Command{
|
|||
Name: "global",
|
||||
Usage: "global secret",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "organization",
|
||||
Usage: "organization name (e.g. octocat)",
|
||||
},
|
||||
common.OrgFlag,
|
||||
common.RepoFlag,
|
||||
common.FormatFlag(tmplSecretList, true),
|
||||
),
|
||||
|
@ -39,7 +36,7 @@ func secretList(c *cli.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
global, owner, repoID, err := parseTargetArgs(client, c)
|
||||
global, orgID, repoID, err := parseTargetArgs(client, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -50,8 +47,8 @@ func secretList(c *cli.Context) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if owner != "" {
|
||||
list, err = client.OrgSecretList(owner)
|
||||
} else if orgID != -1 {
|
||||
list, err = client.OrgSecretList(orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -17,10 +17,7 @@ var secretDeleteCmd = &cli.Command{
|
|||
Name: "global",
|
||||
Usage: "global secret",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "organization",
|
||||
Usage: "organization name (e.g. octocat)",
|
||||
},
|
||||
common.OrgFlag,
|
||||
common.RepoFlag,
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
|
@ -37,7 +34,7 @@ func secretDelete(c *cli.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
global, owner, repoID, err := parseTargetArgs(client, c)
|
||||
global, orgID, repoID, err := parseTargetArgs(client, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -45,8 +42,8 @@ func secretDelete(c *cli.Context) error {
|
|||
if global {
|
||||
return client.GlobalSecretDelete(secretName)
|
||||
}
|
||||
if owner != "" {
|
||||
return client.OrgSecretDelete(owner, secretName)
|
||||
if orgID != -1 {
|
||||
return client.OrgSecretDelete(orgID, secretName)
|
||||
}
|
||||
return client.SecretDelete(repoID, secretName)
|
||||
}
|
||||
|
|
|
@ -21,10 +21,7 @@ var secretUpdateCmd = &cli.Command{
|
|||
Name: "global",
|
||||
Usage: "global secret",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "organization",
|
||||
Usage: "organization name (e.g. octocat)",
|
||||
},
|
||||
common.OrgFlag,
|
||||
common.RepoFlag,
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
|
@ -71,7 +68,7 @@ func secretUpdate(c *cli.Context) error {
|
|||
secret.Value = string(out)
|
||||
}
|
||||
|
||||
global, owner, repoID, err := parseTargetArgs(client, c)
|
||||
global, orgID, repoID, err := parseTargetArgs(client, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -80,8 +77,8 @@ func secretUpdate(c *cli.Context) error {
|
|||
_, err = client.GlobalSecretUpdate(secret)
|
||||
return err
|
||||
}
|
||||
if owner != "" {
|
||||
_, err = client.OrgSecretUpdate(owner, secret)
|
||||
if orgID != -1 {
|
||||
_, err = client.OrgSecretUpdate(orgID, secret)
|
||||
return err
|
||||
}
|
||||
_, err = client.SecretUpdate(repoID, secret)
|
||||
|
|
|
@ -783,7 +783,82 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/orgs/{owner}/permissions": {
|
||||
"/org/lookup/{org_full_name}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Organizations"
|
||||
],
|
||||
"summary": "Lookup organization by full-name",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"default": "Bearer \u003cpersonal access token\u003e",
|
||||
"description": "Insert your personal access token",
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "the organizations full-name / slug",
|
||||
"name": "org_full_name",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Org"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/orgs/{org_id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Organization"
|
||||
],
|
||||
"summary": "Get organization by id",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"default": "Bearer \u003cpersonal access token\u003e",
|
||||
"description": "Insert your personal access token",
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "the organziation's id",
|
||||
"name": "org_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Org"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/orgs/{org_id}/permissions": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
|
@ -803,8 +878,8 @@ const docTemplate = `{
|
|||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "the owner's name",
|
||||
"name": "owner",
|
||||
"description": "the organziation's id",
|
||||
"name": "org_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
|
@ -822,7 +897,7 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/orgs/{owner}/secrets": {
|
||||
"/orgs/{org_id}/secrets": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
|
@ -842,8 +917,8 @@ const docTemplate = `{
|
|||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "the owner's name",
|
||||
"name": "owner",
|
||||
"description": "the org's id",
|
||||
"name": "org_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
|
@ -873,52 +948,9 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Organization secrets"
|
||||
],
|
||||
"summary": "Persist/create an organization secret",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"default": "Bearer \u003cpersonal access token\u003e",
|
||||
"description": "Insert your personal access token",
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "the owner's name",
|
||||
"name": "owner",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "the new secret",
|
||||
"name": "secretData",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Secret"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Secret"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/orgs/{owner}/secrets/{secret}": {
|
||||
"/orgs/{org_id}/secrets/{secret}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
|
@ -938,8 +970,8 @@ const docTemplate = `{
|
|||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "the owner's name",
|
||||
"name": "owner",
|
||||
"description": "the org's id",
|
||||
"name": "org_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
|
@ -979,8 +1011,8 @@ const docTemplate = `{
|
|||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "the owner's name",
|
||||
"name": "owner",
|
||||
"description": "the org's id",
|
||||
"name": "org_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
|
@ -1017,8 +1049,8 @@ const docTemplate = `{
|
|||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "the owner's name",
|
||||
"name": "owner",
|
||||
"description": "the org's id",
|
||||
"name": "org_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
|
@ -1049,6 +1081,51 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/orgs/{owner}/secrets": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Organization secrets"
|
||||
],
|
||||
"summary": "Persist/create an organization secret",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"default": "Bearer \u003cpersonal access token\u003e",
|
||||
"description": "Insert your personal access token",
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "the org's id",
|
||||
"name": "org_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "the new secret",
|
||||
"name": "secretData",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Secret"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Secret"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/pipelines": {
|
||||
"get": {
|
||||
"produces": [
|
||||
|
@ -3603,6 +3680,20 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"Org": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"is_user": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"OrgPerm": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -3845,6 +3936,9 @@ const docTemplate = `{
|
|||
"netrc_only_trusted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"org_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"owner": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
@ -86,7 +86,7 @@ func HandleAuth(c *gin.Context) {
|
|||
if len(config.Orgs) != 0 {
|
||||
teams, terr := _forge.Teams(c, tmpuser)
|
||||
if terr != nil || !config.IsMember(teams) {
|
||||
log.Error().Msgf("cannot verify team membership for %s.", u.Login)
|
||||
log.Error().Err(terr).Msgf("cannot verify team membership for %s.", u.Login)
|
||||
c.Redirect(303, "/login?error=access_denied")
|
||||
return
|
||||
}
|
||||
|
@ -111,6 +111,15 @@ func HandleAuth(c *gin.Context) {
|
|||
c.Redirect(http.StatusSeeOther, "/login?error=internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
// if another user already have activated repos on behave of that user,
|
||||
// the user was stored as org. now we adopt it to the user.
|
||||
if org, err := _store.OrgFindByName(u.Login); err == nil && org != nil {
|
||||
org.IsUser = true
|
||||
if err := _store.OrgUpdate(org); err != nil {
|
||||
log.Error().Err(err).Msgf("on user creation, could not mark org as user")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update the user meta data and authorization data.
|
||||
|
@ -127,7 +136,7 @@ func HandleAuth(c *gin.Context) {
|
|||
if len(config.Orgs) != 0 {
|
||||
teams, terr := _forge.Teams(c, u)
|
||||
if terr != nil || !config.IsMember(teams) {
|
||||
log.Error().Msgf("cannot verify team membership for %s.", u.Login)
|
||||
log.Error().Err(terr).Msgf("cannot verify team membership for %s.", u.Login)
|
||||
c.Redirect(http.StatusSeeOther, "/login?error=access_denied")
|
||||
return
|
||||
}
|
||||
|
|
|
@ -15,41 +15,149 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/woodpecker-ci/woodpecker/server"
|
||||
"github.com/woodpecker-ci/woodpecker/server/model"
|
||||
"github.com/woodpecker-ci/woodpecker/server/router/middleware/session"
|
||||
"github.com/woodpecker-ci/woodpecker/server/store"
|
||||
"github.com/woodpecker-ci/woodpecker/server/store/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetOrg
|
||||
//
|
||||
// @Summary Get organization by id
|
||||
// @Router /orgs/{org_id} [get]
|
||||
// @Produce json
|
||||
// @Success 200 {array} Org
|
||||
// @Tags Organization
|
||||
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
|
||||
// @Param org_id path string true "the organziation's id"
|
||||
func GetOrg(c *gin.Context) {
|
||||
_store := store.FromContext(c)
|
||||
|
||||
orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Error parsing org id. %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
org, err := _store.OrgGet(orgID)
|
||||
if err != nil {
|
||||
if errors.Is(err, types.RecordNotExist) {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, org)
|
||||
}
|
||||
|
||||
// GetOrgPermissions
|
||||
//
|
||||
// @Summary Get the permissions of the current user in the given organization
|
||||
// @Router /orgs/{owner}/permissions [get]
|
||||
// @Router /orgs/{org_id}/permissions [get]
|
||||
// @Produce json
|
||||
// @Success 200 {array} OrgPerm
|
||||
// @Tags Organization permissions
|
||||
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
|
||||
// @Param owner path string true "the owner's name"
|
||||
// @Param org_id path string true "the organziation's id"
|
||||
func GetOrgPermissions(c *gin.Context) {
|
||||
var (
|
||||
err error
|
||||
user = session.User(c)
|
||||
owner = c.Param("owner")
|
||||
)
|
||||
user := session.User(c)
|
||||
_store := store.FromContext(c)
|
||||
|
||||
orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Error parsing org id. %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
c.JSON(http.StatusOK, &model.OrgPerm{})
|
||||
return
|
||||
}
|
||||
|
||||
perm, err := server.Config.Services.Membership.Get(c, user, owner)
|
||||
org, err := _store.OrgGet(orgID)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Error getting membership for %q. %s", owner, err)
|
||||
c.String(http.StatusInternalServerError, "Error getting org %d. %s", orgID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if (org.IsUser && org.Name == user.Login) || user.Admin {
|
||||
c.JSON(http.StatusOK, &model.OrgPerm{
|
||||
Member: true,
|
||||
Admin: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
perm, err := server.Config.Services.Membership.Get(c, user, org.Name)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Error getting membership for %d. %s", orgID, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, perm)
|
||||
}
|
||||
|
||||
// LookupOrg
|
||||
//
|
||||
// @Summary Lookup organization by full-name
|
||||
// @Router /org/lookup/{org_full_name} [get]
|
||||
// @Produce json
|
||||
// @Success 200 {object} Org
|
||||
// @Tags Organizations
|
||||
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
|
||||
// @Param org_full_name path string true "the organizations full-name / slug"
|
||||
func LookupOrg(c *gin.Context) {
|
||||
_store := store.FromContext(c)
|
||||
|
||||
orgFullName := strings.TrimLeft(c.Param("org_full_name"), "/")
|
||||
|
||||
org, err := _store.OrgFindByName(orgFullName)
|
||||
if err != nil {
|
||||
if errors.Is(err, types.RecordNotExist) {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
_ = c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// don't leak private org infos
|
||||
if org.Private {
|
||||
user := session.User(c)
|
||||
if user == nil {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if !user.Admin && org.Name != user.Login {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
} else if !user.Admin {
|
||||
perm, err := server.Config.Services.Membership.Get(c, user, org.Name)
|
||||
if err != nil {
|
||||
log.Error().Msgf("Failed to check membership: %v", err)
|
||||
c.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
return
|
||||
}
|
||||
|
||||
if perm == nil || !perm.Member {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, org)
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ package api
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/woodpecker-ci/woodpecker/server/router/middleware/session"
|
||||
|
@ -27,19 +28,23 @@ import (
|
|||
// GetOrgSecret
|
||||
//
|
||||
// @Summary Get the named organization secret
|
||||
// @Router /orgs/{owner}/secrets/{secret} [get]
|
||||
// @Router /orgs/{org_id}/secrets/{secret} [get]
|
||||
// @Produce json
|
||||
// @Success 200 {object} Secret
|
||||
// @Tags Organization secrets
|
||||
// @Tags Organization secrets
|
||||
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
|
||||
// @Param owner path string true "the owner's name"
|
||||
// @Param org_id path string true "the org's id"
|
||||
// @Param secret path string true "the secret's name"
|
||||
func GetOrgSecret(c *gin.Context) {
|
||||
var (
|
||||
owner = c.Param("owner")
|
||||
name = c.Param("secret")
|
||||
)
|
||||
secret, err := server.Config.Services.Secrets.OrgSecretFind(owner, name)
|
||||
name := c.Param("secret")
|
||||
|
||||
orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Error parsing org id. %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
secret, err := server.Config.Services.Secrets.OrgSecretFind(orgID, name)
|
||||
if err != nil {
|
||||
handleDbGetError(c, err)
|
||||
return
|
||||
|
@ -50,19 +55,24 @@ func GetOrgSecret(c *gin.Context) {
|
|||
// GetOrgSecretList
|
||||
//
|
||||
// @Summary Get the organization secret list
|
||||
// @Router /orgs/{owner}/secrets [get]
|
||||
// @Router /orgs/{org_id}/secrets [get]
|
||||
// @Produce json
|
||||
// @Success 200 {array} Secret
|
||||
// @Tags Organization secrets
|
||||
// @Tags Organization secrets
|
||||
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
|
||||
// @Param owner path string true "the owner's name"
|
||||
// @Param org_id path string true "the org's id"
|
||||
// @Param page query int false "for response pagination, page offset number" default(1)
|
||||
// @Param perPage query int false "for response pagination, max items per page" default(50)
|
||||
// @Param perPage query int false "for response pagination, max items per page" default(50)
|
||||
func GetOrgSecretList(c *gin.Context) {
|
||||
owner := c.Param("owner")
|
||||
list, err := server.Config.Services.Secrets.OrgSecretList(owner, session.Pagination(c))
|
||||
orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Error getting secret list for %q. %s", owner, err)
|
||||
c.String(http.StatusBadRequest, "Error parsing org id. %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
list, err := server.Config.Services.Secrets.OrgSecretList(orgID, session.Pagination(c))
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Error getting secret list for %q. %s", orgID, err)
|
||||
return
|
||||
}
|
||||
// copy the secret detail to remove the sensitive
|
||||
|
@ -79,20 +89,24 @@ func GetOrgSecretList(c *gin.Context) {
|
|||
// @Router /orgs/{owner}/secrets [post]
|
||||
// @Produce json
|
||||
// @Success 200 {object} Secret
|
||||
// @Tags Organization secrets
|
||||
// @Tags Organization secrets
|
||||
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
|
||||
// @Param owner path string true "the owner's name"
|
||||
// @Param org_id path string true "the org's id"
|
||||
// @Param secretData body Secret true "the new secret"
|
||||
func PostOrgSecret(c *gin.Context) {
|
||||
owner := c.Param("owner")
|
||||
orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Error parsing org id. %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
in := new(model.Secret)
|
||||
if err := c.Bind(in); err != nil {
|
||||
c.String(http.StatusBadRequest, "Error parsing org %q secret. %s", owner, err)
|
||||
c.String(http.StatusBadRequest, "Error parsing org %q secret. %s", orgID, err)
|
||||
return
|
||||
}
|
||||
secret := &model.Secret{
|
||||
Owner: owner,
|
||||
OrgID: orgID,
|
||||
Name: in.Name,
|
||||
Value: in.Value,
|
||||
Events: in.Events,
|
||||
|
@ -100,11 +114,11 @@ func PostOrgSecret(c *gin.Context) {
|
|||
PluginsOnly: in.PluginsOnly,
|
||||
}
|
||||
if err := secret.Validate(); err != nil {
|
||||
c.String(http.StatusUnprocessableEntity, "Error inserting org %q secret. %s", owner, err)
|
||||
c.String(http.StatusUnprocessableEntity, "Error inserting org %q secret. %s", orgID, err)
|
||||
return
|
||||
}
|
||||
if err := server.Config.Services.Secrets.OrgSecretCreate(owner, secret); err != nil {
|
||||
c.String(http.StatusInternalServerError, "Error inserting org %q secret %q. %s", owner, in.Name, err)
|
||||
if err := server.Config.Services.Secrets.OrgSecretCreate(orgID, secret); err != nil {
|
||||
c.String(http.StatusInternalServerError, "Error inserting org %q secret %q. %s", orgID, in.Name, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, secret.Copy())
|
||||
|
@ -113,28 +127,30 @@ func PostOrgSecret(c *gin.Context) {
|
|||
// PatchOrgSecret
|
||||
//
|
||||
// @Summary Update an organization secret
|
||||
// @Router /orgs/{owner}/secrets/{secret} [patch]
|
||||
// @Router /orgs/{org_id}/secrets/{secret} [patch]
|
||||
// @Produce json
|
||||
// @Success 200 {object} Secret
|
||||
// @Tags Organization secrets
|
||||
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
|
||||
// @Param owner path string true "the owner's name"
|
||||
// @Param org_id path string true "the org's id"
|
||||
// @Param secret path string true "the secret's name"
|
||||
// @Param secretData body Secret true "the update secret data"
|
||||
// @Param secretData body Secret true "the update secret data"
|
||||
func PatchOrgSecret(c *gin.Context) {
|
||||
var (
|
||||
owner = c.Param("owner")
|
||||
name = c.Param("secret")
|
||||
)
|
||||
name := c.Param("secret")
|
||||
orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Error parsing org id. %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
in := new(model.Secret)
|
||||
err := c.Bind(in)
|
||||
err = c.Bind(in)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Error parsing secret. %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
secret, err := server.Config.Services.Secrets.OrgSecretFind(owner, name)
|
||||
secret, err := server.Config.Services.Secrets.OrgSecretFind(orgID, name)
|
||||
if err != nil {
|
||||
handleDbGetError(c, err)
|
||||
return
|
||||
|
@ -151,11 +167,11 @@ func PatchOrgSecret(c *gin.Context) {
|
|||
secret.PluginsOnly = in.PluginsOnly
|
||||
|
||||
if err := secret.Validate(); err != nil {
|
||||
c.String(http.StatusUnprocessableEntity, "Error updating org %q secret. %s", owner, err)
|
||||
c.String(http.StatusUnprocessableEntity, "Error updating org %q secret. %s", orgID, err)
|
||||
return
|
||||
}
|
||||
if err := server.Config.Services.Secrets.OrgSecretUpdate(owner, secret); err != nil {
|
||||
c.String(http.StatusInternalServerError, "Error updating org %q secret %q. %s", owner, in.Name, err)
|
||||
if err := server.Config.Services.Secrets.OrgSecretUpdate(orgID, secret); err != nil {
|
||||
c.String(http.StatusInternalServerError, "Error updating org %q secret %q. %s", orgID, in.Name, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, secret.Copy())
|
||||
|
@ -164,19 +180,22 @@ func PatchOrgSecret(c *gin.Context) {
|
|||
// DeleteOrgSecret
|
||||
//
|
||||
// @Summary Delete the named secret from an organization
|
||||
// @Router /orgs/{owner}/secrets/{secret} [delete]
|
||||
// @Router /orgs/{org_id}/secrets/{secret} [delete]
|
||||
// @Produce plain
|
||||
// @Success 200
|
||||
// @Tags Organization secrets
|
||||
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
|
||||
// @Param owner path string true "the owner's name"
|
||||
// @Param secret path string true "the secret's name"
|
||||
// @Param org_id path string true "the org's id"
|
||||
// @Param secret path string true "the secret's name"
|
||||
func DeleteOrgSecret(c *gin.Context) {
|
||||
var (
|
||||
owner = c.Param("owner")
|
||||
name = c.Param("secret")
|
||||
)
|
||||
if err := server.Config.Services.Secrets.OrgSecretDelete(owner, name); err != nil {
|
||||
name := c.Param("secret")
|
||||
orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Error parsing org id. %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := server.Config.Services.Secrets.OrgSecretDelete(orgID, name); err != nil {
|
||||
handleDbGetError(c, err)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -119,6 +119,31 @@ func PostRepo(c *gin.Context) {
|
|||
sig,
|
||||
)
|
||||
|
||||
// find org of repo
|
||||
var org *model.Org
|
||||
org, err = _store.OrgFindByName(repo.Owner)
|
||||
if err != nil && !errors.Is(err, types.RecordNotExist) {
|
||||
c.String(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// create an org if it doesn't exist yet
|
||||
if errors.Is(err, types.RecordNotExist) {
|
||||
org, err = forge.Org(c, user, repo.Owner)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Could not fetch organization from forge.")
|
||||
return
|
||||
}
|
||||
|
||||
err = _store.OrgCreate(org)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
repo.OrgID = org.ID
|
||||
|
||||
err = forge.Activate(c, user, repo, link)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, err.Error())
|
||||
|
|
25
server/cache/membership.go
vendored
25
server/cache/membership.go
vendored
|
@ -16,6 +16,7 @@ package cache
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/woodpecker-ci/woodpecker/server/forge"
|
||||
|
@ -27,37 +28,37 @@ import (
|
|||
// MembershipService is a service to check for user membership.
|
||||
type MembershipService interface {
|
||||
// Get returns if the user is a member of the organization.
|
||||
Get(ctx context.Context, u *model.User, name string) (*model.OrgPerm, error)
|
||||
Get(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error)
|
||||
}
|
||||
|
||||
type membershipCache struct {
|
||||
Forge forge.Forge
|
||||
Cache *ttlcache.Cache[string, *model.OrgPerm]
|
||||
TTL time.Duration
|
||||
forge forge.Forge
|
||||
cache *ttlcache.Cache[string, *model.OrgPerm]
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewMembershipService creates a new membership service.
|
||||
func NewMembershipService(f forge.Forge) MembershipService {
|
||||
return &membershipCache{
|
||||
TTL: 10 * time.Minute,
|
||||
Forge: f,
|
||||
Cache: ttlcache.New(ttlcache.WithDisableTouchOnHit[string, *model.OrgPerm]()),
|
||||
ttl: 10 * time.Minute,
|
||||
forge: f,
|
||||
cache: ttlcache.New(ttlcache.WithDisableTouchOnHit[string, *model.OrgPerm]()),
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns if the user is a member of the organization.
|
||||
func (c *membershipCache) Get(ctx context.Context, u *model.User, name string) (*model.OrgPerm, error) {
|
||||
key := u.Login + "/" + name
|
||||
func (c *membershipCache) Get(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error) {
|
||||
key := fmt.Sprintf("%s-%s", u.ForgeRemoteID, org)
|
||||
// Error can be safely ignored, as cache can only return error from loaders.
|
||||
item, _ := c.Cache.Get(key)
|
||||
item, _ := c.cache.Get(key)
|
||||
if item != nil && !item.IsExpired() {
|
||||
return item.Value(), nil
|
||||
}
|
||||
|
||||
perm, err := c.Forge.OrgMembership(ctx, u, name)
|
||||
perm, err := c.forge.OrgMembership(ctx, u, org)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Cache.Set(key, perm, c.TTL)
|
||||
c.cache.Set(key, perm, c.ttl)
|
||||
return perm, nil
|
||||
}
|
||||
|
|
|
@ -334,6 +334,18 @@ func (c *config) OrgMembership(ctx context.Context, u *model.User, owner string)
|
|||
return &model.OrgPerm{Member: perm != "", Admin: perm == "owner"}, nil
|
||||
}
|
||||
|
||||
func (c *config) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) {
|
||||
workspace, err := c.newClient(ctx, u).GetWorkspace(owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.Org{
|
||||
Name: workspace.Slug,
|
||||
IsUser: false, // bitbucket uses workspaces (similar to orgs) for teams and single users so we can not distinguish between them
|
||||
}, nil
|
||||
}
|
||||
|
||||
// helper function to return the bitbucket oauth2 client
|
||||
func (c *config) newClient(ctx context.Context, u *model.User) *internal.Client {
|
||||
if u == nil {
|
||||
|
|
|
@ -226,6 +226,13 @@ func (c *Client) ListPullRequests(owner, name string, opts *ListOpts) ([]*PullRe
|
|||
return out.Values, err
|
||||
}
|
||||
|
||||
func (c *Client) GetWorkspace(name string) (*Workspace, error) {
|
||||
out := new(Workspace)
|
||||
uri := fmt.Sprintf(pathWorkspace, c.base, name)
|
||||
_, err := c.do(uri, get, nil, out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *Client) do(rawurl, method string, in, out interface{}) (*string, error) {
|
||||
uri, err := url.Parse(rawurl)
|
||||
if err != nil {
|
||||
|
|
|
@ -52,7 +52,7 @@ type Forge interface {
|
|||
// Repos fetches a list of repos from the forge.
|
||||
Repos(ctx context.Context, u *model.User) ([]*model.Repo, error)
|
||||
|
||||
// File fetches a file from the forge repository and returns in string
|
||||
// File fetches a file from the forge repository and returns it in string
|
||||
// format.
|
||||
File(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]byte, error)
|
||||
|
||||
|
@ -89,7 +89,10 @@ type Forge interface {
|
|||
|
||||
// OrgMembership returns if user is member of organization and if user
|
||||
// is admin/owner in that organization.
|
||||
OrgMembership(ctx context.Context, u *model.User, owner string) (*model.OrgPerm, error)
|
||||
OrgMembership(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error)
|
||||
|
||||
// Org fetches the organization from the forge by name. If the name is a user an org with type user is returned.
|
||||
Org(ctx context.Context, u *model.User, org string) (*model.Org, error)
|
||||
}
|
||||
|
||||
// Refresher refreshes an oauth token and expiration for the given user. It
|
||||
|
|
|
@ -528,6 +528,32 @@ func (c *Gitea) OrgMembership(ctx context.Context, u *model.User, owner string)
|
|||
return &model.OrgPerm{Member: member, Admin: perm.IsAdmin || perm.IsOwner}, nil
|
||||
}
|
||||
|
||||
func (c *Gitea) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) {
|
||||
client, err := c.newClientToken(ctx, u.Token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, _, err := client.GetUserInfo(owner)
|
||||
if user != nil && err == nil {
|
||||
return &model.Org{
|
||||
Name: user.UserName,
|
||||
IsUser: true,
|
||||
Private: user.Visibility != gitea.VisibleTypePublic,
|
||||
}, nil
|
||||
}
|
||||
|
||||
org, _, err := client.GetOrg(owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.Org{
|
||||
Name: org.UserName,
|
||||
Private: gitea.VisibleType(org.Visibility) != gitea.VisibleTypePublic,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// helper function to return the Gitea client with Token
|
||||
func (c *Gitea) newClientToken(ctx context.Context, token string) (*gitea.Client, error) {
|
||||
httpClient := &http.Client{}
|
||||
|
|
|
@ -350,6 +350,27 @@ func (c *client) OrgMembership(ctx context.Context, u *model.User, owner string)
|
|||
return &model.OrgPerm{Member: org.GetState() == "active", Admin: org.GetRole() == "admin"}, nil
|
||||
}
|
||||
|
||||
func (c *client) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) {
|
||||
client := c.newClientToken(ctx, u.Token)
|
||||
|
||||
user, _, err := client.Users.Get(ctx, owner)
|
||||
if user != nil && err == nil {
|
||||
return &model.Org{
|
||||
Name: user.GetName(),
|
||||
IsUser: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
org, _, err := client.Organizations.Get(ctx, owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.Org{
|
||||
Name: org.GetName(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// helper function to return the GitHub oauth2 context using an HTTPClient that
|
||||
// disables TLS verification if disabled in the forge settings.
|
||||
func (c *client) newContext(ctx context.Context) context.Context {
|
||||
|
|
|
@ -680,6 +680,48 @@ func (g *GitLab) OrgMembership(ctx context.Context, u *model.User, owner string)
|
|||
return &model.OrgPerm{}, nil
|
||||
}
|
||||
|
||||
func (g *GitLab) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) {
|
||||
client, err := newClient(g.url, u.Token, g.SkipVerify)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
users, _, err := client.Users.ListUsers(&gitlab.ListUsersOptions{
|
||||
ListOptions: gitlab.ListOptions{
|
||||
Page: 1,
|
||||
PerPage: 1,
|
||||
},
|
||||
Username: gitlab.String(owner),
|
||||
})
|
||||
if len(users) == 1 && err == nil {
|
||||
return &model.Org{
|
||||
Name: users[0].Username,
|
||||
IsUser: true,
|
||||
Private: users[0].PrivateProfile,
|
||||
}, nil
|
||||
}
|
||||
|
||||
groups, _, err := client.Groups.ListGroups(&gitlab.ListGroupsOptions{
|
||||
ListOptions: gitlab.ListOptions{
|
||||
Page: 1,
|
||||
PerPage: 1,
|
||||
},
|
||||
Search: gitlab.String(owner),
|
||||
}, gitlab.WithContext(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(groups) != 1 {
|
||||
return nil, fmt.Errorf("could not find org %s", owner)
|
||||
}
|
||||
|
||||
return &model.Org{
|
||||
Name: groups[0].Name,
|
||||
Private: groups[0].Visibility != gitlab.PublicVisibility,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (g *GitLab) loadChangedFilesFromMergeRequest(ctx context.Context, tmpRepo *model.Repo, pipeline *model.Pipeline, mergeIID int) (*model.Pipeline, error) {
|
||||
_store, ok := store.TryFromContext(ctx)
|
||||
if !ok {
|
||||
|
|
|
@ -274,17 +274,43 @@ func (_m *Forge) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) {
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// OrgMembership provides a mock function with given fields: ctx, u, owner
|
||||
func (_m *Forge) OrgMembership(ctx context.Context, u *model.User, owner string) (*model.OrgPerm, error) {
|
||||
ret := _m.Called(ctx, u, owner)
|
||||
// Org provides a mock function with given fields: ctx, u, org
|
||||
func (_m *Forge) Org(ctx context.Context, u *model.User, org string) (*model.Org, error) {
|
||||
ret := _m.Called(ctx, u, org)
|
||||
|
||||
var r0 *model.Org
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *model.User, string) (*model.Org, error)); ok {
|
||||
return rf(ctx, u, org)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *model.User, string) *model.Org); ok {
|
||||
r0 = rf(ctx, u, org)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Org)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *model.User, string) error); ok {
|
||||
r1 = rf(ctx, u, org)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// OrgMembership provides a mock function with given fields: ctx, u, org
|
||||
func (_m *Forge) OrgMembership(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error) {
|
||||
ret := _m.Called(ctx, u, org)
|
||||
|
||||
var r0 *model.OrgPerm
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *model.User, string) (*model.OrgPerm, error)); ok {
|
||||
return rf(ctx, u, owner)
|
||||
return rf(ctx, u, org)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *model.User, string) *model.OrgPerm); ok {
|
||||
r0 = rf(ctx, u, owner)
|
||||
r0 = rf(ctx, u, org)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.OrgPerm)
|
||||
|
@ -292,7 +318,7 @@ func (_m *Forge) OrgMembership(ctx context.Context, u *model.User, owner string)
|
|||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *model.User, string) error); ok {
|
||||
r1 = rf(ctx, u, owner)
|
||||
r1 = rf(ctx, u, org)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
|
29
server/model/org.go
Normal file
29
server/model/org.go
Normal file
|
@ -0,0 +1,29 @@
|
|||
// Copyright 2023 Woodpecker Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package model
|
||||
|
||||
// Org represents an organization.
|
||||
type Org struct {
|
||||
ID int64 `json:"id,omitempty" xorm:"pk autoincr 'id'"`
|
||||
Name string `json:"name" xorm:"UNIQUE 'name'"`
|
||||
IsUser bool `json:"is_user" xorm:"is_user"`
|
||||
// if name lookup has to check for membership or not
|
||||
Private bool `json:"-" xorm:"private"`
|
||||
} // @name Org
|
||||
|
||||
// TableName return database table name for xorm
|
||||
func (Org) TableName() string {
|
||||
return "orgs"
|
||||
}
|
|
@ -26,6 +26,7 @@ type Repo struct {
|
|||
UserID int64 `json:"-" xorm:"repo_user_id"`
|
||||
// ForgeRemoteID is the unique identifier for the repository on the forge.
|
||||
ForgeRemoteID ForgeRemoteID `json:"forge_remote_id" xorm:"forge_remote_id"`
|
||||
OrgID int64 `json:"org_id" xorm:"repo_org_id"`
|
||||
Owner string `json:"owner" xorm:"UNIQUE(name) 'repo_owner'"`
|
||||
Name string `json:"name" xorm:"UNIQUE(name) 'repo_name'"`
|
||||
FullName string `json:"full_name" xorm:"UNIQUE 'repo_full_name'"`
|
||||
|
|
|
@ -40,11 +40,11 @@ type SecretService interface {
|
|||
SecretUpdate(*Repo, *Secret) error
|
||||
SecretDelete(*Repo, string) error
|
||||
// Organization secrets
|
||||
OrgSecretFind(string, string) (*Secret, error)
|
||||
OrgSecretList(string, *ListOptions) ([]*Secret, error)
|
||||
OrgSecretCreate(string, *Secret) error
|
||||
OrgSecretUpdate(string, *Secret) error
|
||||
OrgSecretDelete(string, string) error
|
||||
OrgSecretFind(int64, string) (*Secret, error)
|
||||
OrgSecretList(int64, *ListOptions) ([]*Secret, error)
|
||||
OrgSecretCreate(int64, *Secret) error
|
||||
OrgSecretUpdate(int64, *Secret) error
|
||||
OrgSecretDelete(int64, string) error
|
||||
// Global secrets
|
||||
GlobalSecretFind(string) (*Secret, error)
|
||||
GlobalSecretList(*ListOptions) ([]*Secret, error)
|
||||
|
@ -60,8 +60,8 @@ type SecretStore interface {
|
|||
SecretCreate(*Secret) error
|
||||
SecretUpdate(*Secret) error
|
||||
SecretDelete(*Secret) error
|
||||
OrgSecretFind(string, string) (*Secret, error)
|
||||
OrgSecretList(string, *ListOptions) ([]*Secret, error)
|
||||
OrgSecretFind(int64, string) (*Secret, error)
|
||||
OrgSecretList(int64, *ListOptions) ([]*Secret, error)
|
||||
GlobalSecretFind(string) (*Secret, error)
|
||||
GlobalSecretList(*ListOptions) ([]*Secret, error)
|
||||
SecretListAll() ([]*Secret, error)
|
||||
|
@ -70,7 +70,7 @@ type SecretStore interface {
|
|||
// Secret represents a secret variable, such as a password or token.
|
||||
type Secret struct {
|
||||
ID int64 `json:"id" xorm:"pk autoincr 'secret_id'"`
|
||||
Owner string `json:"-" xorm:"NOT NULL DEFAULT '' UNIQUE(s) INDEX 'secret_owner'"`
|
||||
OrgID int64 `json:"-" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_org_id'"`
|
||||
RepoID int64 `json:"-" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_repo_id'"`
|
||||
Name string `json:"name" xorm:"NOT NULL UNIQUE(s) INDEX 'secret_name'"`
|
||||
Value string `json:"value,omitempty" xorm:"TEXT 'secret_value'"`
|
||||
|
@ -93,12 +93,12 @@ func (s *Secret) BeforeInsert() {
|
|||
|
||||
// Global secret.
|
||||
func (s Secret) Global() bool {
|
||||
return s.RepoID == 0 && s.Owner == ""
|
||||
return s.RepoID == 0 && s.OrgID == 0
|
||||
}
|
||||
|
||||
// Organization secret.
|
||||
func (s Secret) Organization() bool {
|
||||
return s.RepoID == 0 && s.Owner != ""
|
||||
return s.RepoID == 0 && s.OrgID != 0
|
||||
}
|
||||
|
||||
// Match returns true if an image and event match the restricted list.
|
||||
|
@ -155,7 +155,7 @@ func (s *Secret) Validate() error {
|
|||
func (s *Secret) Copy() *Secret {
|
||||
return &Secret{
|
||||
ID: s.ID,
|
||||
Owner: s.Owner,
|
||||
OrgID: s.OrgID,
|
||||
RepoID: s.RepoID,
|
||||
Name: s.Name,
|
||||
Images: s.Images,
|
||||
|
|
|
@ -99,7 +99,7 @@ func (wrapper *EncryptedSecretStore) SecretDelete(secret *model.Secret) error {
|
|||
return wrapper.store.SecretDelete(secret)
|
||||
}
|
||||
|
||||
func (wrapper *EncryptedSecretStore) OrgSecretFind(s, s2 string) (*model.Secret, error) {
|
||||
func (wrapper *EncryptedSecretStore) OrgSecretFind(s int64, s2 string) (*model.Secret, error) {
|
||||
result, err := wrapper.store.OrgSecretFind(s, s2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -112,7 +112,7 @@ func (wrapper *EncryptedSecretStore) OrgSecretFind(s, s2 string) (*model.Secret,
|
|||
return result, nil
|
||||
}
|
||||
|
||||
func (wrapper *EncryptedSecretStore) OrgSecretList(s string, p *model.ListOptions) ([]*model.Secret, error) {
|
||||
func (wrapper *EncryptedSecretStore) OrgSecretList(s int64, p *model.ListOptions) ([]*model.Secret, error) {
|
||||
results, err := wrapper.store.OrgSecretList(s, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
@ -86,23 +86,23 @@ func (b *builtin) SecretDelete(repo *model.Repo, name string) error {
|
|||
return b.store.SecretDelete(secret)
|
||||
}
|
||||
|
||||
func (b *builtin) OrgSecretFind(owner, name string) (*model.Secret, error) {
|
||||
func (b *builtin) OrgSecretFind(owner int64, name string) (*model.Secret, error) {
|
||||
return b.store.OrgSecretFind(owner, name)
|
||||
}
|
||||
|
||||
func (b *builtin) OrgSecretList(owner string, p *model.ListOptions) ([]*model.Secret, error) {
|
||||
func (b *builtin) OrgSecretList(owner int64, p *model.ListOptions) ([]*model.Secret, error) {
|
||||
return b.store.OrgSecretList(owner, p)
|
||||
}
|
||||
|
||||
func (b *builtin) OrgSecretCreate(_ string, in *model.Secret) error {
|
||||
func (b *builtin) OrgSecretCreate(_ int64, in *model.Secret) error {
|
||||
return b.store.SecretCreate(in)
|
||||
}
|
||||
|
||||
func (b *builtin) OrgSecretUpdate(_ string, in *model.Secret) error {
|
||||
func (b *builtin) OrgSecretUpdate(_ int64, in *model.Secret) error {
|
||||
return b.store.SecretUpdate(in)
|
||||
}
|
||||
|
||||
func (b *builtin) OrgSecretDelete(owner, name string) error {
|
||||
func (b *builtin) OrgSecretDelete(owner int64, name string) error {
|
||||
secret, err := b.store.OrgSecretFind(owner, name)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
@ -46,13 +46,15 @@ func apiRoutes(e *gin.Engine) {
|
|||
users.DELETE("/:login", api.DeleteUser)
|
||||
}
|
||||
|
||||
orgBase := apiBase.Group("/orgs/:owner")
|
||||
apiBase.GET("/orgs/lookup/*org_full_name", api.LookupOrg)
|
||||
orgBase := apiBase.Group("/orgs/:org_id")
|
||||
{
|
||||
orgBase.GET("/permissions", api.GetOrgPermissions)
|
||||
|
||||
org := orgBase.Group("")
|
||||
{
|
||||
org.Use(session.MustOrgMember(true))
|
||||
org.GET("", api.GetOrg)
|
||||
org.GET("/secrets", api.GetOrgSecretList)
|
||||
org.POST("/secrets", api.PostOrgSecret)
|
||||
org.GET("/secrets/:secret", api.GetOrgSecret)
|
||||
|
|
|
@ -16,6 +16,7 @@ package session
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/woodpecker-ci/woodpecker/server"
|
||||
"github.com/woodpecker-ci/woodpecker/server/model"
|
||||
|
@ -117,36 +118,47 @@ func MustUser() gin.HandlerFunc {
|
|||
|
||||
func MustOrgMember(admin bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
_store := store.FromContext(c)
|
||||
|
||||
user := User(c)
|
||||
owner := c.Param("owner")
|
||||
if user == nil {
|
||||
c.String(http.StatusUnauthorized, "User not authorized")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if owner == "" {
|
||||
c.String(http.StatusForbidden, "User not authorized")
|
||||
c.Abort()
|
||||
|
||||
orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Error parsing org id. %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
org, err := _store.OrgGet(orgID)
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "Organization not found")
|
||||
return
|
||||
}
|
||||
|
||||
// User can access his own, admin can access all
|
||||
if user.Login == owner || user.Admin {
|
||||
if (org.Name == user.Login) || user.Admin {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
perm, err := server.Config.Services.Membership.Get(c, user, owner)
|
||||
perm, err := server.Config.Services.Membership.Get(c, user, org.Name)
|
||||
if err != nil {
|
||||
log.Error().Msgf("Failed to check membership: %v", err)
|
||||
c.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if perm == nil || (!admin && !perm.Member) || (admin && !perm.Admin) {
|
||||
c.String(http.StatusForbidden, "User not authorized")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2018 Drone.IO Inc.
|
||||
// Copyright 2023 Woodpecker Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
|
112
server/store/datastore/migration/021_add_orgs.go
Normal file
112
server/store/datastore/migration/021_add_orgs.go
Normal file
|
@ -0,0 +1,112 @@
|
|||
// Copyright 2022 Woodpecker Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package migration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/woodpecker-ci/woodpecker/server/model"
|
||||
)
|
||||
|
||||
type oldSecret021 struct {
|
||||
ID int64 `json:"id" xorm:"pk autoincr 'secret_id'"`
|
||||
Owner string `json:"-" xorm:"'secret_owner'"`
|
||||
OrgID int64 `json:"-" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_org_id'"`
|
||||
RepoID int64 `json:"-" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_repo_id'"`
|
||||
Name string `json:"name" xorm:"NOT NULL UNIQUE(s) INDEX 'secret_name'"`
|
||||
}
|
||||
|
||||
func (oldSecret021) TableName() string {
|
||||
return "secrets"
|
||||
}
|
||||
|
||||
var addOrgs = task{
|
||||
name: "add-orgs",
|
||||
required: true,
|
||||
fn: func(sess *xorm.Session) error {
|
||||
if exist, err := sess.IsTableExist("orgs"); exist && err == nil {
|
||||
if err := sess.DropTable("orgs"); err != nil {
|
||||
return fmt.Errorf("drop old orgs table failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := sess.Sync(new(model.Org), new(model.Repo), new(model.User)); err != nil {
|
||||
return fmt.Errorf("sync new models failed: %w", err)
|
||||
}
|
||||
|
||||
// make sure the columns exist before removing them
|
||||
if err := sess.Sync(new(oldSecret021)); err != nil {
|
||||
return fmt.Errorf("sync old secrets models failed: %w", err)
|
||||
}
|
||||
|
||||
// get all org names from repos
|
||||
var repos []*model.Repo
|
||||
if err := sess.Find(&repos); err != nil {
|
||||
return fmt.Errorf("find all repos failed: %w", err)
|
||||
}
|
||||
|
||||
orgs := make(map[string]*model.Org)
|
||||
users := make(map[string]bool)
|
||||
for _, repo := range repos {
|
||||
orgName := strings.ToLower(repo.Owner)
|
||||
|
||||
// check if it's a registered user
|
||||
if _, ok := users[orgName]; !ok {
|
||||
exist, err := sess.Where("user_login = ?", orgName).Exist(new(model.User))
|
||||
if err != nil {
|
||||
return fmt.Errorf("check if user '%s' exist failed: %w", orgName, err)
|
||||
}
|
||||
users[orgName] = exist
|
||||
}
|
||||
|
||||
// create org if not already created
|
||||
if _, ok := orgs[orgName]; !ok {
|
||||
org := &model.Org{
|
||||
Name: orgName,
|
||||
IsUser: users[orgName],
|
||||
}
|
||||
if _, err := sess.Insert(org); err != nil {
|
||||
return fmt.Errorf("insert org %#v failed: %w", org, err)
|
||||
}
|
||||
orgs[orgName] = org
|
||||
|
||||
// update org secrets
|
||||
var secrets []*oldSecret021
|
||||
if err := sess.Where(builder.Eq{"secret_owner": orgName, "secret_repo_id": 0}).Find(&secrets); err != nil {
|
||||
return fmt.Errorf("get org secrets failed: %w", err)
|
||||
}
|
||||
|
||||
for _, secret := range secrets {
|
||||
secret.OrgID = org.ID
|
||||
if _, err := sess.ID(secret.ID).Cols("secret_org_id").Update(secret); err != nil {
|
||||
return fmt.Errorf("update org secret %d failed: %w", secret.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update the repo
|
||||
repo.OrgID = orgs[orgName].ID
|
||||
if _, err := sess.ID(repo.ID).Cols("repo_org_id").Update(repo); err != nil {
|
||||
return fmt.Errorf("update repos failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return dropTableColumns(sess, "secrets", "secret_owner")
|
||||
},
|
||||
}
|
|
@ -53,6 +53,7 @@ var migrationTasks = []*task{
|
|||
&initLogsEntriesTable,
|
||||
&migrateLogs2LogEntries,
|
||||
&parentStepsToWorkflows,
|
||||
&addOrgs,
|
||||
}
|
||||
|
||||
var allBeans = []interface{}{
|
||||
|
@ -72,6 +73,7 @@ var allBeans = []interface{}{
|
|||
new(model.Cron),
|
||||
new(model.Redirection),
|
||||
new(model.Workflow),
|
||||
new(model.Org),
|
||||
}
|
||||
|
||||
type migrations struct {
|
||||
|
@ -106,39 +108,47 @@ func Migrate(e *xorm.Engine) error {
|
|||
e.SetDisableGlobalCache(true)
|
||||
|
||||
if err := e.Sync(new(migrations)); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("error to create migrations table: %w", err)
|
||||
}
|
||||
|
||||
sess := e.NewSession()
|
||||
defer sess.Close()
|
||||
if err := sess.Begin(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("could not create initial migration session: %w", err)
|
||||
}
|
||||
|
||||
// check if we have a fresh installation or need to check for migrations
|
||||
c, err := sess.Count(new(migrations))
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("could not count migrations: %w", err)
|
||||
}
|
||||
|
||||
if c == 0 {
|
||||
if err := initNew(sess); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("could not init a new database: %w", err)
|
||||
}
|
||||
return sess.Commit()
|
||||
if err := sess.Commit(); err != nil {
|
||||
return fmt.Errorf("could not commit initial migration session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := sess.Commit(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("could not commit initial migration session: %w", err)
|
||||
}
|
||||
|
||||
if err := runTasks(e, migrationTasks); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("run tasks failed: %w", err)
|
||||
}
|
||||
|
||||
e.SetDisableGlobalCache(false)
|
||||
|
||||
return syncAll(e)
|
||||
if err := syncAll(e); err != nil {
|
||||
return fmt.Errorf("msg: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runTasks(e *xorm.Engine, tasks []*task) error {
|
||||
|
@ -166,17 +176,16 @@ func runTasks(e *xorm.Engine, tasks []*task) error {
|
|||
sess := e.NewSession().NoCache()
|
||||
defer sess.Close()
|
||||
if err := sess.Begin(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("could not begin session for '%s': %w", task.name, err)
|
||||
}
|
||||
|
||||
if taskErr = task.fn(sess); taskErr != nil {
|
||||
aliveMsgCancel(nil)
|
||||
if err2 := sess.Rollback(); err2 != nil {
|
||||
taskErr = errors.Join(taskErr, err2)
|
||||
if err := sess.Rollback(); err != nil {
|
||||
taskErr = errors.Join(taskErr, err)
|
||||
}
|
||||
}
|
||||
if err := sess.Commit(); err != nil {
|
||||
return err
|
||||
} else if err := sess.Commit(); err != nil {
|
||||
return fmt.Errorf("could not commit session for '%s': %w", task.name, err)
|
||||
}
|
||||
} else if task.engineFn != nil {
|
||||
taskErr = task.engineFn(e)
|
||||
|
@ -189,7 +198,7 @@ func runTasks(e *xorm.Engine, tasks []*task) error {
|
|||
aliveMsgCancel(nil)
|
||||
if taskErr != nil {
|
||||
if task.required {
|
||||
return taskErr
|
||||
return fmt.Errorf("migration task '%s' failed: %w", task.name, taskErr)
|
||||
}
|
||||
log.Error().Err(taskErr).Msgf("migration task '%s' failed but is not required", task.name)
|
||||
continue
|
||||
|
@ -197,7 +206,7 @@ func runTasks(e *xorm.Engine, tasks []*task) error {
|
|||
log.Debug().Msgf("migration task '%s' done", task.name)
|
||||
|
||||
if _, err := e.Insert(&migrations{task.name}); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("migration task '%s' could not be marked as finished: %w", task.name, err)
|
||||
}
|
||||
|
||||
migCache[task.name] = true
|
||||
|
|
59
server/store/datastore/org.go
Normal file
59
server/store/datastore/org.go
Normal file
|
@ -0,0 +1,59 @@
|
|||
// Copyright 2022 Woodpecker Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/woodpecker-ci/woodpecker/server/model"
|
||||
)
|
||||
|
||||
func (s storage) OrgCreate(org *model.Org) error {
|
||||
// sanitize
|
||||
org.Name = strings.ToLower(org.Name)
|
||||
// insert
|
||||
_, err := s.engine.Insert(org)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s storage) OrgGet(id int64) (*model.Org, error) {
|
||||
org := new(model.Org)
|
||||
return org, wrapGet(s.engine.ID(id).Get(org))
|
||||
}
|
||||
|
||||
func (s storage) OrgUpdate(org *model.Org) error {
|
||||
// sanitize
|
||||
org.Name = strings.ToLower(org.Name)
|
||||
// update
|
||||
_, err := s.engine.ID(org.ID).AllCols().Update(org)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s storage) OrgDelete(id int64) error {
|
||||
return wrapDelete(s.engine.ID(id).Delete(new(model.Org)))
|
||||
}
|
||||
|
||||
func (s storage) OrgFindByName(name string) (*model.Org, error) {
|
||||
// sanitize
|
||||
name = strings.ToLower(name)
|
||||
// find
|
||||
org := new(model.Org)
|
||||
return org, wrapGet(s.engine.Where("name = ?", name).Get(org))
|
||||
}
|
||||
|
||||
func (s storage) OrgRepoList(org *model.Org, p *model.ListOptions) ([]*model.Repo, error) {
|
||||
var repos []*model.Repo
|
||||
return repos, s.paginate(p).OrderBy("repo_id").Where("repo_org_id = ?", org.ID).Find(&repos)
|
||||
}
|
75
server/store/datastore/org_test.go
Normal file
75
server/store/datastore/org_test.go
Normal file
|
@ -0,0 +1,75 @@
|
|||
// Copyright 2023 Woodpecker Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/woodpecker-ci/woodpecker/server/model"
|
||||
)
|
||||
|
||||
func TestOrgCRUD(t *testing.T) {
|
||||
store, closer := newTestStore(t, new(model.Org), new(model.Repo))
|
||||
defer closer()
|
||||
|
||||
org1 := &model.Org{
|
||||
Name: "someAwesomeOrg",
|
||||
IsUser: false,
|
||||
Private: true,
|
||||
}
|
||||
|
||||
// create first org to play with
|
||||
assert.NoError(t, store.OrgCreate(org1))
|
||||
assert.EqualValues(t, "someawesomeorg", org1.Name)
|
||||
|
||||
// retrieve it
|
||||
orgOne, err := store.OrgGet(org1.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, org1, orgOne)
|
||||
|
||||
// change name
|
||||
assert.NoError(t, store.OrgUpdate(&model.Org{ID: org1.ID, Name: "RenamedOrg"}))
|
||||
|
||||
// force a name duplication and fail
|
||||
assert.Error(t, store.OrgCreate(&model.Org{Name: "reNamedorg"}))
|
||||
|
||||
// find updated org by name
|
||||
orgOne, err = store.OrgFindByName("renamedorG")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEqualValues(t, org1, orgOne)
|
||||
assert.EqualValues(t, org1.ID, orgOne.ID)
|
||||
assert.EqualValues(t, false, orgOne.IsUser)
|
||||
assert.EqualValues(t, false, orgOne.Private)
|
||||
assert.EqualValues(t, "renamedorg", orgOne.Name)
|
||||
|
||||
// create two more orgs and repos
|
||||
someUser := &model.Org{Name: "some_other_u", IsUser: true}
|
||||
assert.NoError(t, store.OrgCreate(someUser))
|
||||
assert.NoError(t, store.OrgCreate(&model.Org{Name: "some_other_org"}))
|
||||
assert.NoError(t, store.CreateRepo(&model.Repo{UserID: 1, Owner: "some_other_u", Name: "abc", FullName: "some_other_u/abc", OrgID: someUser.ID}))
|
||||
assert.NoError(t, store.CreateRepo(&model.Repo{UserID: 1, Owner: "some_other_u", Name: "xyz", FullName: "some_other_u/xyz", OrgID: someUser.ID}))
|
||||
assert.NoError(t, store.CreateRepo(&model.Repo{UserID: 1, Owner: "renamedorg", Name: "567", FullName: "renamedorg/567", OrgID: orgOne.ID}))
|
||||
|
||||
// get all repos for a specific org
|
||||
repos, err := store.OrgRepoList(someUser, &model.ListOptions{All: true})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, repos, 2)
|
||||
|
||||
// delete an org and check if it's gone
|
||||
assert.NoError(t, store.OrgDelete(org1.ID))
|
||||
assert.Error(t, store.OrgDelete(org1.ID))
|
||||
}
|
|
@ -33,8 +33,8 @@ func (s storage) SecretList(repo *model.Repo, includeGlobalAndOrgSecrets bool, p
|
|||
var secrets []*model.Secret
|
||||
var cond builder.Cond = builder.Eq{"secret_repo_id": repo.ID}
|
||||
if includeGlobalAndOrgSecrets {
|
||||
cond = cond.Or(builder.Eq{"secret_owner": repo.Owner}).
|
||||
Or(builder.And(builder.Eq{"secret_owner": ""}, builder.Eq{"secret_repo_id": 0}))
|
||||
cond = cond.Or(builder.Eq{"secret_org_id": repo.OrgID}).
|
||||
Or(builder.And(builder.Eq{"secret_org_id": 0}, builder.Eq{"secret_repo_id": 0}))
|
||||
}
|
||||
return secrets, s.paginate(p).Where(cond).OrderBy(orderSecretsBy).Find(&secrets)
|
||||
}
|
||||
|
@ -59,28 +59,28 @@ func (s storage) SecretDelete(secret *model.Secret) error {
|
|||
return wrapDelete(s.engine.ID(secret.ID).Delete(new(model.Secret)))
|
||||
}
|
||||
|
||||
func (s storage) OrgSecretFind(owner, name string) (*model.Secret, error) {
|
||||
func (s storage) OrgSecretFind(orgID int64, name string) (*model.Secret, error) {
|
||||
secret := new(model.Secret)
|
||||
return secret, wrapGet(s.engine.Where(
|
||||
builder.Eq{"secret_owner": owner, "secret_name": name},
|
||||
builder.Eq{"secret_org_id": orgID, "secret_name": name},
|
||||
).Get(secret))
|
||||
}
|
||||
|
||||
func (s storage) OrgSecretList(owner string, p *model.ListOptions) ([]*model.Secret, error) {
|
||||
func (s storage) OrgSecretList(orgID int64, p *model.ListOptions) ([]*model.Secret, error) {
|
||||
secrets := make([]*model.Secret, 0)
|
||||
return secrets, s.paginate(p).Where("secret_owner = ?", owner).OrderBy(orderSecretsBy).Find(&secrets)
|
||||
return secrets, s.paginate(p).Where("secret_org_id = ?", orgID).OrderBy(orderSecretsBy).Find(&secrets)
|
||||
}
|
||||
|
||||
func (s storage) GlobalSecretFind(name string) (*model.Secret, error) {
|
||||
secret := new(model.Secret)
|
||||
return secret, wrapGet(s.engine.Where(
|
||||
builder.Eq{"secret_owner": "", "secret_repo_id": 0, "secret_name": name},
|
||||
builder.Eq{"secret_org_id": 0, "secret_repo_id": 0, "secret_name": name},
|
||||
).Get(secret))
|
||||
}
|
||||
|
||||
func (s storage) GlobalSecretList(p *model.ListOptions) ([]*model.Secret, error) {
|
||||
secrets := make([]*model.Secret, 0)
|
||||
return secrets, s.paginate(p).Where(
|
||||
builder.Eq{"secret_owner": "", "secret_repo_id": 0},
|
||||
builder.Eq{"secret_org_id": 0, "secret_repo_id": 0},
|
||||
).OrderBy(orderSecretsBy).Find(&secrets)
|
||||
}
|
||||
|
|
|
@ -73,7 +73,7 @@ func TestSecretList(t *testing.T) {
|
|||
|
||||
createTestSecrets(t, store)
|
||||
|
||||
list, err := store.SecretList(&model.Repo{ID: 1, Owner: "org"}, false, &model.ListOptions{Page: 1, PerPage: 50})
|
||||
list, err := store.SecretList(&model.Repo{ID: 1, OrgID: 12}, false, &model.ListOptions{Page: 1, PerPage: 50})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, list, 2)
|
||||
}
|
||||
|
@ -95,7 +95,7 @@ func TestSecretPipelineList(t *testing.T) {
|
|||
|
||||
createTestSecrets(t, store)
|
||||
|
||||
list, err := store.SecretList(&model.Repo{ID: 1, Owner: "org"}, true, &model.ListOptions{Page: 1, PerPage: 50})
|
||||
list, err := store.SecretList(&model.Repo{ID: 1, OrgID: 12}, true, &model.ListOptions{Page: 1, PerPage: 50})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, list, 4)
|
||||
}
|
||||
|
@ -179,7 +179,7 @@ func TestSecretIndexes(t *testing.T) {
|
|||
|
||||
func createTestSecrets(t *testing.T, store *storage) {
|
||||
assert.NoError(t, store.SecretCreate(&model.Secret{
|
||||
Owner: "org",
|
||||
OrgID: 12,
|
||||
Name: "usr",
|
||||
Value: "sec",
|
||||
}))
|
||||
|
@ -204,7 +204,7 @@ func TestOrgSecretFind(t *testing.T) {
|
|||
defer closer()
|
||||
|
||||
err := store.SecretCreate(&model.Secret{
|
||||
Owner: "org",
|
||||
OrgID: 12,
|
||||
Name: "password",
|
||||
Value: "correct-horse-battery-staple",
|
||||
Images: []string{"golang", "node"},
|
||||
|
@ -215,13 +215,13 @@ func TestOrgSecretFind(t *testing.T) {
|
|||
return
|
||||
}
|
||||
|
||||
secret, err := store.OrgSecretFind("org", "password")
|
||||
secret, err := store.OrgSecretFind(12, "password")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if got, want := secret.Owner, "org"; got != want {
|
||||
t.Errorf("Want owner %s, got %s", want, got)
|
||||
if got, want := secret.OrgID, int64(12); got != want {
|
||||
t.Errorf("Want org_id %d, got %d", want, got)
|
||||
}
|
||||
if got, want := secret.Name, "password"; got != want {
|
||||
t.Errorf("Want secret name %s, got %s", want, got)
|
||||
|
@ -249,7 +249,7 @@ func TestOrgSecretList(t *testing.T) {
|
|||
|
||||
createTestSecrets(t, store)
|
||||
|
||||
list, err := store.OrgSecretList("org", &model.ListOptions{All: true})
|
||||
list, err := store.OrgSecretList(12, &model.ListOptions{All: true})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, list, 1)
|
||||
|
||||
|
|
|
@ -1169,16 +1169,122 @@ func (_m *Store) Migrate() error {
|
|||
return r0
|
||||
}
|
||||
|
||||
// OrgCreate provides a mock function with given fields: _a0
|
||||
func (_m *Store) OrgCreate(_a0 *model.Org) error {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(*model.Org) error); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// OrgDelete provides a mock function with given fields: _a0
|
||||
func (_m *Store) OrgDelete(_a0 int64) error {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(int64) error); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// OrgFindByName provides a mock function with given fields: _a0
|
||||
func (_m *Store) OrgFindByName(_a0 string) (*model.Org, error) {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
var r0 *model.Org
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(string) (*model.Org, error)); ok {
|
||||
return rf(_a0)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(string) *model.Org); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Org)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(string) error); ok {
|
||||
r1 = rf(_a0)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// OrgGet provides a mock function with given fields: _a0
|
||||
func (_m *Store) OrgGet(_a0 int64) (*model.Org, error) {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
var r0 *model.Org
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(int64) (*model.Org, error)); ok {
|
||||
return rf(_a0)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(int64) *model.Org); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Org)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(int64) error); ok {
|
||||
r1 = rf(_a0)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// OrgRepoList provides a mock function with given fields: _a0, _a1
|
||||
func (_m *Store) OrgRepoList(_a0 *model.Org, _a1 *model.ListOptions) ([]*model.Repo, error) {
|
||||
ret := _m.Called(_a0, _a1)
|
||||
|
||||
var r0 []*model.Repo
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(*model.Org, *model.ListOptions) ([]*model.Repo, error)); ok {
|
||||
return rf(_a0, _a1)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(*model.Org, *model.ListOptions) []*model.Repo); ok {
|
||||
r0 = rf(_a0, _a1)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.Repo)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(*model.Org, *model.ListOptions) error); ok {
|
||||
r1 = rf(_a0, _a1)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// OrgSecretFind provides a mock function with given fields: _a0, _a1
|
||||
func (_m *Store) OrgSecretFind(_a0 string, _a1 string) (*model.Secret, error) {
|
||||
func (_m *Store) OrgSecretFind(_a0 int64, _a1 string) (*model.Secret, error) {
|
||||
ret := _m.Called(_a0, _a1)
|
||||
|
||||
var r0 *model.Secret
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(string, string) (*model.Secret, error)); ok {
|
||||
if rf, ok := ret.Get(0).(func(int64, string) (*model.Secret, error)); ok {
|
||||
return rf(_a0, _a1)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(string, string) *model.Secret); ok {
|
||||
if rf, ok := ret.Get(0).(func(int64, string) *model.Secret); ok {
|
||||
r0 = rf(_a0, _a1)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
|
@ -1186,7 +1292,7 @@ func (_m *Store) OrgSecretFind(_a0 string, _a1 string) (*model.Secret, error) {
|
|||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(string, string) error); ok {
|
||||
if rf, ok := ret.Get(1).(func(int64, string) error); ok {
|
||||
r1 = rf(_a0, _a1)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
|
@ -1196,15 +1302,15 @@ func (_m *Store) OrgSecretFind(_a0 string, _a1 string) (*model.Secret, error) {
|
|||
}
|
||||
|
||||
// OrgSecretList provides a mock function with given fields: _a0, _a1
|
||||
func (_m *Store) OrgSecretList(_a0 string, _a1 *model.ListOptions) ([]*model.Secret, error) {
|
||||
func (_m *Store) OrgSecretList(_a0 int64, _a1 *model.ListOptions) ([]*model.Secret, error) {
|
||||
ret := _m.Called(_a0, _a1)
|
||||
|
||||
var r0 []*model.Secret
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(string, *model.ListOptions) ([]*model.Secret, error)); ok {
|
||||
if rf, ok := ret.Get(0).(func(int64, *model.ListOptions) ([]*model.Secret, error)); ok {
|
||||
return rf(_a0, _a1)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(string, *model.ListOptions) []*model.Secret); ok {
|
||||
if rf, ok := ret.Get(0).(func(int64, *model.ListOptions) []*model.Secret); ok {
|
||||
r0 = rf(_a0, _a1)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
|
@ -1212,7 +1318,7 @@ func (_m *Store) OrgSecretList(_a0 string, _a1 *model.ListOptions) ([]*model.Sec
|
|||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(string, *model.ListOptions) error); ok {
|
||||
if rf, ok := ret.Get(1).(func(int64, *model.ListOptions) error); ok {
|
||||
r1 = rf(_a0, _a1)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
|
@ -1221,6 +1327,20 @@ func (_m *Store) OrgSecretList(_a0 string, _a1 *model.ListOptions) ([]*model.Sec
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// OrgUpdate provides a mock function with given fields: _a0
|
||||
func (_m *Store) OrgUpdate(_a0 *model.Org) error {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(*model.Org) error); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// PermDelete provides a mock function with given fields: perm
|
||||
func (_m *Store) PermDelete(perm *model.Perm) error {
|
||||
ret := _m.Called(perm)
|
||||
|
|
|
@ -121,8 +121,8 @@ type Store interface {
|
|||
SecretCreate(*model.Secret) error
|
||||
SecretUpdate(*model.Secret) error
|
||||
SecretDelete(*model.Secret) error
|
||||
OrgSecretFind(string, string) (*model.Secret, error)
|
||||
OrgSecretList(string, *model.ListOptions) ([]*model.Secret, error)
|
||||
OrgSecretFind(int64, string) (*model.Secret, error)
|
||||
OrgSecretList(int64, *model.ListOptions) ([]*model.Secret, error)
|
||||
GlobalSecretFind(string) (*model.Secret, error)
|
||||
GlobalSecretList(*model.ListOptions) ([]*model.Secret, error)
|
||||
|
||||
|
@ -183,6 +183,16 @@ type Store interface {
|
|||
WorkflowLoad(int64) (*model.Workflow, error)
|
||||
WorkflowUpdate(*model.Workflow) error
|
||||
|
||||
// Org
|
||||
OrgCreate(*model.Org) error
|
||||
OrgGet(int64) (*model.Org, error)
|
||||
OrgFindByName(string) (*model.Org, error)
|
||||
OrgUpdate(*model.Org) error
|
||||
OrgDelete(int64) error
|
||||
|
||||
// Org repos
|
||||
OrgRepoList(*model.Org, *model.ListOptions) ([]*model.Repo, error)
|
||||
|
||||
// Store operations
|
||||
Ping() error
|
||||
Close() error
|
||||
|
|
|
@ -38,9 +38,9 @@
|
|||
</Panel>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { computed, defineComponent, inject, Ref, ref } from 'vue';
|
||||
import { computed, inject, Ref, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from '~/components/atomic/Button.vue';
|
||||
|
@ -61,86 +61,61 @@ const emptySecret = {
|
|||
event: [WebhookEvents.Push],
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'OrgSecretsTab',
|
||||
const apiClient = useApiClient();
|
||||
const notifications = useNotifications();
|
||||
const i18n = useI18n();
|
||||
|
||||
components: {
|
||||
Button,
|
||||
Panel,
|
||||
DocsLink,
|
||||
SecretList,
|
||||
SecretEdit,
|
||||
},
|
||||
const org = inject<Ref<Org>>('org');
|
||||
const selectedSecret = ref<Partial<Secret>>();
|
||||
const isEditingSecret = computed(() => !!selectedSecret.value?.id);
|
||||
|
||||
setup() {
|
||||
const apiClient = useApiClient();
|
||||
const notifications = useNotifications();
|
||||
const i18n = useI18n();
|
||||
async function loadSecrets(page: number): Promise<Secret[] | null> {
|
||||
if (!org?.value) {
|
||||
throw new Error("Unexpected: Can't load org");
|
||||
}
|
||||
|
||||
const org = inject<Ref<Org>>('org');
|
||||
const selectedSecret = ref<Partial<Secret>>();
|
||||
const isEditingSecret = computed(() => !!selectedSecret.value?.id);
|
||||
return apiClient.getOrgSecretList(org.value.id, page);
|
||||
}
|
||||
|
||||
async function loadSecrets(page: number): Promise<Secret[] | null> {
|
||||
if (!org?.value) {
|
||||
throw new Error("Unexpected: Can't load org");
|
||||
}
|
||||
const { resetPage, data: secrets } = usePagination(loadSecrets, () => !selectedSecret.value);
|
||||
|
||||
return apiClient.getOrgSecretList(org.value.name, page);
|
||||
}
|
||||
const { doSubmit: createSecret, isLoading: isSaving } = useAsyncAction(async () => {
|
||||
if (!org?.value) {
|
||||
throw new Error("Unexpected: Can't load org");
|
||||
}
|
||||
|
||||
const { resetPage, data: secrets } = usePagination(loadSecrets, () => !selectedSecret.value);
|
||||
if (!selectedSecret.value) {
|
||||
throw new Error("Unexpected: Can't get secret");
|
||||
}
|
||||
|
||||
const { doSubmit: createSecret, isLoading: isSaving } = useAsyncAction(async () => {
|
||||
if (!org?.value) {
|
||||
throw new Error("Unexpected: Can't load org");
|
||||
}
|
||||
|
||||
if (!selectedSecret.value) {
|
||||
throw new Error("Unexpected: Can't get secret");
|
||||
}
|
||||
|
||||
if (isEditingSecret.value) {
|
||||
await apiClient.updateOrgSecret(org.value.name, selectedSecret.value);
|
||||
} else {
|
||||
await apiClient.createOrgSecret(org.value.name, selectedSecret.value);
|
||||
}
|
||||
notifications.notify({
|
||||
title: i18n.t(isEditingSecret.value ? 'org.settings.secrets.saved' : 'org.settings.secrets.created'),
|
||||
type: 'success',
|
||||
});
|
||||
selectedSecret.value = undefined;
|
||||
resetPage();
|
||||
});
|
||||
|
||||
const { doSubmit: deleteSecret, isLoading: isDeleting } = useAsyncAction(async (_secret: Secret) => {
|
||||
if (!org?.value) {
|
||||
throw new Error("Unexpected: Can't load org");
|
||||
}
|
||||
|
||||
await apiClient.deleteOrgSecret(org.value.name, _secret.name);
|
||||
notifications.notify({ title: i18n.t('org.settings.secrets.deleted'), type: 'success' });
|
||||
resetPage();
|
||||
});
|
||||
|
||||
function editSecret(secret: Secret) {
|
||||
selectedSecret.value = cloneDeep(secret);
|
||||
}
|
||||
|
||||
function showAddSecret() {
|
||||
selectedSecret.value = cloneDeep(emptySecret);
|
||||
}
|
||||
|
||||
return {
|
||||
selectedSecret,
|
||||
secrets,
|
||||
isDeleting,
|
||||
isSaving,
|
||||
showAddSecret,
|
||||
createSecret,
|
||||
editSecret,
|
||||
deleteSecret,
|
||||
};
|
||||
},
|
||||
if (isEditingSecret.value) {
|
||||
await apiClient.updateOrgSecret(org.value.id, selectedSecret.value);
|
||||
} else {
|
||||
await apiClient.createOrgSecret(org.value.id, selectedSecret.value);
|
||||
}
|
||||
notifications.notify({
|
||||
title: i18n.t(isEditingSecret.value ? 'org.settings.secrets.saved' : 'org.settings.secrets.created'),
|
||||
type: 'success',
|
||||
});
|
||||
selectedSecret.value = undefined;
|
||||
resetPage();
|
||||
});
|
||||
|
||||
const { doSubmit: deleteSecret, isLoading: isDeleting } = useAsyncAction(async (_secret: Secret) => {
|
||||
if (!org?.value) {
|
||||
throw new Error("Unexpected: Can't load org");
|
||||
}
|
||||
|
||||
await apiClient.deleteOrgSecret(org.value.id, _secret.name);
|
||||
notifications.notify({ title: i18n.t('org.settings.secrets.deleted'), type: 'success' });
|
||||
resetPage();
|
||||
});
|
||||
|
||||
function editSecret(secret: Secret) {
|
||||
selectedSecret.value = cloneDeep(secret);
|
||||
}
|
||||
|
||||
function showAddSecret() {
|
||||
selectedSecret.value = cloneDeep(emptySecret);
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { inject as vueInject, InjectionKey, provide as vueProvide, Ref } from 'vue';
|
||||
|
||||
import { Repo } from '~/lib/api/types';
|
||||
import { Org, OrgPermissions, Repo } from '~/lib/api/types';
|
||||
|
||||
export type InjectKeys = {
|
||||
repo: Ref<Repo>;
|
||||
org: Ref<Org | undefined>;
|
||||
'org-permissions': Ref<OrgPermissions | undefined>;
|
||||
};
|
||||
|
||||
export function inject<T extends keyof InjectKeys>(key: T): InjectKeys[T] {
|
||||
|
|
|
@ -2,6 +2,7 @@ import ApiClient, { encodeQueryString } from './client';
|
|||
import {
|
||||
Agent,
|
||||
Cron,
|
||||
Org,
|
||||
OrgPermissions,
|
||||
Pipeline,
|
||||
PipelineConfig,
|
||||
|
@ -190,26 +191,34 @@ export default class WoodpeckerClient extends ApiClient {
|
|||
return this._post(`/api/repos/${repoId}/cron/${cronId}`) as Promise<Pipeline>;
|
||||
}
|
||||
|
||||
getOrgPermissions(owner: string): Promise<OrgPermissions> {
|
||||
return this._get(`/api/orgs/${owner}/permissions`) as Promise<OrgPermissions>;
|
||||
getOrg(orgId: number): Promise<Org> {
|
||||
return this._get(`/api/orgs/${orgId}`) as Promise<Org>;
|
||||
}
|
||||
|
||||
getOrgSecretList(owner: string, page: number): Promise<Secret[] | null> {
|
||||
return this._get(`/api/orgs/${owner}/secrets?page=${page}`) as Promise<Secret[] | null>;
|
||||
lookupOrg(name: string): Promise<Org> {
|
||||
return this._get(`/api/orgs/lookup/${name}`) as Promise<Org>;
|
||||
}
|
||||
|
||||
createOrgSecret(owner: string, secret: Partial<Secret>): Promise<unknown> {
|
||||
return this._post(`/api/orgs/${owner}/secrets`, secret);
|
||||
getOrgPermissions(orgId: number): Promise<OrgPermissions> {
|
||||
return this._get(`/api/orgs/${orgId}/permissions`) as Promise<OrgPermissions>;
|
||||
}
|
||||
|
||||
updateOrgSecret(owner: string, secret: Partial<Secret>): Promise<unknown> {
|
||||
getOrgSecretList(orgId: number, page: number): Promise<Secret[] | null> {
|
||||
return this._get(`/api/orgs/${orgId}/secrets?page=${page}`) as Promise<Secret[] | null>;
|
||||
}
|
||||
|
||||
createOrgSecret(orgId: number, secret: Partial<Secret>): Promise<unknown> {
|
||||
return this._post(`/api/orgs/${orgId}/secrets`, secret);
|
||||
}
|
||||
|
||||
updateOrgSecret(orgId: number, secret: Partial<Secret>): Promise<unknown> {
|
||||
const secretName = encodeURIComponent(secret.name ?? '');
|
||||
return this._patch(`/api/orgs/${owner}/secrets/${secretName}`, secret);
|
||||
return this._patch(`/api/orgs/${orgId}/secrets/${secretName}`, secret);
|
||||
}
|
||||
|
||||
deleteOrgSecret(owner: string, secretName: string): Promise<unknown> {
|
||||
deleteOrgSecret(orgId: number, secretName: string): Promise<unknown> {
|
||||
const name = encodeURIComponent(secretName);
|
||||
return this._delete(`/api/orgs/${owner}/secrets/${name}`);
|
||||
return this._delete(`/api/orgs/${orgId}/secrets/${name}`);
|
||||
}
|
||||
|
||||
getGlobalSecretList(page: number): Promise<Secret[] | null> {
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
// A version control organization.
|
||||
export type Org = {
|
||||
// The name of the organization.
|
||||
id: number;
|
||||
name: string;
|
||||
is_user: boolean;
|
||||
};
|
||||
|
||||
export type OrgPermissions = {
|
||||
|
|
|
@ -13,6 +13,9 @@ export type Repo = {
|
|||
// Currently this is either 'git' or 'hg' (Mercurial).
|
||||
scm: string;
|
||||
|
||||
// The id of the organization that owns the repository.
|
||||
org_id: number;
|
||||
|
||||
// The owner of the repository.
|
||||
owner: string;
|
||||
|
||||
|
|
|
@ -105,7 +105,7 @@ const routes: RouteRecordRaw[] = [
|
|||
],
|
||||
},
|
||||
{
|
||||
path: '/org/:orgName',
|
||||
path: '/orgs/:orgId',
|
||||
component: (): Component => import('~/views/org/OrgWrapper.vue'),
|
||||
props: true,
|
||||
children: [
|
||||
|
@ -124,6 +124,11 @@ const routes: RouteRecordRaw[] = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/org/:orgName/:pathMatch(.*)*',
|
||||
component: (): Component => import('~/views/org/OrgDeprecatedRedirect.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: (): Component => import('~/views/RouterView.vue'),
|
||||
|
|
26
web/src/views/org/OrgDeprecatedRedirect.vue
Normal file
26
web/src/views/org/OrgDeprecatedRedirect.vue
Normal file
|
@ -0,0 +1,26 @@
|
|||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import useApiClient from '~/compositions/useApiClient';
|
||||
|
||||
const apiClient = useApiClient();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps<{
|
||||
orgName: string;
|
||||
}>();
|
||||
|
||||
onMounted(async () => {
|
||||
const org = await apiClient.lookupOrg(props.orgName);
|
||||
|
||||
const path = route.path.replace(`/org/${props.orgName}`, `/orgs/${org?.id}`);
|
||||
|
||||
await router.replace({ path });
|
||||
});
|
||||
</script>
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<Scaffold v-model:search="search">
|
||||
<Scaffold v-if="org && orgPermissions" v-model:search="search">
|
||||
<template #title>
|
||||
{{ orgName }}
|
||||
{{ org.name }}
|
||||
</template>
|
||||
|
||||
<template #titleActions>
|
||||
|
@ -25,33 +25,25 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref, toRef } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import IconButton from '~/components/atomic/IconButton.vue';
|
||||
import ListItem from '~/components/atomic/ListItem.vue';
|
||||
import Scaffold from '~/components/layout/scaffold/Scaffold.vue';
|
||||
import useApiClient from '~/compositions/useApiClient';
|
||||
import { inject } from '~/compositions/useInjectProvide';
|
||||
import { useRepoSearch } from '~/compositions/useRepoSearch';
|
||||
import { OrgPermissions } from '~/lib/api/types';
|
||||
import { useRepoStore } from '~/store/repos';
|
||||
|
||||
const props = defineProps<{
|
||||
orgName: string;
|
||||
}>();
|
||||
|
||||
const apiClient = useApiClient();
|
||||
const repoStore = useRepoStore();
|
||||
|
||||
// TODO: filter server side
|
||||
const orgName = toRef(props, 'orgName');
|
||||
const repos = computed(() => Array.from(repoStore.repos.values()).filter((repo) => repo.owner === orgName.value));
|
||||
const search = ref('');
|
||||
const orgPermissions = ref<OrgPermissions>({ member: false, admin: false });
|
||||
const org = inject('org');
|
||||
const orgPermissions = inject('org-permissions');
|
||||
|
||||
const search = ref('');
|
||||
const repos = computed(() => Array.from(repoStore.repos.values()).filter((repo) => repo.org_id === org.value?.id));
|
||||
const { searchedRepos } = useRepoSearch(repos, search);
|
||||
|
||||
onMounted(async () => {
|
||||
await repoStore.loadRepos();
|
||||
orgPermissions.value = await apiClient.getOrgPermissions(orgName.value);
|
||||
await repoStore.loadRepos(); // TODO: load only org repos
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<template>
|
||||
<Scaffold enable-tabs :go-back="goBack">
|
||||
<Scaffold v-if="org" enable-tabs :go-back="goBack">
|
||||
<template #title>
|
||||
<span>
|
||||
<router-link :to="{ name: 'org', params: { orgName: org.name } }" class="hover:underline">
|
||||
<router-link :to="{ name: 'org' }" class="hover:underline">
|
||||
{{ org.name }}
|
||||
</router-link>
|
||||
/
|
||||
|
@ -17,32 +17,25 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { inject, onMounted, Ref } from 'vue';
|
||||
import { onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import Tab from '~/components/layout/scaffold/Tab.vue';
|
||||
import OrgSecretsTab from '~/components/org/settings/OrgSecretsTab.vue';
|
||||
import { inject } from '~/compositions/useInjectProvide';
|
||||
import useNotifications from '~/compositions/useNotifications';
|
||||
import { useRouteBackOrDefault } from '~/compositions/useRouteBackOrDefault';
|
||||
import { Org, OrgPermissions } from '~/lib/api/types';
|
||||
|
||||
const notifications = useNotifications();
|
||||
const router = useRouter();
|
||||
const i18n = useI18n();
|
||||
|
||||
const orgPermissions = inject<Ref<OrgPermissions>>('org-permissions');
|
||||
if (!orgPermissions) {
|
||||
throw new Error('Unexpected: "orgPermissions" should be provided at this place');
|
||||
}
|
||||
|
||||
const org = inject<Ref<Org>>('org');
|
||||
if (!org) {
|
||||
throw new Error('Unexpected: "org" should be provided at this place');
|
||||
}
|
||||
const org = inject('org');
|
||||
const orgPermissions = inject('org-permissions');
|
||||
|
||||
onMounted(async () => {
|
||||
if (!orgPermissions.value.admin) {
|
||||
if (!orgPermissions.value?.admin) {
|
||||
notifications.notify({ type: 'error', title: i18n.t('org.settings.not_allowed') });
|
||||
await router.replace({ name: 'home' });
|
||||
}
|
||||
|
|
|
@ -19,34 +19,31 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, provide, ref, toRef, watch } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import IconButton from '~/components/atomic/IconButton.vue';
|
||||
import Scaffold from '~/components/layout/scaffold/Scaffold.vue';
|
||||
import useApiClient from '~/compositions/useApiClient';
|
||||
import { provide } from '~/compositions/useInjectProvide';
|
||||
import { Org, OrgPermissions } from '~/lib/api/types';
|
||||
|
||||
const props = defineProps<{
|
||||
orgName: string;
|
||||
orgId: string;
|
||||
}>();
|
||||
|
||||
const orgName = toRef(props, 'orgName');
|
||||
const orgId = computed(() => parseInt(props.orgId, 10));
|
||||
const apiClient = useApiClient();
|
||||
|
||||
const org = computed<Org>(() => ({ name: orgName.value }));
|
||||
const org = ref<Org>();
|
||||
const orgPermissions = ref<OrgPermissions>();
|
||||
provide('org', org);
|
||||
provide('org-permissions', orgPermissions);
|
||||
|
||||
async function load() {
|
||||
orgPermissions.value = await apiClient.getOrgPermissions(orgName.value);
|
||||
org.value = await apiClient.getOrg(orgId.value);
|
||||
orgPermissions.value = await apiClient.getOrgPermissions(org.value.id);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
});
|
||||
|
||||
watch([orgName], () => {
|
||||
load();
|
||||
});
|
||||
onMounted(load);
|
||||
watch(orgId, load);
|
||||
</script>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<Scaffold enable-tabs :go-back="goBack">
|
||||
<template #title>
|
||||
<span>
|
||||
<router-link :to="{ name: 'org', params: { orgName: repo.owner } }" class="hover:underline">
|
||||
<router-link :to="{ name: 'org', params: { orgId: repo.org_id } }" class="hover:underline">
|
||||
{{ repo.owner }}
|
||||
</router-link>
|
||||
/
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
>
|
||||
<template #title>
|
||||
<span class="flex">
|
||||
<router-link :to="{ name: 'org', params: { orgName: repo?.owner } }" class="hover:underline">{{
|
||||
<router-link :to="{ name: 'org', params: { orgId: repo.org_id } }" class="hover:underline">{{
|
||||
repo.owner
|
||||
}}</router-link>
|
||||
{{ ` / ${repo.name}` }}
|
||||
|
|
|
@ -46,8 +46,10 @@ const (
|
|||
pathRepoRegistry = "%s/api/repos/%d/registry/%s"
|
||||
pathRepoCrons = "%s/api/repos/%d/cron"
|
||||
pathRepoCron = "%s/api/repos/%d/cron/%d"
|
||||
pathOrgSecrets = "%s/api/orgs/%s/secrets"
|
||||
pathOrgSecret = "%s/api/orgs/%s/secrets/%s"
|
||||
pathOrg = "%s/api/orgs/%d"
|
||||
pathOrgLookup = "%s/api/orgs/lookup/%s"
|
||||
pathOrgSecrets = "%s/api/orgs/%d/secrets"
|
||||
pathOrgSecret = "%s/api/orgs/%d/secrets/%s"
|
||||
pathGlobalSecrets = "%s/api/secrets"
|
||||
pathGlobalSecret = "%s/api/secrets/%s"
|
||||
pathUsers = "%s/api/users"
|
||||
|
@ -397,41 +399,57 @@ func (c *client) SecretDelete(repoID int64, secret string) error {
|
|||
return c.delete(uri)
|
||||
}
|
||||
|
||||
// Org returns an organization by id.
|
||||
func (c *client) Org(orgID int64) (*Org, error) {
|
||||
out := new(Org)
|
||||
uri := fmt.Sprintf(pathOrg, c.addr, orgID)
|
||||
err := c.get(uri, out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// OrgLookup returns a organsization by its name.
|
||||
func (c *client) OrgLookup(name string) (*Org, error) {
|
||||
out := new(Org)
|
||||
uri := fmt.Sprintf(pathOrgLookup, c.addr, name)
|
||||
err := c.get(uri, out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// OrgSecret returns an organization secret by name.
|
||||
func (c *client) OrgSecret(owner, secret string) (*Secret, error) {
|
||||
func (c *client) OrgSecret(orgID int64, secret string) (*Secret, error) {
|
||||
out := new(Secret)
|
||||
uri := fmt.Sprintf(pathOrgSecret, c.addr, owner, secret)
|
||||
uri := fmt.Sprintf(pathOrgSecret, c.addr, orgID, secret)
|
||||
err := c.get(uri, out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// OrgSecretList returns a list of all organization secrets.
|
||||
func (c *client) OrgSecretList(owner string) ([]*Secret, error) {
|
||||
func (c *client) OrgSecretList(orgID int64) ([]*Secret, error) {
|
||||
var out []*Secret
|
||||
uri := fmt.Sprintf(pathOrgSecrets, c.addr, owner)
|
||||
uri := fmt.Sprintf(pathOrgSecrets, c.addr, orgID)
|
||||
err := c.get(uri, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// OrgSecretCreate creates an organization secret.
|
||||
func (c *client) OrgSecretCreate(owner string, in *Secret) (*Secret, error) {
|
||||
func (c *client) OrgSecretCreate(orgID int64, in *Secret) (*Secret, error) {
|
||||
out := new(Secret)
|
||||
uri := fmt.Sprintf(pathOrgSecrets, c.addr, owner)
|
||||
uri := fmt.Sprintf(pathOrgSecrets, c.addr, orgID)
|
||||
err := c.post(uri, in, out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// OrgSecretUpdate updates an organization secret.
|
||||
func (c *client) OrgSecretUpdate(owner string, in *Secret) (*Secret, error) {
|
||||
func (c *client) OrgSecretUpdate(orgID int64, in *Secret) (*Secret, error) {
|
||||
out := new(Secret)
|
||||
uri := fmt.Sprintf(pathOrgSecret, c.addr, owner, in.Name)
|
||||
uri := fmt.Sprintf(pathOrgSecret, c.addr, orgID, in.Name)
|
||||
err := c.patch(uri, in, out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// OrgSecretDelete deletes an organization secret.
|
||||
func (c *client) OrgSecretDelete(owner, secret string) error {
|
||||
uri := fmt.Sprintf(pathOrgSecret, c.addr, owner, secret)
|
||||
func (c *client) OrgSecretDelete(orgID int64, secret string) error {
|
||||
uri := fmt.Sprintf(pathOrgSecret, c.addr, orgID, secret)
|
||||
return c.delete(uri)
|
||||
}
|
||||
|
||||
|
|
|
@ -148,20 +148,26 @@ type Client interface {
|
|||
// SecretDelete deletes a secret.
|
||||
SecretDelete(repoID int64, secret string) error
|
||||
|
||||
// Org returns an organization by name.
|
||||
Org(orgID int64) (*Org, error)
|
||||
|
||||
// OrgLookup returns an organization id by name.
|
||||
OrgLookup(orgName string) (*Org, error)
|
||||
|
||||
// OrgSecret returns an organization secret by name.
|
||||
OrgSecret(owner, secret string) (*Secret, error)
|
||||
OrgSecret(orgID int64, secret string) (*Secret, error)
|
||||
|
||||
// OrgSecretList returns a list of all organization secrets.
|
||||
OrgSecretList(owner string) ([]*Secret, error)
|
||||
OrgSecretList(orgID int64) ([]*Secret, error)
|
||||
|
||||
// OrgSecretCreate creates an organization secret.
|
||||
OrgSecretCreate(owner string, secret *Secret) (*Secret, error)
|
||||
OrgSecretCreate(orgID int64, secret *Secret) (*Secret, error)
|
||||
|
||||
// OrgSecretUpdate updates an organization secret.
|
||||
OrgSecretUpdate(owner string, secret *Secret) (*Secret, error)
|
||||
OrgSecretUpdate(orgID int64, secret *Secret) (*Secret, error)
|
||||
|
||||
// OrgSecretDelete deletes an organization secret.
|
||||
OrgSecretDelete(owner, secret string) error
|
||||
OrgSecretDelete(orgID int64, secret string) error
|
||||
|
||||
// GlobalSecret returns an global secret by name.
|
||||
GlobalSecret(secret string) (*Secret, error)
|
||||
|
|
|
@ -241,4 +241,11 @@ type (
|
|||
DepStatus map[string]string `json:"dep_status"`
|
||||
AgentID int64 `json:"agent_id"`
|
||||
}
|
||||
|
||||
// Org is the JSON data for an organization
|
||||
Org struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsUser bool `json:"is_user"`
|
||||
}
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue