Compare commits

...

2 Commits

Author SHA1 Message Date
ed41657c22 Merge branch 'main' into feature/devcontainer-secrets
Some checks failed
DevStar Studio Auto Test Pipeline / unit-frontend-test (pull_request) Has been cancelled
DevStar Studio Auto Test Pipeline / unit-backend-test (pull_request) Has been cancelled
DevStar Studio CI/CD Pipeline / build-and-push-x86-64-docker-image (pull_request) Has been cancelled
DevStar E2E Test / e2e-test (pull_request) Has been cancelled
2025-12-16 06:45:51 +00:00
hwy
a1bc0c9187 devcontainer添加密匙
Some checks failed
DevStar Studio Auto Test Pipeline / unit-frontend-test (pull_request) Failing after 1m34s
DevStar Studio Auto Test Pipeline / unit-backend-test (pull_request) Failing after 33s
DevStar Studio CI/CD Pipeline / build-and-push-x86-64-docker-image (pull_request) Successful in 7m21s
DevStar E2E Test / e2e-test (pull_request) Successful in 8m47s
2025-12-16 13:49:22 +08:00
15 changed files with 1200 additions and 11 deletions

View File

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

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

View File

@@ -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

View File

@@ -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=工作流

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

View File

@@ -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() {

View File

@@ -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)

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

View File

@@ -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" .}}

View File

@@ -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>

View File

@@ -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" .}}

View File

@@ -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>

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

View File

@@ -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" .}}

View File

@@ -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>