Compare commits
3 Commits
acad913bf2
...
25c0d44218
| Author | SHA1 | Date | |
|---|---|---|---|
| 25c0d44218 | |||
| 130b070a79 | |||
| 197b067d80 |
@@ -0,0 +1,57 @@
|
||||
package db
|
||||
|
||||
import "database/sql"
|
||||
import "time"
|
||||
|
||||
// TransferRepo reparents a repository to another project by changing its owning
|
||||
// project_id. Physical storage is keyed by the numeric repo id (not the project),
|
||||
// so no files move. The caller is responsible for enforcing the destination-project
|
||||
// name/type uniqueness (idx_repos_project_name_type) before calling — a collision
|
||||
// surfaces here as a constraint error.
|
||||
func (s *Store) TransferRepo(repoPublicID string, destProjectID string) error {
|
||||
var result sql.Result
|
||||
var rows int64
|
||||
var err error
|
||||
|
||||
result, err = s.Exec(`UPDATE repos
|
||||
SET project_id = (SELECT id FROM projects WHERE public_id = ?)
|
||||
WHERE public_id = ?`, destProjectID, repoPublicID)
|
||||
if err != nil { return err }
|
||||
|
||||
rows, err = result.RowsAffected()
|
||||
if err != nil { return err }
|
||||
if rows <= 0 { return sql.ErrNoRows }
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearRepoSigningKey detaches a repo's signing key. Used when a transfer moves a
|
||||
// repo away from a project whose project-scoped GPG key is no longer selectable.
|
||||
func (s *Store) ClearRepoSigningKey(repoPublicID string) error {
|
||||
var err error
|
||||
_, err = s.Exec(`UPDATE repos SET signing_key_id = NULL WHERE public_id = ?`, repoPublicID)
|
||||
return err
|
||||
}
|
||||
|
||||
// TransferBoard reparents a board to another project. Board child rows (blocks,
|
||||
// comments, etc.) are keyed by board id and follow automatically; explicit
|
||||
// board_members that are not members of the destination project are pruned by the
|
||||
// caller after this returns.
|
||||
func (s *Store) TransferBoard(boardPublicID string, destProjectID string, updatedBy string) error {
|
||||
var result sql.Result
|
||||
var rows int64
|
||||
var err error
|
||||
|
||||
result, err = s.Exec(`UPDATE boards
|
||||
SET project_id = (SELECT id FROM projects WHERE public_id = ?),
|
||||
updated_by = COALESCE((SELECT id FROM users WHERE public_id = ?), updated_by),
|
||||
updated_at = ?
|
||||
WHERE public_id = ? AND delete_at = 0`,
|
||||
destProjectID, updatedBy, time.Now().UTC().Unix(), boardPublicID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err = result.RowsAffected()
|
||||
if err != nil { return err }
|
||||
if rows <= 0 { return sql.ErrNoRows }
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package handlers
|
||||
|
||||
import "database/sql"
|
||||
import "net/http"
|
||||
import "strings"
|
||||
|
||||
import "codit/internal/middleware"
|
||||
import "codit/internal/models"
|
||||
|
||||
// transferRequest carries the destination project for a repo/board transfer.
|
||||
type transferRequest struct {
|
||||
TargetProjectID string `json:"target_project_id"`
|
||||
}
|
||||
|
||||
// transferRepoResponse is the repo response plus a flag telling the UI that the
|
||||
// signing key was dropped because it was scoped to the old project.
|
||||
type transferRepoResponse struct {
|
||||
repoResponse
|
||||
SigningKeyCleared bool `json:"signing_key_cleared"`
|
||||
}
|
||||
|
||||
// transferBoardResponse is the moved board plus any board members that were
|
||||
// pruned because they are not members of the destination project.
|
||||
type transferBoardResponse struct {
|
||||
models.Board
|
||||
PrunedMembers []string `json:"pruned_members,omitempty"`
|
||||
}
|
||||
|
||||
// TransferRepo reparents a repository to another project. The caller must be an
|
||||
// admin on BOTH the source (owning) and destination projects. Physical storage is
|
||||
// keyed by the numeric repo id, so nothing moves on disk; but the public clone /
|
||||
// RPM / Docker URLs (which embed the project slug) change, and access re-scopes to
|
||||
// the destination project's members. A project-scoped signing key that is not
|
||||
// selectable in the destination is dropped.
|
||||
func (api *API) TransferRepo(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var repo models.Repo
|
||||
var req transferRequest
|
||||
var destProject models.Project
|
||||
var destID string
|
||||
var prevSigningKeyID string
|
||||
var signingKeyCleared bool
|
||||
var exists bool
|
||||
var err error
|
||||
var cloneURL string
|
||||
var rpmURL string
|
||||
var dockerURL string
|
||||
|
||||
repo, err = api.getRepoResolved(api.store(r), params["id"])
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "repo not found")
|
||||
return
|
||||
}
|
||||
// Admin on the source (owning) project.
|
||||
if !api.requireProjectRole(w, r, repo.ProjectID, models.RoleAdmin) {
|
||||
return
|
||||
}
|
||||
|
||||
err = DecodeJSON(r, &req)
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
destID = strings.TrimSpace(req.TargetProjectID)
|
||||
if destID == "" {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "target_project_id required")
|
||||
return
|
||||
}
|
||||
if destID == repo.ProjectID {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "repository is already in this project")
|
||||
return
|
||||
}
|
||||
|
||||
destProject, err = api.store(r).GetProject(destID)
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "target project not found")
|
||||
return
|
||||
}
|
||||
// Admin on the destination project too.
|
||||
if !api.requireProjectRole(w, r, destProject.ID, models.RoleAdmin) {
|
||||
return
|
||||
}
|
||||
|
||||
// Hard-block a name/type collision in the destination (idx_repos_project_name_type).
|
||||
exists, err = api.store(r).RepoNameExists(destProject.ID, repo.Name, repo.Type)
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusConflict, "destination project already has a "+repo.Type+" repository named \""+repo.Name+"\"")
|
||||
return
|
||||
}
|
||||
|
||||
err = api.store(r).TransferRepo(repo.ID, destProject.ID)
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// If the repo was also foreign-attached to the destination, that attachment is
|
||||
// now redundant (it owns it). Drop it (no-op if it was never attached).
|
||||
err = api.store(r).DetachRepoFromProject(destProject.ID, repo.ID)
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Re-scope the signing key: a key scoped to the old project is not selectable
|
||||
// in the destination, so clear it rather than leave signing misconfigured.
|
||||
prevSigningKeyID = strings.TrimSpace(repo.SigningKeyID)
|
||||
if prevSigningKeyID != "" {
|
||||
var selectable bool
|
||||
selectable, err = api.store(r).GPGKeySelectableByProject(prevSigningKeyID, destProject.ID)
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !selectable {
|
||||
err = api.store(r).ClearRepoSigningKey(repo.ID)
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
signingKeyCleared = true
|
||||
}
|
||||
}
|
||||
|
||||
repo, err = api.getRepoResolved(api.store(r), repo.ID)
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// A cleared signing key on an RPM repo means its published signature/key are
|
||||
// stale; rebuild repodata so the on-disk metadata reflects the new state.
|
||||
if signingKeyCleared && repo.Type == models.RepoTypeRPM {
|
||||
api.scheduleRepoRepodataRebuilds(repo.Path)
|
||||
}
|
||||
|
||||
if repo.Type == models.RepoTypeGit {
|
||||
cloneURL = api.cloneURL(destProject.Slug, repo.Name)
|
||||
}
|
||||
if repo.Type == models.RepoTypeRPM {
|
||||
rpmURL = api.rpmURL(destProject.Slug, repo.Name)
|
||||
}
|
||||
if repo.Type == models.RepoTypeDocker {
|
||||
dockerURL = api.dockerURL(destProject.Slug, repo.Name)
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, transferRepoResponse{
|
||||
repoResponse: repoResponse{
|
||||
Repo: repo,
|
||||
CloneURL: cloneURL,
|
||||
RPMURL: rpmURL,
|
||||
DockerURL: dockerURL,
|
||||
OwnerProjectName: destProject.Name,
|
||||
OwnerProject: destProject.ID,
|
||||
OwnerSlug: destProject.Slug,
|
||||
},
|
||||
SigningKeyCleared: signingKeyCleared,
|
||||
})
|
||||
}
|
||||
|
||||
// TransferBoard reparents a board to another project. The caller must be an admin
|
||||
// on BOTH the source and destination projects. Board child rows follow the board
|
||||
// id automatically; explicit board members that are not members of the destination
|
||||
// project are pruned (they would otherwise retain access after the move).
|
||||
func (api *API) TransferBoard(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var board models.Board
|
||||
var req transferRequest
|
||||
var destProject models.Project
|
||||
var destID string
|
||||
var updatedBy string
|
||||
var members []models.BoardMember
|
||||
var pruned []string
|
||||
var m models.BoardMember
|
||||
var user models.User
|
||||
var ok bool
|
||||
var err error
|
||||
|
||||
board, err = api.store(r).GetBoard(params["boardId"])
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "board not found")
|
||||
return
|
||||
}
|
||||
// Admin on the source project.
|
||||
if !api.requireProjectRole(w, r, board.ProjectID, models.RoleAdmin) {
|
||||
return
|
||||
}
|
||||
|
||||
err = DecodeJSON(r, &req)
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
destID = strings.TrimSpace(req.TargetProjectID)
|
||||
if destID == "" {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "target_project_id required")
|
||||
return
|
||||
}
|
||||
if destID == board.ProjectID {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "board is already in this project")
|
||||
return
|
||||
}
|
||||
|
||||
destProject, err = api.store(r).GetProject(destID)
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "target project not found")
|
||||
return
|
||||
}
|
||||
// Admin on the destination project too.
|
||||
if !api.requireProjectRole(w, r, destProject.ID, models.RoleAdmin) {
|
||||
return
|
||||
}
|
||||
|
||||
members, err = api.store(r).ListBoardMembers(board.ID)
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if ok {
|
||||
updatedBy = user.ID
|
||||
}
|
||||
|
||||
err = api.store(r).TransferBoard(board.ID, destProject.ID, updatedBy)
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Prune explicit board members who have no role in the destination project;
|
||||
// otherwise they would keep board access across the move.
|
||||
for _, m = range members {
|
||||
var role string
|
||||
var rerr error
|
||||
role, rerr = api.store(r).GetProjectRoleForUser(destProject.ID, m.UserID)
|
||||
if rerr == sql.ErrNoRows || (rerr == nil && role == "") {
|
||||
err = api.store(r).DeleteBoardMember(board.ID, m.UserID)
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
pruned = append(pruned, m.Username)
|
||||
continue
|
||||
}
|
||||
if rerr != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, rerr.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
board, err = api.store(r).GetBoard(board.ID)
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, transferBoardResponse{Board: board, PrunedMembers: pruned})
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func buildTeamsPayload(event models.Event) teamsMessageCard {
|
||||
}
|
||||
|
||||
func (d *Dispatcher) deliverTeams(ctx context.Context, target models.DeliveryTarget, event models.Event) error {
|
||||
return d.postJSON(ctx, target.ChannelName, target.Address, buildTeamsPayload(event))
|
||||
return d.postJSON(ctx, target.ChannelName, target.Address, buildTeamsPayload(d.withOriginTitle(event)))
|
||||
}
|
||||
|
||||
// slackMessage uses the attachment form so the severity color renders as a bar.
|
||||
@@ -122,5 +122,5 @@ func buildSlackPayload(event models.Event) slackMessage {
|
||||
}
|
||||
|
||||
func (d *Dispatcher) deliverSlack(ctx context.Context, target models.DeliveryTarget, event models.Event) error {
|
||||
return d.postJSON(ctx, target.ChannelName, target.Address, buildSlackPayload(event))
|
||||
return d.postJSON(ctx, target.ChannelName, target.Address, buildSlackPayload(d.withOriginTitle(event)))
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestDeliverSlackPostsPayload(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
|
||||
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
|
||||
err = d.deliverSlack(context.Background(), models.DeliveryTarget{ChannelName: "alerts", TransportType: models.TransportTypeSlack, Address: srv.URL}, models.Event{Title: "down", Body: "refused", Severity: models.EventSeverityCritical})
|
||||
if err != nil {
|
||||
t.Fatalf("deliverSlack error: %v", err)
|
||||
@@ -75,7 +75,7 @@ func TestDeliverTeamsErrorsOnBadStatus(t *testing.T) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
defer srv.Close()
|
||||
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
|
||||
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
|
||||
err = d.deliverTeams(context.Background(), models.DeliveryTarget{ChannelName: "t", TransportType: models.TransportTypeMSTeams, Address: srv.URL}, models.Event{Title: "x"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on 400 status")
|
||||
@@ -85,7 +85,7 @@ func TestDeliverTeamsErrorsOnBadStatus(t *testing.T) {
|
||||
func TestPostJSONErrorsOnEmptyURL(t *testing.T) {
|
||||
var d *Dispatcher
|
||||
var err error
|
||||
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
|
||||
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
|
||||
err = d.postJSON(context.Background(), "c", "", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on empty URL")
|
||||
|
||||
@@ -8,6 +8,7 @@ package notify
|
||||
import "context"
|
||||
import "encoding/json"
|
||||
import "net/http"
|
||||
import "strings"
|
||||
import "time"
|
||||
|
||||
import "codit/internal/db"
|
||||
@@ -30,23 +31,29 @@ func (d *Dispatcher) webhookTimeout() time.Duration {
|
||||
|
||||
// Dispatcher delivers events to the channels attached to their source monitor.
|
||||
type Dispatcher struct {
|
||||
store *db.Store
|
||||
logger codit_logger.Logger
|
||||
secrets *secretcrypto.Box
|
||||
policy monitor.EgressPolicy
|
||||
client *http.Client
|
||||
store *db.Store
|
||||
logger codit_logger.Logger
|
||||
secrets *secretcrypto.Box
|
||||
policy monitor.EgressPolicy
|
||||
client *http.Client
|
||||
serverID string // this deployment's service id, stamped on outbound alerts
|
||||
siteName string // this deployment's human site name, stamped on outbound alerts
|
||||
}
|
||||
|
||||
// NewDispatcher builds a dispatcher. The HTTP client dials through the egress
|
||||
// policy so webhook delivery is subject to the same SSRF guard as the probes;
|
||||
// dataDir locates the deployment key used to decrypt transport secrets. The
|
||||
// policy is retained so per-transport TLS variants can reuse the same dialer.
|
||||
func NewDispatcher(store *db.Store, policy monitor.EgressPolicy, dataDir string, logger codit_logger.Logger) *Dispatcher {
|
||||
// serverID and siteName identify this deployment and are stamped onto alerts
|
||||
// sent to external channels (never onto the persisted event / in-app feed).
|
||||
func NewDispatcher(store *db.Store, serverID string, siteName string, policy monitor.EgressPolicy, dataDir string, logger codit_logger.Logger) *Dispatcher {
|
||||
return &Dispatcher{
|
||||
store: store,
|
||||
logger: logger,
|
||||
secrets: secretcrypto.New(dataDir),
|
||||
policy: policy,
|
||||
store: store,
|
||||
logger: logger,
|
||||
secrets: secretcrypto.New(dataDir),
|
||||
policy: policy,
|
||||
serverID: strings.TrimSpace(serverID),
|
||||
siteName: strings.TrimSpace(siteName),
|
||||
client: &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: policy.DialContext,
|
||||
@@ -56,6 +63,33 @@ func NewDispatcher(store *db.Store, policy monitor.EgressPolicy, dataDir string,
|
||||
}
|
||||
}
|
||||
|
||||
// originLabel identifies the sending deployment for external recipients as
|
||||
// "<service-id> (<site-name>)" — the app's own self-identity convention, kept
|
||||
// ASCII so it is safe in an email Subject header. Drops whichever part is empty;
|
||||
// returns "" if both are empty (then no decoration is applied).
|
||||
func (d *Dispatcher) originLabel() string {
|
||||
if d.serverID != "" && d.siteName != "" {
|
||||
return d.siteName + " (" + d.serverID + ")"
|
||||
}
|
||||
if d.siteName != "" {
|
||||
return d.siteName
|
||||
}
|
||||
return d.serverID
|
||||
}
|
||||
|
||||
// withOriginTitle returns a copy of the event whose Title is prefixed with the
|
||||
// sending deployment's identity, so external recipients can tell at a glance
|
||||
// which system raised the alert. The persisted event is passed by value, so the
|
||||
// in-app feed and other channels are unaffected.
|
||||
func (d *Dispatcher) withOriginTitle(event models.Event) models.Event {
|
||||
var origin string
|
||||
origin = d.originLabel()
|
||||
if origin != "" {
|
||||
event.Title = "[" + origin + "] " + event.Title
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
// secretMap decrypts a stored transport secret blob into its field map.
|
||||
func (d *Dispatcher) secretMap(encrypted string) map[string]string {
|
||||
var plain string
|
||||
|
||||
@@ -165,7 +165,7 @@ func (d *Dispatcher) deliverMSGraph(ctx context.Context, target models.DeliveryT
|
||||
}
|
||||
|
||||
sendURL = "https://" + graphHost + "/v1.0/users/" + url.PathEscape(strings.TrimSpace(cfg.Sender)) + "/sendMail"
|
||||
payload, err = json.Marshal(buildGraphSendMail(event, recipients))
|
||||
payload, err = json.Marshal(buildGraphSendMail(d.withOriginTitle(event), recipients))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ func (d *Dispatcher) deliverSMTP(ctx context.Context, target models.DeliveryTarg
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = wc.Write(buildEmailMessage(cfg.From, recipients, event.Title, event.Body, time.Unix(event.CreatedAt, 0).UTC()))
|
||||
_, err = wc.Write(buildEmailMessage(cfg.From, recipients, d.withOriginTitle(event).Title, event.Body, time.Unix(event.CreatedAt, 0).UTC()))
|
||||
if err != nil {
|
||||
wc.Close()
|
||||
return err
|
||||
|
||||
@@ -98,6 +98,10 @@ type webhookPayload struct {
|
||||
Payload string `json:"payload,omitempty"`
|
||||
Channel string `json:"channel"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
// Sending-deployment identity so a receiver can tell which system raised the
|
||||
// alert without inspecting the URL it was delivered to.
|
||||
ServiceID string `json:"service_id,omitempty"`
|
||||
SiteName string `json:"site_name,omitempty"`
|
||||
}
|
||||
|
||||
// deliverWebhook POSTs the event as JSON to the channel's address. The client's
|
||||
@@ -143,6 +147,8 @@ func (d *Dispatcher) deliverWebhook(ctx context.Context, target models.DeliveryT
|
||||
Payload: event.Payload,
|
||||
Channel: target.ChannelName,
|
||||
CreatedAt: event.CreatedAt,
|
||||
ServiceID: d.serverID,
|
||||
SiteName: d.siteName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestDeliverWebhookPostsJSONWithAuthHeader(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
|
||||
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
|
||||
target = models.DeliveryTarget{
|
||||
ChannelName: "ops-webhook",
|
||||
TransportType: models.TransportTypeWebhook,
|
||||
@@ -79,7 +79,7 @@ func TestDeliverWebhookErrorsOnBadStatus(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
|
||||
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
|
||||
err = d.deliverWebhook(context.Background(), models.DeliveryTarget{
|
||||
ChannelName: "ops-webhook",
|
||||
TransportType: models.TransportTypeWebhook,
|
||||
@@ -94,7 +94,7 @@ func TestClientForDefaultsToSharedClient(t *testing.T) {
|
||||
var d *Dispatcher
|
||||
var c *http.Client
|
||||
var err error
|
||||
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
|
||||
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
|
||||
c, err = d.clientFor(webhookConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -108,7 +108,7 @@ func TestClientForInsecureSkipVerifyBuildsClient(t *testing.T) {
|
||||
var d *Dispatcher
|
||||
var c *http.Client
|
||||
var err error
|
||||
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
|
||||
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
|
||||
c, err = d.clientFor(webhookConfig{InsecureSkipVerify: true})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -123,7 +123,7 @@ func TestClientForFailsClosedWhenCAUnresolvable(t *testing.T) {
|
||||
var err error
|
||||
// nil store => the pinned CA cannot be resolved; must fail closed rather
|
||||
// than fall back to system roots.
|
||||
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
|
||||
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
|
||||
_, err = d.clientFor(webhookConfig{CARef: "ca-123"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected fail-closed error when CA cannot be resolved")
|
||||
@@ -133,7 +133,7 @@ func TestClientForFailsClosedWhenCAUnresolvable(t *testing.T) {
|
||||
func TestDeliverWebhookErrorsOnEmptyAddress(t *testing.T) {
|
||||
var d *Dispatcher
|
||||
var err error
|
||||
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
|
||||
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
|
||||
err = d.deliverWebhook(context.Background(), models.DeliveryTarget{ChannelName: "x", TransportType: models.TransportTypeWebhook}, map[string]string{}, testEvent())
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on empty address, got nil")
|
||||
|
||||
+3
-1
@@ -699,7 +699,7 @@ func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger, identi
|
||||
// External-delivery relay: drains the events outbox and dispatches each event
|
||||
// to its matching notification channels (rules + per-monitor attachment). One
|
||||
// path for every producer (monitoring, RPM mirror, …).
|
||||
app.notify_relay = notify.NewRelay(app.store, notify.NewDispatcher(app.store, monitor.DefaultEgressPolicy(), cfg.DataDir, logger), logger)
|
||||
app.notify_relay = notify.NewRelay(app.store, notify.NewDispatcher(app.store, app.serverId, app.siteName, monitor.DefaultEgressPolicy(), cfg.DataDir, logger), logger)
|
||||
app.upload_store = &storage.FileStore{BaseDir: app.uploads_base_dir}
|
||||
app.repo_locks = repolock.NewManager()
|
||||
|
||||
@@ -1239,6 +1239,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
|
||||
router.Handle("GET", "/api/boards", txRead(api.ListAllBoards))
|
||||
router.Handle("GET", "/api/boards/:boardId", txRead(api.GetBoard))
|
||||
router.Handle("PATCH", "/api/boards/:boardId", txWrite(api.UpdateBoard))
|
||||
router.Handle("POST", "/api/boards/:boardId/transfer", txWrite(api.TransferBoard))
|
||||
router.Handle("DELETE", "/api/boards/:boardId", txWrite(api.DeleteBoard))
|
||||
router.Handle("GET", "/api/boards/:boardId/blocks", txRead(api.ListBlocks))
|
||||
router.Handle("POST", "/api/boards/:boardId/blocks", txWrite(api.CreateBlock))
|
||||
@@ -1284,6 +1285,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
|
||||
router.Handle("GET", "/api/repos", txRead(api.ListAllRepos))
|
||||
router.Handle("GET", "/api/repos/:id", txRead(api.GetRepo))
|
||||
router.Handle("PATCH", "/api/repos/:id", txWrite(api.UpdateRepo))
|
||||
router.Handle("POST", "/api/repos/:id/transfer", txWrite(api.TransferRepo))
|
||||
router.Handle("DELETE", "/api/repos/:id", api.DeleteRepo)
|
||||
router.Handle("GET", "/api/repos/:id/branches", api.RepoBranches)
|
||||
router.Handle("GET", "/api/repos/:id/branches/info", api.RepoBranchesInfo)
|
||||
|
||||
@@ -132,6 +132,12 @@ export interface Repo {
|
||||
owner_slug?: string
|
||||
}
|
||||
|
||||
export interface TransferRepoResult extends Repo {
|
||||
// true when a project-scoped signing key was dropped because it is not
|
||||
// selectable in the destination project.
|
||||
signing_key_cleared?: boolean
|
||||
}
|
||||
|
||||
export interface RepoStats {
|
||||
repo_id: string
|
||||
branches: number
|
||||
@@ -1563,6 +1569,12 @@ export interface Board {
|
||||
delete_at?: number
|
||||
}
|
||||
|
||||
export interface TransferBoardResult extends Board {
|
||||
// usernames of board members pruned because they are not members of the
|
||||
// destination project.
|
||||
pruned_members?: string[]
|
||||
}
|
||||
|
||||
export type BoardCreatePayload = {
|
||||
title: string
|
||||
description?: string
|
||||
@@ -2477,6 +2489,8 @@ export const api = {
|
||||
})
|
||||
},
|
||||
deleteRepo: (repoId: string) => request<void>(`/api/repos/${repoId}`, { method: 'DELETE' }),
|
||||
transferRepo: (repoId: string, targetProjectId: string) =>
|
||||
request<TransferRepoResult>(`/api/repos/${repoId}/transfer`, { method: 'POST', body: JSON.stringify({ target_project_id: targetProjectId }) }),
|
||||
listSelectableSigningKeys: (projectId: string) => request<GPGKey[]>(`/api/projects/${projectId}/selectable-signing-keys`),
|
||||
listAvailableRepos: (projectId: string, query?: string, limit?: number, offset?: number) => {
|
||||
const params = new URLSearchParams()
|
||||
@@ -2742,6 +2756,8 @@ export const api = {
|
||||
updateBoard: (boardId: string, payload: BoardUpdatePayload) =>
|
||||
request<Board>(`/api/boards/${boardId}`, { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||
deleteBoard: (boardId: string) => request<void>(`/api/boards/${boardId}`, { method: 'DELETE' }),
|
||||
transferBoard: (boardId: string, targetProjectId: string) =>
|
||||
request<TransferBoardResult>(`/api/boards/${boardId}/transfer`, { method: 'POST', body: JSON.stringify({ target_project_id: targetProjectId }) }),
|
||||
|
||||
listBlocks: (boardId: string, type?: string) => {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Autocomplete from './Autocomplete'
|
||||
import FormDialogContent from './FormDialogContent'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, Project } from '../api'
|
||||
|
||||
type TransferProjectDialogProps = {
|
||||
open: boolean
|
||||
// what is being moved — drives the copy and warnings.
|
||||
kind: 'repository' | 'board'
|
||||
itemName: string
|
||||
// owning project, excluded from the candidate list.
|
||||
currentProjectId: string
|
||||
repoType?: 'git' | 'rpm' | 'docker'
|
||||
onClose: () => void
|
||||
// performs the transfer; the dialog owns busy/error UI around it.
|
||||
onConfirm: (targetProjectId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export default function TransferProjectDialog(props: TransferProjectDialogProps) {
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [target, setTarget] = useState<Project | null>(null)
|
||||
const [busy, setBusy] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.open) return
|
||||
setTarget(null)
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
api.listProjects(500)
|
||||
.then((page) => setProjects((page.items || []).filter((p) => p.id !== props.currentProjectId)))
|
||||
.catch(() => setProjects([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [props.open, props.currentProjectId])
|
||||
|
||||
const handleConfirm = async (): Promise<void> => {
|
||||
if (!target) {
|
||||
setError('Select a destination project.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await props.onConfirm(target.id)
|
||||
props.onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Transfer failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onClose={props.onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Transfer {props.kind}</DialogTitle>
|
||||
<FormDialogContent>
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Move <strong>{props.itemName}</strong> to another project. You must be an admin of both this project and the destination.
|
||||
</Typography>
|
||||
<Autocomplete<Project, false, false, false>
|
||||
options={projects}
|
||||
loading={loading}
|
||||
value={target}
|
||||
onChange={(_event, value) => setTarget(value)}
|
||||
getOptionLabel={(option) => option.name || option.slug}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
renderInput={(params) => <TextField {...params} label="Destination project" margin="dense" />}
|
||||
/>
|
||||
<Alert severity="warning" sx={{ mt: 0.5 }}>
|
||||
<Box sx={{ display: 'grid', gap: 0.5 }}>
|
||||
{props.kind === 'repository' ? (
|
||||
<>
|
||||
<span>
|
||||
Clone / RPM / Docker URLs change to the destination project's slug — existing remotes,
|
||||
image references, and <code>.repo</code> baseurls will stop working.
|
||||
</span>
|
||||
<span>Access changes to the destination project's members.</span>
|
||||
{props.repoType === 'git' || props.repoType === 'rpm' ? (
|
||||
<span>A signing key scoped to this project will be removed.</span>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Access changes to the destination project's members.</span>
|
||||
<span>Board members who are not members of the destination project will be removed.</span>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Alert>
|
||||
</FormDialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleConfirm} variant="contained" color="warning" disabled={busy || !target}>
|
||||
{busy ? 'Transferring...' : 'Transfer'}
|
||||
</Button>
|
||||
<Button onClick={props.onClose} disabled={busy}>Cancel</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -556,7 +556,7 @@ function TextConverterWidget(props: UtilityWidgetProps) {
|
||||
defaultRatio={0.45}
|
||||
sx={{ flex: 1, minHeight: 0 }}
|
||||
left={
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: 0, gap: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: 0, gap: 0.5, mb: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Input</Typography>
|
||||
<Box sx={{ flex: 1, minHeight: 0 }}>
|
||||
<LazyCodeEditorField value={input} onChange={setInput} language="plaintext" height="100%" ariaLabel="Input" placeholder="Text to convert" />
|
||||
@@ -625,7 +625,7 @@ function JsonFormatterWidget(props: UtilityWidgetProps) {
|
||||
defaultRatio={0.45}
|
||||
sx={{ flex: 1, minHeight: 0 }}
|
||||
left={
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: 0, gap: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: 0, gap: 0.5, mb: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Input JSON</Typography>
|
||||
<Box sx={{ flex: 1, minHeight: 0 }}>
|
||||
<LazyCodeEditorField value={input} onChange={setInput} language="json" height="100%" ariaLabel="Input JSON" placeholder="Paste or type JSON" />
|
||||
@@ -708,7 +708,7 @@ function XmlFormatterWidget(props: UtilityWidgetProps) {
|
||||
defaultRatio={0.45}
|
||||
sx={{ flex: 1, minHeight: 0 }}
|
||||
left={
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: 0, gap: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: 0, gap: 0.5, mb: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Input XML</Typography>
|
||||
<Box sx={{ flex: 1, minHeight: 0 }}>
|
||||
<LazyCodeEditorField value={input} onChange={setInput} language="markup" height="100%" ariaLabel="Input XML" placeholder="Paste or type XML" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'
|
||||
import DriveFileMoveOutlinedIcon from '@mui/icons-material/DriveFileMoveOutlined'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import {
|
||||
Box,
|
||||
@@ -17,6 +18,7 @@ import PageAlert from '../components/PageAlert'
|
||||
import ProjectPageFrame from '../components/ProjectPageFrame'
|
||||
import ResizableSortableTable, { RSTColumn } from '../components/ResizableSortableTable'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import TransferProjectDialog from '../components/TransferProjectDialog'
|
||||
|
||||
export default function ProjectBoardsPage() {
|
||||
const { projectId } = useParams()
|
||||
@@ -25,6 +27,8 @@ export default function ProjectBoardsPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [canWrite, setCanWrite] = useState(false)
|
||||
const [canAdmin, setCanAdmin] = useState(false)
|
||||
const [transferBoard, setTransferBoard] = useState<Board | null>(null)
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
@@ -62,12 +66,17 @@ export default function ProjectBoardsPage() {
|
||||
.then(([me, members]) => {
|
||||
if (me.is_admin) {
|
||||
setCanWrite(true)
|
||||
setCanAdmin(true)
|
||||
return
|
||||
}
|
||||
const match = members.find((member) => member.user_id === me.id)
|
||||
setCanWrite(Boolean(match && (match.role === 'admin' || match.role === 'writer')))
|
||||
setCanAdmin(Boolean(match && match.role === 'admin'))
|
||||
})
|
||||
.catch(() => {
|
||||
setCanWrite(false)
|
||||
setCanAdmin(false)
|
||||
})
|
||||
.catch(() => setCanWrite(false))
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -173,6 +182,13 @@ export default function ProjectBoardsPage() {
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</ListRowIconAction>
|
||||
<ListRowIconAction
|
||||
title={canAdmin ? 'Transfer to another project' : 'Only project admins can transfer'}
|
||||
disabled={!canAdmin}
|
||||
onClick={() => setTransferBoard(board)}
|
||||
>
|
||||
<DriveFileMoveOutlinedIcon fontSize="small" />
|
||||
</ListRowIconAction>
|
||||
<ListRowIconAction
|
||||
title={canWrite ? 'Delete board' : 'You do not have permission to delete'}
|
||||
color="error"
|
||||
@@ -250,6 +266,19 @@ export default function ProjectBoardsPage() {
|
||||
onClose={() => { setDeleteBoard(null); setDeleteError(null); setDeleteConfirmValue('') }}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
|
||||
<TransferProjectDialog
|
||||
open={Boolean(transferBoard)}
|
||||
kind="board"
|
||||
itemName={transferBoard?.title || ''}
|
||||
currentProjectId={projectId ?? ''}
|
||||
onClose={() => setTransferBoard(null)}
|
||||
onConfirm={async (targetProjectId) => {
|
||||
if (!transferBoard) return
|
||||
await api.transferBoard(transferBoard.id, targetProjectId)
|
||||
load()
|
||||
}}
|
||||
/>
|
||||
</ProjectPageFrame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'
|
||||
import DriveFileMoveOutlinedIcon from '@mui/icons-material/DriveFileMoveOutlined'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import LinkOffIcon from '@mui/icons-material/LinkOff'
|
||||
import LinkIcon from '@mui/icons-material/Link'
|
||||
@@ -28,6 +29,7 @@ import ResizableSortableTable, { RSTColumn } from '../components/ResizableSortab
|
||||
import RepositoryFormDialog from '../components/RepositoryFormDialog'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import SelectField from '../components/SelectField'
|
||||
import TransferProjectDialog from '../components/TransferProjectDialog'
|
||||
|
||||
type RepoAccessInfo = {
|
||||
label: string
|
||||
@@ -91,6 +93,8 @@ export default function ProjectReposPage() {
|
||||
const [page, setPage] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [canWrite, setCanWrite] = useState(false)
|
||||
const [canAdmin, setCanAdmin] = useState(false)
|
||||
const [transferRepo, setTransferRepo] = useState<Repo | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
@@ -146,12 +150,17 @@ export default function ProjectReposPage() {
|
||||
.then(([me, members]) => {
|
||||
if (me.is_admin) {
|
||||
setCanWrite(true)
|
||||
setCanAdmin(true)
|
||||
return
|
||||
}
|
||||
const match = members.find((member) => member.user_id === me.id)
|
||||
setCanWrite(Boolean(match && (match.role === 'admin' || match.role === 'writer')))
|
||||
setCanAdmin(Boolean(match && match.role === 'admin'))
|
||||
})
|
||||
.catch(() => {
|
||||
setCanWrite(false)
|
||||
setCanAdmin(false)
|
||||
})
|
||||
.catch(() => setCanWrite(false))
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -561,6 +570,9 @@ export default function ProjectReposPage() {
|
||||
<ListRowIconAction title={canWrite ? 'Edit repository' : 'You do not have permission to edit'} disabled={!canWrite} onClick={() => openEdit(repo)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</ListRowIconAction>
|
||||
<ListRowIconAction title={canAdmin ? 'Transfer to another project' : 'Only project admins can transfer'} disabled={!canAdmin} onClick={() => setTransferRepo(repo)}>
|
||||
<DriveFileMoveOutlinedIcon fontSize="small" />
|
||||
</ListRowIconAction>
|
||||
<ListRowIconAction title={canWrite ? 'Delete repository' : 'You do not have permission to delete'} color="error" disabled={!canWrite} onClick={() => openDelete(repo)}>
|
||||
<DeleteOutlinedIcon fontSize="small" />
|
||||
</ListRowIconAction>
|
||||
@@ -800,6 +812,22 @@ export default function ProjectReposPage() {
|
||||
onErrorClose={() => setForeignError(null)}
|
||||
onClose={closeForeign}
|
||||
/>
|
||||
<TransferProjectDialog
|
||||
open={Boolean(transferRepo)}
|
||||
kind="repository"
|
||||
itemName={transferRepo?.name || ''}
|
||||
currentProjectId={projectId ?? ''}
|
||||
repoType={transferRepo?.type}
|
||||
onClose={() => setTransferRepo(null)}
|
||||
onConfirm={async (targetProjectId) => {
|
||||
if (!transferRepo) return
|
||||
const movedId = transferRepo.id
|
||||
await api.transferRepo(movedId, targetProjectId)
|
||||
setRepos((prev) => prev.filter((repo) => repo.id !== movedId))
|
||||
setAllRepos((prev) => prev.filter((repo) => repo.id !== movedId))
|
||||
setTotalRepos((prev) => (prev > 0 ? prev - 1 : 0))
|
||||
}}
|
||||
/>
|
||||
</ProjectPageFrame>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user