Compare commits
2 Commits
99e17f6a1d
...
feature/de
| Author | SHA1 | Date | |
|---|---|---|---|
| ed41657c22 | |||
| a1bc0c9187 |
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
secret_module "code.gitea.io/gitea/modules/secret"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
)
|
||||
|
||||
type Devcontainer struct {
|
||||
@@ -142,6 +144,59 @@ func GetScript(ctx context.Context, userId, repoID int64) (map[string]string, er
|
||||
variables[v.Name] = v.Data
|
||||
}
|
||||
|
||||
// Now handle secrets from devcontainer_script table
|
||||
// Level precedence: Org / User > Repo (secrets override variables with same name)
|
||||
// Process Repo level secrets first, then Owner level secrets (Owner will override Repo)
|
||||
|
||||
// Repo level secrets
|
||||
err = db.GetEngine(ctx).
|
||||
Select("variable_name").
|
||||
Table("devcontainer_script").
|
||||
Where("repo_id = ?", repoID).
|
||||
Find(&name)
|
||||
|
||||
var filteredRepoSecrets []*DevcontainerSecret
|
||||
repoSecrets, err := db.Find[DevcontainerSecret](ctx, FindSecretsOpts{RepoID: repoID})
|
||||
if err != nil {
|
||||
log.Error("find secrets of repo: %d, error: %v", repoID, err)
|
||||
} else {
|
||||
for _, s := range repoSecrets {
|
||||
if contains(name, s.Name) {
|
||||
filteredRepoSecrets = append(filteredRepoSecrets, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Org / User level secrets
|
||||
err = db.GetEngine(ctx).
|
||||
Select("variable_name").
|
||||
Table("devcontainer_script").
|
||||
Where("user_id = ? AND repo_id = ?", userId, 0).
|
||||
Find(&name)
|
||||
|
||||
var filteredOwnerSecrets []*DevcontainerSecret
|
||||
ownerSecrets, err := db.Find[DevcontainerSecret](ctx, FindSecretsOpts{OwnerID: userId})
|
||||
if err != nil {
|
||||
log.Error("find secrets of org: %d, error: %v", userId, err)
|
||||
} else {
|
||||
for _, s := range ownerSecrets {
|
||||
if contains(name, s.Name) {
|
||||
filteredOwnerSecrets = append(filteredOwnerSecrets, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge secrets into variables map (secrets override variables with same name)
|
||||
// Level precedence: Org / User > Repo
|
||||
for _, s := range append(filteredRepoSecrets, filteredOwnerSecrets...) {
|
||||
decrypted, err := secret_module.DecryptSecret(setting.SecretKey, s.Data)
|
||||
if err != nil {
|
||||
log.Error("decrypt secret %v %q: %v", s.ID, s.Name, err)
|
||||
continue
|
||||
}
|
||||
variables[s.Name] = decrypted
|
||||
}
|
||||
|
||||
return variables, nil
|
||||
}
|
||||
|
||||
|
||||
218
models/devcontainer/secret.go
Normal file
218
models/devcontainer/secret.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package devcontainer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
secret_module "code.gitea.io/gitea/modules/secret"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// DevcontainerSecret represents a secret that can be used in devcontainer
|
||||
//
|
||||
// It can be:
|
||||
// 1. org/user level secret, OwnerID is org/user ID and RepoID is 0
|
||||
// 2. repo level secret, OwnerID is 0 and RepoID is repo ID
|
||||
//
|
||||
// Please note that it's not acceptable to have both OwnerID and RepoID to be non-zero,
|
||||
// or it will be complicated to find secrets belonging to a specific owner.
|
||||
// For example, conditions like `OwnerID = 1` will also return secret {OwnerID: 1, RepoID: 1},
|
||||
// but it's a repo level secret, not an org/user level secret.
|
||||
// To avoid this, make it clear with {OwnerID: 0, RepoID: 1} for repo level secrets.
|
||||
//
|
||||
// Please note that it's not acceptable to have both OwnerID and RepoID to zero, global secrets are not supported.
|
||||
// It's for security reasons, admin may be not aware of that the secrets could be stolen by any user when setting them as global.
|
||||
type DevcontainerSecret struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OwnerID int64 `xorm:"INDEX UNIQUE(owner_repo_name) NOT NULL"`
|
||||
RepoID int64 `xorm:"INDEX UNIQUE(owner_repo_name) NOT NULL DEFAULT 0"`
|
||||
Name string `xorm:"UNIQUE(owner_repo_name) NOT NULL"`
|
||||
Data string `xorm:"LONGTEXT"` // encrypted data
|
||||
Description string `xorm:"TEXT"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
const (
|
||||
SecretDataMaxLength = 65536
|
||||
SecretDescriptionMaxLength = 4096
|
||||
)
|
||||
|
||||
// ErrSecretNotFound represents a "secret not found" error.
|
||||
type ErrSecretNotFound struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (err ErrSecretNotFound) Error() string {
|
||||
return fmt.Sprintf("secret was not found [name: %s]", err.Name)
|
||||
}
|
||||
|
||||
func (err ErrSecretNotFound) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// InsertEncryptedSecret Creates, encrypts, and validates a new secret with yet unencrypted data and insert into database
|
||||
func InsertEncryptedSecret(ctx context.Context, ownerID, repoID int64, name, data, description string) (*DevcontainerSecret, error) {
|
||||
if ownerID != 0 && repoID != 0 {
|
||||
// It's trying to create a secret that belongs to a repository, but OwnerID has been set accidentally.
|
||||
// Remove OwnerID to avoid confusion; it's not worth returning an error here.
|
||||
ownerID = 0
|
||||
}
|
||||
if ownerID == 0 && repoID == 0 {
|
||||
return nil, fmt.Errorf("%w: ownerID and repoID cannot be both zero, global secrets are not supported", util.ErrInvalidArgument)
|
||||
}
|
||||
|
||||
if len(data) > SecretDataMaxLength {
|
||||
return nil, util.NewInvalidArgumentErrorf("data too long")
|
||||
}
|
||||
|
||||
description = util.TruncateRunes(description, SecretDescriptionMaxLength)
|
||||
|
||||
encrypted, err := secret_module.EncryptSecret(setting.SecretKey, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secret := &DevcontainerSecret{
|
||||
OwnerID: ownerID,
|
||||
RepoID: repoID,
|
||||
Name: strings.ToUpper(name),
|
||||
Data: encrypted,
|
||||
Description: description,
|
||||
}
|
||||
return secret, db.Insert(ctx, secret)
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(DevcontainerSecret))
|
||||
}
|
||||
|
||||
type FindSecretsOpts struct {
|
||||
db.ListOptions
|
||||
RepoID int64
|
||||
OwnerID int64 // it will be ignored if RepoID is set
|
||||
SecretID int64
|
||||
Name string
|
||||
}
|
||||
|
||||
func (opts FindSecretsOpts) ToConds() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
|
||||
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
|
||||
if opts.RepoID != 0 { // if RepoID is set
|
||||
// ignore OwnerID and treat it as 0
|
||||
cond = cond.And(builder.Eq{"owner_id": 0})
|
||||
} else {
|
||||
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
|
||||
}
|
||||
|
||||
if opts.SecretID != 0 {
|
||||
cond = cond.And(builder.Eq{"id": opts.SecretID})
|
||||
}
|
||||
if opts.Name != "" {
|
||||
cond = cond.And(builder.Eq{"name": strings.ToUpper(opts.Name)})
|
||||
}
|
||||
|
||||
return cond
|
||||
}
|
||||
|
||||
func FindSecrets(ctx context.Context, opts FindSecretsOpts) ([]*DevcontainerSecret, error) {
|
||||
return db.Find[DevcontainerSecret](ctx, opts)
|
||||
}
|
||||
|
||||
// UpdateSecret changes org or user repo secret.
|
||||
func UpdateSecret(ctx context.Context, secretID int64, data, description string) error {
|
||||
if len(data) > SecretDataMaxLength {
|
||||
return util.NewInvalidArgumentErrorf("data too long")
|
||||
}
|
||||
|
||||
description = util.TruncateRunes(description, SecretDescriptionMaxLength)
|
||||
|
||||
encrypted, err := secret_module.EncryptSecret(setting.SecretKey, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s := &DevcontainerSecret{
|
||||
Data: encrypted,
|
||||
Description: description,
|
||||
}
|
||||
affected, err := db.GetEngine(ctx).ID(secretID).Cols("data", "description").Update(s)
|
||||
if affected != 1 {
|
||||
return ErrSecretNotFound{}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteSecret(ctx context.Context, id int64) error {
|
||||
if _, err := db.DeleteByID[DevcontainerSecret](ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSecrets gets all secrets for a given user and repo, decrypts them and returns as a map
|
||||
// Level precedence: Org / User > Repo
|
||||
func GetSecrets(ctx context.Context, userId, repoID int64) (map[string]string, error) {
|
||||
secrets := map[string]string{}
|
||||
|
||||
// Org / User level
|
||||
ownerSecrets, err := db.Find[DevcontainerSecret](ctx, FindSecretsOpts{OwnerID: userId})
|
||||
if err != nil {
|
||||
log.Error("find secrets of owner: %d, error: %v", userId, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Repo level
|
||||
repoSecrets, err := db.Find[DevcontainerSecret](ctx, FindSecretsOpts{RepoID: repoID})
|
||||
if err != nil {
|
||||
log.Error("find secrets of repo: %d, error: %v", repoID, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Level precedence: Org / User > Repo
|
||||
// First add repo secrets, then owner secrets (owner secrets will override repo secrets with same name)
|
||||
for _, secret := range repoSecrets {
|
||||
v, err := secret_module.DecryptSecret(setting.SecretKey, secret.Data)
|
||||
if err != nil {
|
||||
log.Error("decrypt secret %v %q: %v", secret.ID, secret.Name, err)
|
||||
return nil, err
|
||||
}
|
||||
secrets[secret.Name] = v
|
||||
}
|
||||
|
||||
for _, secret := range ownerSecrets {
|
||||
v, err := secret_module.DecryptSecret(setting.SecretKey, secret.Data)
|
||||
if err != nil {
|
||||
log.Error("decrypt secret %v %q: %v", secret.ID, secret.Name, err)
|
||||
return nil, err
|
||||
}
|
||||
secrets[secret.Name] = v
|
||||
}
|
||||
|
||||
return secrets, nil
|
||||
}
|
||||
|
||||
func CountWrongRepoLevelSecrets(ctx context.Context) (int64, error) {
|
||||
var result int64
|
||||
_, err := db.GetEngine(ctx).SQL("SELECT count(`id`) FROM `devcontainer_secret` WHERE `repo_id` > 0 AND `owner_id` > 0").Get(&result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func UpdateWrongRepoLevelSecrets(ctx context.Context) (int64, error) {
|
||||
result, err := db.GetEngine(ctx).Exec("UPDATE `devcontainer_secret` SET `owner_id` = 0 WHERE `repo_id` > 0 AND `owner_id` > 0")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
@@ -3905,6 +3905,7 @@ management = Secrets Management
|
||||
|
||||
[devcontainer]
|
||||
variables = Variables
|
||||
secrets = Secrets
|
||||
variables.management = Variables Management
|
||||
variables.creation = Add Variable
|
||||
variables.none = There are no variables yet.
|
||||
@@ -3921,6 +3922,25 @@ variables.update.failed = Failed to edit variable.
|
||||
variables.update.success = The variable has been edited.
|
||||
scripts=Script Management
|
||||
scripts.description=Add variable names to become the initialization script content of the development container, with the same name priority being: User > Repository > Administration
|
||||
secrets.management=Secrets Management
|
||||
secrets.add_secret=Add secret
|
||||
secrets.edit_secret=Edit secret
|
||||
secrets.deletion=Remove secret
|
||||
secrets.deletion.description=Removing a secret is permanent and cannot be undone. Continue?
|
||||
secrets.deletion.success=The secret has been removed.
|
||||
secrets.deletion.failed=Failed to remove secret.
|
||||
secrets.save_success=The secret "%s" has been saved.
|
||||
secrets.save_failed=Failed to save secret.
|
||||
secrets.creation.failed=Failed to add secret.
|
||||
secrets.creation.success=The secret "%s" has been added.
|
||||
secrets.update.failed=Failed to edit secret.
|
||||
secrets.update.success=The secret has been edited.
|
||||
secrets.none=There are no secrets yet.
|
||||
secrets.description=1. As a variable: "$secret name" can be referenced in the variable value and the script specified in devcontainer.json, with the same name priority being: User > Repository <br>2. As a script: Script management adds secret names to become the initialization script content of the devcontainer.
|
||||
secrets.scripts.description=Add secret names to become the initialization script content of the development container, with the same name priority being: User > Repository
|
||||
secrets.creation.name_placeholder=case-insensitive, alphanumeric characters or underscores only, cannot start with GITEA_ or GITHUB_
|
||||
secrets.creation.value_placeholder=Input any content. Whitespace at the start and end will be omitted.
|
||||
secrets.creation.description_placeholder=Enter short description (optional).
|
||||
[actions]
|
||||
actions = Actions
|
||||
|
||||
|
||||
@@ -3896,6 +3896,7 @@ management=密钥管理
|
||||
|
||||
[devcontainer]
|
||||
variables=变量
|
||||
secrets=密钥
|
||||
variables.management=变量管理
|
||||
variables.creation=添加变量
|
||||
variables.none=目前还没有变量。
|
||||
@@ -3912,6 +3913,25 @@ variables.update.failed=变量编辑失败。
|
||||
variables.update.success=变量已编辑。
|
||||
scripts=脚本管理
|
||||
scripts.description=添加变量名成为开发容器的初始化脚本内容,同名脚本优先级:用户>仓库>管理后台。
|
||||
secrets.management=密钥管理
|
||||
secrets.add_secret=添加密钥
|
||||
secrets.edit_secret=编辑密钥
|
||||
secrets.deletion=删除密钥
|
||||
secrets.deletion.description=删除密钥是永久性的,无法撤消。继续吗?
|
||||
secrets.deletion.success=密钥已删除。
|
||||
secrets.deletion.failed=删除密钥失败。
|
||||
secrets.save_success=密钥「%s」保存成功。
|
||||
secrets.save_failed=密钥保存失败。
|
||||
secrets.creation.failed=密钥添加失败。
|
||||
secrets.creation.success=密钥「%s」添加成功。
|
||||
secrets.update.failed=密钥编辑失败。
|
||||
secrets.update.success=密钥已编辑。
|
||||
secrets.none=目前还没有密钥。
|
||||
secrets.description=1.作为变量使用:「$密钥名」可以在变量值和devcontainer.json指定的脚本中引用,同名密钥优先级:用户>仓库。<br>2.作为脚本使用:脚本管理添加密钥名成为开发容器的初始化脚本内容。
|
||||
secrets.scripts.description=添加密钥名成为开发容器的初始化脚本内容,同名脚本优先级:用户>仓库。
|
||||
secrets.creation.name_placeholder=不区分大小写,仅限字母数字或下划线且不能以 GITEA_ 或 GITHUB_ 开头
|
||||
secrets.creation.value_placeholder=输入任何内容,开头和结尾的空白将会被忽略
|
||||
secrets.creation.description_placeholder=输入简短描述(可选)
|
||||
[actions]
|
||||
actions=工作流
|
||||
|
||||
|
||||
390
routers/web/devcontainer/secrets.go
Normal file
390
routers/web/devcontainer/secrets.go
Normal file
@@ -0,0 +1,390 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package devcontainer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
devcontainer_model "code.gitea.io/gitea/models/devcontainer"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
shared_user "code.gitea.io/gitea/routers/web/shared/user"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
devcontainer_service "code.gitea.io/gitea/services/devcontainer"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
)
|
||||
|
||||
const (
|
||||
tplRepoSecrets templates.TplName = "repo/settings/devcontainer"
|
||||
tplOrgSecrets templates.TplName = "org/settings/devcontainer"
|
||||
tplUserSecrets templates.TplName = "user/settings/devcontainer"
|
||||
)
|
||||
|
||||
type secretsCtx struct {
|
||||
OwnerID int64
|
||||
RepoID int64
|
||||
IsRepo bool
|
||||
IsOrg bool
|
||||
IsUser bool
|
||||
SecretsTemplate templates.TplName
|
||||
RedirectLink string
|
||||
}
|
||||
|
||||
func getSecretsCtx(ctx *context.Context) (*secretsCtx, error) {
|
||||
if ctx.Data["PageIsRepoSettings"] == true {
|
||||
return &secretsCtx{
|
||||
OwnerID: 0,
|
||||
RepoID: ctx.Repo.Repository.ID,
|
||||
IsRepo: true,
|
||||
SecretsTemplate: tplRepoSecrets,
|
||||
RedirectLink: ctx.Repo.RepoLink + "/settings/devcontainer/secrets",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if ctx.Data["PageIsOrgSettings"] == true {
|
||||
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
|
||||
ctx.ServerError("RenderUserOrgHeader", err)
|
||||
return nil, nil
|
||||
}
|
||||
return &secretsCtx{
|
||||
OwnerID: ctx.ContextUser.ID,
|
||||
RepoID: 0,
|
||||
IsOrg: true,
|
||||
SecretsTemplate: tplOrgSecrets,
|
||||
RedirectLink: ctx.Org.OrgLink + "/settings/devcontainer/secrets",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if ctx.Data["PageIsUserSettings"] == true {
|
||||
return &secretsCtx{
|
||||
OwnerID: ctx.Doer.ID,
|
||||
RepoID: 0,
|
||||
IsUser: true,
|
||||
SecretsTemplate: tplUserSecrets,
|
||||
RedirectLink: setting.AppSubURL + "/user/settings/devcontainer/secrets",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("unable to set Secrets context")
|
||||
}
|
||||
|
||||
func Secrets(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("devcontainer.secrets")
|
||||
ctx.Data["PageType"] = "secrets"
|
||||
ctx.Data["PageIsSharedSettingsDevcontainerSecrets"] = true
|
||||
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
|
||||
|
||||
sCtx, err := getSecretsCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getSecretsCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
if sCtx.IsRepo {
|
||||
ctx.Data["DisableSSH"] = setting.SSH.Disabled
|
||||
}
|
||||
|
||||
// 查询本地密钥
|
||||
secrets, err := db.Find[devcontainer_model.DevcontainerSecret](ctx, devcontainer_model.FindSecretsOpts{
|
||||
OwnerID: sCtx.OwnerID,
|
||||
RepoID: sCtx.RepoID,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("FindSecrets", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 查询已启用的密钥(tags)
|
||||
var tags []string
|
||||
err = db.GetEngine(ctx).
|
||||
Select("variable_name").
|
||||
Table("devcontainer_script").
|
||||
Where("user_id = ? AND repo_id = ?", sCtx.OwnerID, sCtx.RepoID).
|
||||
Find(&tags)
|
||||
if err != nil {
|
||||
log.Error("Get script names for secrets: %v", err)
|
||||
}
|
||||
|
||||
// 将tags转换为JSON格式的字符串
|
||||
tagsJSON, err := json.Marshal(tags)
|
||||
if err != nil {
|
||||
ctx.ServerError("Marshal tags", err)
|
||||
return
|
||||
}
|
||||
// 确保tagsJSON不为null
|
||||
tagsJSONStr := string(tagsJSON)
|
||||
if tagsJSONStr == "null" {
|
||||
tagsJSONStr = "[]"
|
||||
}
|
||||
|
||||
// 设置模板数据
|
||||
ctx.Data["Secrets"] = secrets
|
||||
ctx.Data["Tags"] = tagsJSONStr
|
||||
ctx.Data["DataMaxLength"] = devcontainer_model.SecretDataMaxLength
|
||||
ctx.Data["DescriptionMaxLength"] = devcontainer_model.SecretDescriptionMaxLength
|
||||
|
||||
ctx.HTML(http.StatusOK, sCtx.SecretsTemplate)
|
||||
}
|
||||
|
||||
func SecretCreate(ctx *context.Context) {
|
||||
sCtx, err := getSecretsCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getSecretsCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.JSONError(ctx.GetErrMsg())
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*forms.AddSecretForm)
|
||||
|
||||
s, err := devcontainer_service.CreateSecret(ctx, sCtx.OwnerID, sCtx.RepoID, form.Name, util.ReserveLineBreakForTextarea(form.Data), form.Description)
|
||||
if err != nil {
|
||||
log.Error("CreateSecret: %v", err)
|
||||
ctx.JSONError(ctx.Tr("devcontainer.secrets.creation.failed"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("devcontainer.secrets.creation.success", s.Name))
|
||||
ctx.JSONRedirect(sCtx.RedirectLink)
|
||||
}
|
||||
|
||||
func SecretUpdate(ctx *context.Context) {
|
||||
sCtx, err := getSecretsCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getSecretsCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.JSONError(ctx.GetErrMsg())
|
||||
return
|
||||
}
|
||||
|
||||
id := ctx.PathParamInt64("secret_id")
|
||||
|
||||
secret := findSecret(ctx, id, sCtx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*forms.AddSecretForm)
|
||||
|
||||
err = devcontainer_model.UpdateSecret(ctx, secret.ID, util.ReserveLineBreakForTextarea(form.Data), form.Description)
|
||||
if err != nil {
|
||||
log.Error("UpdateSecret: %v", err)
|
||||
ctx.JSONError(ctx.Tr("devcontainer.secrets.update.failed"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("devcontainer.secrets.update.success"))
|
||||
ctx.JSONRedirect(sCtx.RedirectLink)
|
||||
}
|
||||
|
||||
func findSecret(ctx *context.Context, id int64, sCtx *secretsCtx) *devcontainer_model.DevcontainerSecret {
|
||||
opts := devcontainer_model.FindSecretsOpts{
|
||||
SecretID: id,
|
||||
}
|
||||
switch {
|
||||
case sCtx.IsRepo:
|
||||
opts.RepoID = sCtx.RepoID
|
||||
if opts.RepoID == 0 {
|
||||
panic("RepoID is 0")
|
||||
}
|
||||
case sCtx.IsOrg, sCtx.IsUser:
|
||||
opts.OwnerID = sCtx.OwnerID
|
||||
if opts.OwnerID == 0 {
|
||||
panic("OwnerID is 0")
|
||||
}
|
||||
default:
|
||||
panic("invalid secret context")
|
||||
}
|
||||
got, err := devcontainer_model.FindSecrets(ctx, opts)
|
||||
if err != nil {
|
||||
ctx.ServerError("FindSecrets", err)
|
||||
return nil
|
||||
} else if len(got) == 0 {
|
||||
ctx.NotFound(nil)
|
||||
return nil
|
||||
}
|
||||
return got[0]
|
||||
}
|
||||
|
||||
func SecretDelete(ctx *context.Context) {
|
||||
sCtx, err := getSecretsCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getSecretsCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
id := ctx.PathParamInt64("secret_id")
|
||||
|
||||
secret := findSecret(ctx, id, sCtx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := devcontainer_service.DeleteSecretByID(ctx, sCtx.OwnerID, sCtx.RepoID, secret.ID); err != nil {
|
||||
log.Error("Delete secret [%d] failed: %v", id, err)
|
||||
ctx.JSONError(ctx.Tr("devcontainer.secrets.deletion.failed"))
|
||||
return
|
||||
}
|
||||
|
||||
// Delete corresponding script record
|
||||
script := &devcontainer_model.DevcontainerScript{
|
||||
UserId: sCtx.OwnerID,
|
||||
RepoId: sCtx.RepoID,
|
||||
VariableName: secret.Name,
|
||||
}
|
||||
_, err = db.GetEngine(ctx).Delete(script)
|
||||
if err != nil {
|
||||
log.Error("Delete script for secret [%d] failed: %v", id, err)
|
||||
// Note: We log the error but don't interrupt the secret deletion process
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("devcontainer.secrets.deletion.success"))
|
||||
ctx.JSONRedirect(sCtx.RedirectLink)
|
||||
}
|
||||
|
||||
func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) {
|
||||
secrets, err := devcontainer_model.FindSecrets(ctx, devcontainer_model.FindSecretsOpts{OwnerID: ownerID, RepoID: repoID})
|
||||
if err != nil {
|
||||
ctx.ServerError("FindSecrets", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Secrets"] = secrets
|
||||
ctx.Data["DataMaxLength"] = devcontainer_model.SecretDataMaxLength
|
||||
ctx.Data["DescriptionMaxLength"] = devcontainer_model.SecretDescriptionMaxLength
|
||||
|
||||
// Get script names for secrets
|
||||
var scriptNames []string
|
||||
err = db.GetEngine(ctx).
|
||||
Select("variable_name").
|
||||
Table("devcontainer_script").
|
||||
Where("(user_id = 0 AND repo_id = 0) OR (user_id = ? AND repo_id = 0) OR (user_id = 0 AND repo_id = ?)", ownerID, repoID).
|
||||
Find(&scriptNames)
|
||||
if err != nil {
|
||||
log.Error("Get script names for secrets: %v", err)
|
||||
}
|
||||
|
||||
// Filter secrets that have scripts
|
||||
var secretNamesWithScripts []string
|
||||
for _, secret := range secrets {
|
||||
if contains(scriptNames, secret.Name) {
|
||||
secretNamesWithScripts = append(secretNamesWithScripts, secret.Name)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["Tags"] = secretNamesWithScripts
|
||||
|
||||
// Set Link for template
|
||||
if ctx.Data["PageIsRepoSettings"] == true {
|
||||
ctx.Data["Link"] = ctx.Repo.RepoLink + "/settings/devcontainer/secrets"
|
||||
} else if ctx.Data["PageIsOrgSettings"] == true {
|
||||
ctx.Data["Link"] = ctx.Org.OrgLink + "/settings/devcontainer/secrets"
|
||||
} else if ctx.Data["PageIsUserSettings"] == true {
|
||||
ctx.Data["Link"] = setting.AppSubURL + "/user/settings/devcontainer/secrets"
|
||||
}
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func SecretScriptCreate(ctx *context.Context) {
|
||||
sCtx, err := getSecretsCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getSecretsCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.JSONError(ctx.GetErrMsg())
|
||||
return
|
||||
}
|
||||
|
||||
query := ctx.Req.URL.Query()
|
||||
secretName := strings.ToUpper(query.Get("name"))
|
||||
|
||||
// Check if secret exists
|
||||
secrets, err := devcontainer_model.FindSecrets(ctx, devcontainer_model.FindSecretsOpts{
|
||||
OwnerID: sCtx.OwnerID,
|
||||
RepoID: sCtx.RepoID,
|
||||
Name: secretName,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Check secret existence: %v", err)
|
||||
ctx.JSONError(ctx.Tr("devcontainer.secrets.creation.failed"))
|
||||
return
|
||||
}
|
||||
if len(secrets) == 0 {
|
||||
log.Error("Secret %s does not exist", secretName)
|
||||
ctx.JSONError(ctx.Tr("devcontainer.secrets.creation.failed"))
|
||||
return
|
||||
}
|
||||
|
||||
// Create devcontainer_script record
|
||||
script := &devcontainer_model.DevcontainerScript{
|
||||
UserId: sCtx.OwnerID,
|
||||
RepoId: sCtx.RepoID,
|
||||
VariableName: secretName,
|
||||
}
|
||||
|
||||
_, err = db.GetEngine(ctx).Insert(script)
|
||||
if err != nil {
|
||||
log.Error("CreateSecretScript: %v", err)
|
||||
ctx.JSONError(ctx.Tr("devcontainer.secrets.creation.failed"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSONOK()
|
||||
}
|
||||
|
||||
func SecretScriptDelete(ctx *context.Context) {
|
||||
sCtx, err := getSecretsCtx(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("getSecretsCtx", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.JSONError(ctx.GetErrMsg())
|
||||
return
|
||||
}
|
||||
|
||||
query := ctx.Req.URL.Query()
|
||||
secretName := strings.ToUpper(query.Get("name"))
|
||||
|
||||
// Delete devcontainer_script record
|
||||
script := &devcontainer_model.DevcontainerScript{
|
||||
UserId: sCtx.OwnerID,
|
||||
RepoId: sCtx.RepoID,
|
||||
VariableName: secretName,
|
||||
}
|
||||
|
||||
_, err = db.GetEngine(ctx).Delete(script)
|
||||
if err != nil {
|
||||
log.Error("DeleteSecretScript: %v", err)
|
||||
ctx.JSONError(ctx.Tr("devcontainer.secrets.deletion.failed"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSONOK()
|
||||
}
|
||||
@@ -471,6 +471,19 @@ func registerWebRoutes(m *web.Router) {
|
||||
|
||||
}
|
||||
|
||||
addSettingsDevcontainerSecretsRoutes := func() {
|
||||
m.Group("/secrets", func() {
|
||||
m.Get("", devcontainer_web.Secrets)
|
||||
m.Post("/new", web.Bind(forms.AddSecretForm{}), devcontainer_web.SecretCreate)
|
||||
m.Post("/{secret_id}/edit", web.Bind(forms.AddSecretForm{}), devcontainer_web.SecretUpdate)
|
||||
m.Post("/{secret_id}/delete", devcontainer_web.SecretDelete)
|
||||
m.Group("/script", func() {
|
||||
m.Get("/new", devcontainer_web.SecretScriptCreate)
|
||||
m.Get("/delete", devcontainer_web.SecretScriptDelete)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
addSettingsSecretsRoutes := func() {
|
||||
m.Group("/secrets", func() {
|
||||
m.Get("", repo_setting.Secrets)
|
||||
@@ -701,6 +714,7 @@ func registerWebRoutes(m *web.Router) {
|
||||
}, actions.MustEnableActions)
|
||||
m.Group("/devcontainer", func() {
|
||||
addSettingsDevcontainerVariablesRoutes()
|
||||
addSettingsDevcontainerSecretsRoutes()
|
||||
})
|
||||
|
||||
m.Get("/organization", user_setting.Organization)
|
||||
@@ -800,6 +814,7 @@ func registerWebRoutes(m *web.Router) {
|
||||
})
|
||||
m.Group("/devcontainer", func() {
|
||||
addSettingsDevcontainerVariablesRoutes()
|
||||
// Note: devcontainer secrets do not support admin/global level
|
||||
})
|
||||
|
||||
m.Group("/users", func() {
|
||||
@@ -1031,6 +1046,7 @@ func registerWebRoutes(m *web.Router) {
|
||||
|
||||
m.Group("/devcontainer", func() {
|
||||
addSettingsDevcontainerVariablesRoutes()
|
||||
addSettingsDevcontainerSecretsRoutes()
|
||||
})
|
||||
m.Post("/rename", web.Bind(forms.RenameOrgForm{}), org.SettingsRenamePost)
|
||||
m.Post("/delete", org.SettingsDeleteOrgPost)
|
||||
@@ -1227,6 +1243,7 @@ func registerWebRoutes(m *web.Router) {
|
||||
|
||||
m.Group("/devcontainer", func() {
|
||||
addSettingsDevcontainerVariablesRoutes()
|
||||
addSettingsDevcontainerSecretsRoutes()
|
||||
})
|
||||
// the follow handler must be under "settings", otherwise this incomplete repo can't be accessed
|
||||
m.Group("/migrate", func() {
|
||||
|
||||
@@ -1270,6 +1270,22 @@ func AddPublicKeyToAllRunningDevContainer(ctx context.Context, userId int64, pub
|
||||
}
|
||||
func parseCommand(ctx context.Context, command string, userId int64, repo *repo.Repository) (string, error) {
|
||||
variables, err := devcontainer_models.GetVariables(ctx, userId, repo.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Get all secrets and merge them into variables map
|
||||
// Secrets override variables with the same name
|
||||
secrets, err := devcontainer_models.GetSecrets(ctx, userId, repo.ID)
|
||||
if err != nil {
|
||||
log.Error("get secrets for parseCommand: %v", err)
|
||||
// Continue with variables only if secrets retrieval fails
|
||||
} else {
|
||||
// Merge secrets into variables, secrets override variables with same name
|
||||
for key, value := range secrets {
|
||||
variables[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
var variablesName []string
|
||||
variablesCircle := checkEachVariable(variables)
|
||||
|
||||
92
services/devcontainer/devcontainer_secrets.go
Normal file
92
services/devcontainer/devcontainer_secrets.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package devcontainer
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
devcontainer_model "code.gitea.io/gitea/models/devcontainer"
|
||||
secret_service "code.gitea.io/gitea/services/secrets"
|
||||
)
|
||||
|
||||
func CreateSecret(ctx context.Context, ownerID, repoID int64, name, data, description string) (*devcontainer_model.DevcontainerSecret, error) {
|
||||
if err := secret_service.ValidateName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return devcontainer_model.InsertEncryptedSecret(ctx, ownerID, repoID, name, data, description)
|
||||
}
|
||||
|
||||
func CreateOrUpdateSecret(ctx context.Context, ownerID, repoID int64, name, data, description string) (*devcontainer_model.DevcontainerSecret, bool, error) {
|
||||
if err := secret_service.ValidateName(name); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
s, err := db.Find[devcontainer_model.DevcontainerSecret](ctx, devcontainer_model.FindSecretsOpts{
|
||||
OwnerID: ownerID,
|
||||
RepoID: repoID,
|
||||
Name: name,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if len(s) == 0 {
|
||||
s, err := devcontainer_model.InsertEncryptedSecret(ctx, ownerID, repoID, name, data, description)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return s, true, nil
|
||||
}
|
||||
|
||||
if err := devcontainer_model.UpdateSecret(ctx, s[0].ID, data, description); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return s[0], false, nil
|
||||
}
|
||||
|
||||
func DeleteSecretByID(ctx context.Context, ownerID, repoID, secretID int64) error {
|
||||
s, err := db.Find[devcontainer_model.DevcontainerSecret](ctx, devcontainer_model.FindSecretsOpts{
|
||||
OwnerID: ownerID,
|
||||
RepoID: repoID,
|
||||
SecretID: secretID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(s) != 1 {
|
||||
return devcontainer_model.ErrSecretNotFound{}
|
||||
}
|
||||
|
||||
return deleteSecret(ctx, s[0])
|
||||
}
|
||||
|
||||
func DeleteSecretByName(ctx context.Context, ownerID, repoID int64, name string) error {
|
||||
if err := secret_service.ValidateName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s, err := db.Find[devcontainer_model.DevcontainerSecret](ctx, devcontainer_model.FindSecretsOpts{
|
||||
OwnerID: ownerID,
|
||||
RepoID: repoID,
|
||||
Name: name,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(s) != 1 {
|
||||
return devcontainer_model.ErrSecretNotFound{}
|
||||
}
|
||||
|
||||
return deleteSecret(ctx, s[0])
|
||||
}
|
||||
|
||||
func deleteSecret(ctx context.Context, s *devcontainer_model.DevcontainerSecret) error {
|
||||
if _, err := db.DeleteByID[devcontainer_model.DevcontainerSecret](ctx, s.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings actions")}}
|
||||
<div class="org-setting-content">
|
||||
{{if eq .PageType "variables"}}
|
||||
{{template "shared/devcontainer/variable_list" .}}
|
||||
{{end}}
|
||||
{{if eq .PageType "variables"}}
|
||||
{{template "shared/devcontainer/variable_list" .}}
|
||||
{{else if eq .PageType "secrets"}}
|
||||
{{template "shared/devcontainer/secret_list" .}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "org/settings/layout_footer" .}}
|
||||
|
||||
@@ -41,9 +41,12 @@
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
<details class="item toggleable-item" {{if or .PageIsSharedSettingsDevcontainerVariables}}open{{end}}>
|
||||
<details class="item toggleable-item" {{if or .PageIsSharedSettingsDevcontainerVariables .PageIsSharedSettingsDevcontainerSecrets}}open{{end}}>
|
||||
<summary>{{ctx.Locale.Tr "admin.devcontainer"}}</summary>
|
||||
<div class="menu">
|
||||
<a class="{{if .PageIsSharedSettingsDevcontainerSecrets}}active {{end}}item" href="{{.OrgLink}}/settings/devcontainer/secrets">
|
||||
{{ctx.Locale.Tr "devcontainer.secrets"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSharedSettingsDevcontainerVariables}}active {{end}}item" href="{{.OrgLink}}/settings/devcontainer/variables">
|
||||
{{ctx.Locale.Tr "devcontainer.variables"}}
|
||||
</a>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings actions")}}
|
||||
<div class="repo-setting-content">
|
||||
{{if eq .PageType "variables"}}
|
||||
{{template "shared/devcontainer/variable_list" .}}
|
||||
{{end}}
|
||||
{{template "shared/devcontainer/variable_list" .}}
|
||||
{{else if eq .PageType "secrets"}}
|
||||
{{template "shared/devcontainer/secret_list" .}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "repo/settings/layout_footer" .}}
|
||||
@@ -54,9 +54,12 @@
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
<details class="item toggleable-item" {{if or .PageIsSharedSettingsDevcontainerVariables}}open{{end}}>
|
||||
<details class="item toggleable-item" {{if or .PageIsSharedSettingsDevcontainerVariables .PageIsSharedSettingsDevcontainerSecrets}}open{{end}}>
|
||||
<summary>{{ctx.Locale.Tr "admin.devcontainer"}}</summary>
|
||||
<div class="menu">
|
||||
<a class="{{if .PageIsSharedSettingsDevcontainerSecrets}}active {{end}}item" href="{{.RepoLink}}/settings/devcontainer/secrets">
|
||||
{{ctx.Locale.Tr "devcontainer.secrets"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSharedSettingsDevcontainerVariables}}active {{end}}item" href="{{.RepoLink}}/settings/devcontainer/variables">
|
||||
{{ctx.Locale.Tr "devcontainer.variables"}}
|
||||
</a>
|
||||
|
||||
346
templates/shared/devcontainer/secret_list.tmpl
Normal file
346
templates/shared/devcontainer/secret_list.tmpl
Normal file
@@ -0,0 +1,346 @@
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "devcontainer.scripts"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
{{ctx.Locale.Tr "devcontainer.secrets.scripts.description"}}
|
||||
<div class="dynamic-tags" data-tags='{{.Tags}}'></div>
|
||||
</div>
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "devcontainer.secrets.management"}}
|
||||
<div class="ui right">
|
||||
<button class="ui primary tiny button show-modal"
|
||||
data-modal="#add-secret-modal"
|
||||
data-modal-form.action="{{.Link}}/new"
|
||||
data-modal-header="{{ctx.Locale.Tr "devcontainer.secrets.add_secret"}}"
|
||||
data-modal-secret-name.value=""
|
||||
data-modal-secret-name.read-only="false"
|
||||
data-modal-secret-data=""
|
||||
data-modal-secret-description=""
|
||||
>
|
||||
{{ctx.Locale.Tr "devcontainer.secrets.add_secret"}}
|
||||
</button>
|
||||
</div>
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
{{if .Secrets}}
|
||||
<div class="flex-list">
|
||||
{{range .Secrets}}
|
||||
<div class="flex-item tw-items-center">
|
||||
<div class="flex-item-leading">
|
||||
{{svg "octicon-key" 32}}
|
||||
</div>
|
||||
<div class="flex-item-main">
|
||||
<div class="flex-item-title">
|
||||
{{.Name}}
|
||||
</div>
|
||||
<div class="flex-item-body">
|
||||
{{if .Description}}{{.Description}}{{else}}-{{end}}
|
||||
</div>
|
||||
<div class="flex-item-body">
|
||||
******
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-item-trailing">
|
||||
<span class="color-text-light-2">
|
||||
{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}}
|
||||
</span>
|
||||
<button class="btn interact-bg tw-p-2 show-modal"
|
||||
data-tooltip-content="{{ctx.Locale.Tr "devcontainer.secrets.edit_secret"}}"
|
||||
data-modal="#add-secret-modal"
|
||||
data-modal-form.action="{{$.Link}}/{{.ID}}/edit"
|
||||
data-modal-header="{{ctx.Locale.Tr "devcontainer.secrets.edit_secret"}}"
|
||||
data-modal-secret-name.value="{{.Name}}"
|
||||
data-modal-secret-name.read-only="true"
|
||||
data-modal-secret-data=""
|
||||
data-modal-secret-description="{{if .Description}}{{.Description}}{{end}}"
|
||||
>
|
||||
{{svg "octicon-pencil"}}
|
||||
</button>
|
||||
<button class="btn interact-bg tw-p-2 link-action"
|
||||
data-tooltip-content="{{ctx.Locale.Tr "devcontainer.secrets.deletion"}}"
|
||||
data-url="{{$.Link}}/{{.ID}}/delete"
|
||||
data-modal-confirm="{{ctx.Locale.Tr "devcontainer.secrets.deletion.description"}}"
|
||||
>
|
||||
{{svg "octicon-trash"}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
{{ctx.Locale.Tr "devcontainer.secrets.none"}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{/* Add secret dialog */}}
|
||||
<div class="ui small modal" id="add-secret-modal">
|
||||
<div class="header"></div>
|
||||
<form class="ui form form-fetch-action" method="post">
|
||||
<div class="content">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="field">
|
||||
{{ctx.Locale.Tr "devcontainer.secrets.description"}}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="secret-name">{{ctx.Locale.Tr "name"}}</label>
|
||||
<input autofocus required
|
||||
id="secret-name"
|
||||
name="name"
|
||||
value="{{.name}}"
|
||||
pattern="^(?!GITEA_|GITHUB_)[a-zA-Z_][a-zA-Z0-9_]*$"
|
||||
placeholder="{{ctx.Locale.Tr "secrets.creation.name_placeholder"}}"
|
||||
>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="secret-data">{{ctx.Locale.Tr "value"}}</label>
|
||||
<textarea required
|
||||
id="secret-data"
|
||||
name="data"
|
||||
maxlength="{{.DataMaxLength}}"
|
||||
placeholder="{{ctx.Locale.Tr "secrets.creation.value_placeholder"}}"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="secret-description">{{ctx.Locale.Tr "secrets.creation.description"}}</label>
|
||||
<textarea
|
||||
id="secret-description"
|
||||
name="description"
|
||||
rows="2"
|
||||
maxlength="{{.DescriptionMaxLength}}"
|
||||
placeholder="{{ctx.Locale.Tr "secrets.creation.description_placeholder"}}"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initDynamicTags() {
|
||||
// 查找所有具有 dynamic-tags 类的元素
|
||||
const elements = document.querySelectorAll('.dynamic-tags');
|
||||
|
||||
elements.forEach(el => {
|
||||
// 获取标签数据
|
||||
let tags = [];
|
||||
try {
|
||||
tags = JSON.parse(el.getAttribute('data-tags') || '[]');
|
||||
} catch (e) {
|
||||
console.error('Invalid tags data:', el.getAttribute('data-tags'));
|
||||
}
|
||||
|
||||
// 创建容器
|
||||
const container = document.createElement('div');
|
||||
container.className = 'dynamic-tags-container';
|
||||
|
||||
// 创建标签列表容器
|
||||
const tagList = document.createElement('div');
|
||||
tagList.className = 'dynamic-tags-list';
|
||||
|
||||
// 渲染标签
|
||||
function renderTags() {
|
||||
// 清空标签列表
|
||||
tagList.innerHTML = '';
|
||||
|
||||
// 添加每个标签
|
||||
tags.forEach(tag => {
|
||||
const tagElement = document.createElement('span');
|
||||
tagElement.className = 'tag-item';
|
||||
tagElement.innerHTML = `
|
||||
${tag}
|
||||
<button class="tag-close" data-tag="${tag}">×</button>
|
||||
`;
|
||||
tagList.appendChild(tagElement);
|
||||
});
|
||||
|
||||
// 添加"新增标签"按钮或输入框
|
||||
const inputContainer = document.createElement('span');
|
||||
inputContainer.className = 'tag-input-container';
|
||||
inputContainer.innerHTML = `
|
||||
<button class="tag-add-button">+ New Script</button>
|
||||
<input type="text" class="tag-input" style="display: none;" placeholder="Enter tag">
|
||||
`;
|
||||
|
||||
tagList.appendChild(inputContainer);
|
||||
|
||||
// 绑定事件
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
// 绑定事件
|
||||
function bindEvents() {
|
||||
// 删除标签事件
|
||||
tagList.querySelectorAll('.tag-close').forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const tag = e.target.getAttribute('data-tag');
|
||||
// 删除标签时访问 /script/delete
|
||||
fetch('{{.Link}}/script/delete?name=' + encodeURIComponent(tag), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
tags = tags.filter(t => t !== tag);
|
||||
renderTags();
|
||||
console.log('Successfully deleted script for secret: ' + tag);
|
||||
} else {
|
||||
console.error('Failed to delete script for secret: ' + tag);
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('Error deleting script:', error);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
// 显示输入框事件
|
||||
const addButton = tagList.querySelector('.tag-add-button');
|
||||
const tagInput = tagList.querySelector('.tag-input');
|
||||
|
||||
addButton.addEventListener('click', () => {
|
||||
addButton.style.display = 'none';
|
||||
tagInput.style.display = 'inline-block';
|
||||
tagInput.focus();
|
||||
});
|
||||
|
||||
// 添加标签事件
|
||||
tagInput.addEventListener('keyup', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const value = e.target.value.trim();
|
||||
if (value && !tags.includes(value)) {
|
||||
// 当按下 Enter 键时,发送请求到 /script/new
|
||||
fetch('{{.Link}}/script/new?name=' + encodeURIComponent(value), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
console.log('Successfully created script for secret: ' + value);
|
||||
tags.push(value);
|
||||
renderTags();
|
||||
} else {
|
||||
console.error('Failed to create script for secret: ' + value);
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('Error creating script:', error);
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 失去焦点时隐藏输入框
|
||||
tagInput.addEventListener('blur', () => {
|
||||
const value = tagInput.value.trim();
|
||||
if (value && !tags.includes(value)) {
|
||||
fetch('{{.Link}}/script/new?name=' + encodeURIComponent(value), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
console.log('Successfully created script for secret: ' + value);
|
||||
tags.push(value);
|
||||
renderTags();
|
||||
} else {
|
||||
console.error('Failed to create script for secret: ' + value);
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('Error creating script:', error);
|
||||
});
|
||||
}
|
||||
addButton.style.display = 'inline-flex';
|
||||
tagInput.style.display = 'none';
|
||||
tagInput.value = '';
|
||||
renderTags();
|
||||
});
|
||||
}
|
||||
|
||||
// 初始渲染
|
||||
renderTags();
|
||||
container.appendChild(tagList);
|
||||
el.innerHTML = '';
|
||||
el.appendChild(container);
|
||||
});
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initDynamicTags();
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.dynamic-tags-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.tag-close {
|
||||
margin-left: 6px;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tag-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tag-add-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: transparent;
|
||||
border: 1px dashed #6c757d;
|
||||
color: #6c757d;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag-add-button:hover {
|
||||
border-color: #007bff;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.tag-input-container {
|
||||
display: inline-block;
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.tag-input {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #6c757d;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tag-input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,10 @@
|
||||
{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings actions")}}
|
||||
<div class="user-setting-content">
|
||||
{{if eq .PageType "variables"}}
|
||||
{{template "shared/devcontainer/variable_list" .}}
|
||||
{{end}}
|
||||
{{if eq .PageType "variables"}}
|
||||
{{template "shared/devcontainer/variable_list" .}}
|
||||
{{else if eq .PageType "secrets"}}
|
||||
{{template "shared/devcontainer/secret_list" .}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{template "user/settings/layout_footer" .}}
|
||||
|
||||
@@ -68,9 +68,12 @@
|
||||
<a class="{{if .PageIsSettingsRepos}}active {{end}}item" href="{{AppSubUrl}}/user/settings/repos">
|
||||
{{ctx.Locale.Tr "settings.repos"}}
|
||||
</a>
|
||||
<details class="item toggleable-item" {{if or .PageIsSharedSettingsDevcontainerVariables}}open{{end}}>
|
||||
<details class="item toggleable-item" {{if or .PageIsSharedSettingsDevcontainerVariables .PageIsSharedSettingsDevcontainerSecrets}}open{{end}}>
|
||||
<summary>{{ctx.Locale.Tr "admin.devcontainer"}}</summary>
|
||||
<div class="menu">
|
||||
<a class="{{if .PageIsSharedSettingsDevcontainerSecrets}}active {{end}}item" href="{{AppSubUrl}}/user/settings/devcontainer/secrets">
|
||||
{{ctx.Locale.Tr "devcontainer.secrets"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSharedSettingsDevcontainerVariables}}active {{end}}item" href="{{AppSubUrl}}/user/settings/devcontainer/variables">
|
||||
{{ctx.Locale.Tr "devcontainer.variables"}}
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user