Add functionality to move property. closes PrivateCaptcha/issues#151

This commit is contained in:
Taras Kushnir
2025-11-11 18:16:40 +02:00
parent 71cdc7f256
commit 9b3aa0ea94
12 changed files with 326 additions and 2 deletions

View File

@@ -31,6 +31,7 @@ const (
UsageEndpoint = "usage"
ReadyEndpoint = "ready"
LiveEndpoint = "live"
MoveEndpoint = "move"
NotificationEndpoint = "notification"
SelfHostedEndpoint = "selfhosted"
ActivationEndpoint = "activation"

View File

@@ -2049,3 +2049,30 @@ func (s *BusinessStoreImpl) ExpireInternalTrials(ctx context.Context, from, to t
return nil
}
func (s *BusinessStoreImpl) MoveProperty(ctx context.Context, property *dbgen.Property, org *dbgen.GetUserOrganizationsRow) (*dbgen.Property, error) {
if s.querier == nil {
return nil, ErrMaintenance
}
if property.OrgID.Int32 == org.Organization.ID {
slog.WarnContext(ctx, "Property is already in the destination org", "propID", property.ID, "orgID", property.OrgID.Int32)
return nil, ErrInvalidInput
}
updatedProperty, err := s.querier.MoveProperty(ctx, &dbgen.MovePropertyParams{
ID: property.ID,
OrgID: Int(org.Organization.ID),
OrgOwnerID: org.Organization.UserID,
})
if err != nil {
slog.ErrorContext(ctx, "Failed to move property to another org", "propID", property.ID, "oldOrgID", property.OrgID.Int32, "newOrgID", org.Organization.ID, common.ErrAttr(err))
return nil, err
}
slog.InfoContext(ctx, "Moved property to another org", "propID", property.ID, "oldOrgID", property.OrgID.Int32, "newOrgID", org.Organization.ID)
s.cacheProperty(ctx, updatedProperty)
return updatedProperty, nil
}

View File

@@ -400,6 +400,43 @@ func (q *Queries) GetUserPropertiesCount(ctx context.Context, orgOwnerID pgtype.
return count, err
}
const moveProperty = `-- name: MoveProperty :one
UPDATE backend.properties SET org_id = $2, org_owner_id = $3, updated_at = NOW()
WHERE id = $1
RETURNING id, name, external_id, org_id, creator_id, org_owner_id, domain, level, salt, growth, created_at, updated_at, deleted_at, validity_interval, allow_subdomains, allow_localhost, max_replay_count
`
type MovePropertyParams struct {
ID int32 `db:"id" json:"id"`
OrgID pgtype.Int4 `db:"org_id" json:"org_id"`
OrgOwnerID pgtype.Int4 `db:"org_owner_id" json:"org_owner_id"`
}
func (q *Queries) MoveProperty(ctx context.Context, arg *MovePropertyParams) (*Property, error) {
row := q.db.QueryRow(ctx, moveProperty, arg.ID, arg.OrgID, arg.OrgOwnerID)
var i Property
err := row.Scan(
&i.ID,
&i.Name,
&i.ExternalID,
&i.OrgID,
&i.CreatorID,
&i.OrgOwnerID,
&i.Domain,
&i.Level,
&i.Salt,
&i.Growth,
&i.CreatedAt,
&i.UpdatedAt,
&i.DeletedAt,
&i.ValidityInterval,
&i.AllowSubdomains,
&i.AllowLocalhost,
&i.MaxReplayCount,
)
return &i, err
}
const softDeleteProperty = `-- name: SoftDeleteProperty :one
UPDATE backend.properties SET deleted_at = NOW(), updated_at = NOW(), name = name || ' deleted_' || substr(md5(random()::text), 1, 8) WHERE id = $1 RETURNING id, name, external_id, org_id, creator_id, org_owner_id, domain, level, salt, growth, created_at, updated_at, deleted_at, validity_interval, allow_subdomains, allow_localhost, max_replay_count
`

View File

@@ -65,6 +65,7 @@ type Querier interface {
GetUsersWithoutSubscription(ctx context.Context, dollar_1 []int32) ([]*User, error)
InsertLock(ctx context.Context, arg *InsertLockParams) (*Lock, error)
InviteUserToOrg(ctx context.Context, arg *InviteUserToOrgParams) (*OrganizationUser, error)
MoveProperty(ctx context.Context, arg *MovePropertyParams) (*Property, error)
Ping(ctx context.Context) (int32, error)
RemoveUserFromOrg(ctx context.Context, arg *RemoveUserFromOrgParams) error
RotateAPIKey(ctx context.Context, arg *RotateAPIKeyParams) (*APIKey, error)

View File

@@ -17,6 +17,11 @@ UPDATE backend.properties SET name = $2, level = $3, growth = $4, validity_inter
WHERE id = $1
RETURNING *;
-- name: MoveProperty :one
UPDATE backend.properties SET org_id = $2, org_owner_id = $3, updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: GetOrgPropertyByName :one
SELECT * from backend.properties WHERE org_id = $1 AND name = $2 AND deleted_at IS NULL;

View File

@@ -97,8 +97,10 @@ type propertyDashboardRenderContext struct {
type propertySettingsRenderContext struct {
propertyDashboardRenderContext
difficultyLevelsRenderContext
Orgs []*userOrg
MinLevel int
MaxLevel int
CanMove bool
}
func (pc *propertySettingsRenderContext) UpdateLevels() {
@@ -653,7 +655,13 @@ func (s *Server) getOrgProperty(w http.ResponseWriter, r *http.Request) (*proper
}
func (s *Server) getOrgPropertySettings(w http.ResponseWriter, r *http.Request) (*propertySettingsRenderContext, error) {
propertyRenderCtx, _, err := s.getOrgProperty(w, r)
ctx := r.Context()
user, err := s.SessionUser(ctx, s.Session(w, r))
if err != nil {
return nil, err
}
propertyRenderCtx, property, err := s.getOrgProperty(w, r)
if err != nil {
return nil, err
}
@@ -661,6 +669,23 @@ func (s *Server) getOrgPropertySettings(w http.ResponseWriter, r *http.Request)
renderCtx := &propertySettingsRenderContext{
propertyDashboardRenderContext: *propertyRenderCtx,
difficultyLevelsRenderContext: createDifficultyLevelsRenderContext(),
Orgs: []*userOrg{},
CanMove: false,
}
// only property owner can move it around
if user.ID == property.CreatorID.Int32 {
if orgs, err := s.Store.Impl().RetrieveUserOrganizations(ctx, user); err == nil {
renderCtx.Orgs = orgsToUserOrgs(orgs)
for _, org := range orgs {
if (org.Organization.ID != property.OrgID.Int32) && (org.Level == dbgen.AccessLevelOwner) {
slog.DebugContext(ctx, "Found at least one other user-owned org", "orgID", org.Organization.ID, "orgName", org.Organization.Name)
renderCtx.CanMove = true
break
}
}
}
}
renderCtx.Tab = propertySettingsTabIndex

View File

@@ -0,0 +1,90 @@
//go:build enterprise
package portal
import (
"log/slog"
"net/http"
"slices"
"strconv"
"strings"
"github.com/PrivateCaptcha/PrivateCaptcha/pkg/common"
dbgen "github.com/PrivateCaptcha/PrivateCaptcha/pkg/db/generated"
)
func (s *Server) moveProperty(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
err := r.ParseForm()
if err != nil {
slog.ErrorContext(ctx, "Failed to read request body", common.ErrAttr(err))
s.RedirectError(http.StatusBadRequest, w, r)
return
}
newOrgParam := strings.TrimSpace(r.FormValue(common.ParamOrg))
newOrgID, err := strconv.Atoi(newOrgParam)
if err != nil {
slog.ErrorContext(ctx, "Failed to parse new org ID", "value", newOrgParam, common.ErrAttr(err))
s.RedirectError(http.StatusBadRequest, w, r)
return
}
user, err := s.SessionUser(ctx, s.Session(w, r))
if err != nil {
s.RedirectError(http.StatusUnauthorized, w, r)
return
}
org, err := s.Org(user, r)
if err != nil {
s.RedirectError(http.StatusInternalServerError, w, r)
return
}
if org.ID == int32(newOrgID) {
// this shouldn't happen as we don't expose such option in FE
slog.ErrorContext(ctx, "Attempt to move property to the same org", "orgID", newOrgID)
w.WriteHeader(http.StatusBadRequest)
return
}
property, err := s.Property(org, r)
if err != nil {
s.RedirectError(http.StatusBadRequest, w, r)
return
}
// we can only move properties that we created
canMove := user.ID == property.CreatorID.Int32
if !canMove {
slog.ErrorContext(ctx, "Not enough permissions to move property", "userID", user.ID,
"orgUserID", org.UserID.Int32, "propertyUserID", property.CreatorID.Int32)
s.RedirectError(http.StatusUnauthorized, w, r)
return
}
orgs, err := s.Store.Impl().RetrieveUserOrganizations(ctx, user)
if err != nil {
slog.ErrorContext(ctx, "Failed to retrieve user orgs", common.ErrAttr(err))
s.RedirectError(http.StatusInternalServerError, w, r)
return
}
idx := slices.IndexFunc(orgs, func(o *dbgen.GetUserOrganizationsRow) bool {
return (o.Organization.ID == int32(newOrgID)) && (o.Level == dbgen.AccessLevelOwner)
})
if idx == -1 {
slog.ErrorContext(ctx, "Org is not found in user owned orgs", "orgID", newOrgID, "userID", user.ID)
s.RedirectError(http.StatusBadRequest, w, r)
return
}
if updatedProperty, err := s.Store.Impl().MoveProperty(ctx, property, orgs[idx]); err == nil {
propertyDashboardURL := s.PartsURL(common.OrgEndpoint, strconv.Itoa(int(updatedProperty.OrgID.Int32)), common.PropertyEndpoint, strconv.Itoa(int(updatedProperty.ID)))
common.Redirect(propertyDashboardURL, http.StatusOK, w, r)
} else {
s.RedirectError(http.StatusInternalServerError, w, r)
}
}

View File

@@ -146,3 +146,63 @@ func TestPostNewOrgProperty(t *testing.T) {
}
}
}
func TestMoveProperty(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.TODO()
user, org1, err := db_tests.CreateNewAccountForTest(ctx, store, t.Name(), testPlan)
if err != nil {
t.Fatalf("Failed to create account: %v", err)
}
// Create a new property
property, err := server.Store.Impl().CreateNewProperty(ctx, &dbgen.CreatePropertyParams{
Name: "propertyName",
OrgID: db.Int(org1.ID),
CreatorID: org1.UserID,
OrgOwnerID: org1.UserID,
Domain: "example.com",
Level: db.Int2(int16(common.DifficultyLevelMedium)),
Growth: dbgen.DifficultyGrowthMedium,
})
if err != nil {
t.Fatalf("Failed to create new property: %v", err)
}
org2, err := store.Impl().CreateNewOrganization(ctx, t.Name()+"-another-org", user.ID)
if err != nil {
t.Fatalf("Failed to create extra org: %v", err)
}
srv := http.NewServeMux()
_ = server.Setup(srv, portalDomain(), common.NoopMiddleware)
cookie, err := portal_tests.AuthenticateSuite(ctx, user.Email, srv, server.XSRF, server.Sessions.CookieName, server.Mailer.(*email.StubMailer))
if err != nil {
t.Fatal(err)
}
form := url.Values{}
form.Set(common.ParamCSRFToken, server.XSRF.Token(strconv.Itoa(int(user.ID))))
form.Set(common.ParamOrg, strconv.Itoa(int(org2.ID)))
req := httptest.NewRequest("POST", fmt.Sprintf("/org/%d/property/%d/move", org1.ID, property.ID), strings.NewReader(form.Encode()))
req.AddCookie(cookie)
req.Header.Set(common.HeaderContentType, common.ContentTypeURLEncoded)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusSeeOther {
t.Errorf("Unexpected status code %v", resp.StatusCode)
}
properties, err := store.Impl().RetrieveOrgProperties(ctx, org2)
if len(properties) != 1 || properties[0].ID != property.ID {
t.Errorf("Property was not moved")
}
}

View File

@@ -57,6 +57,8 @@ type RenderConstants struct {
IgnoreError string
Terms string
MaxReplayCount string
MoveEndpoint string
Org string
}
func NewRenderConstants() *RenderConstants {
@@ -105,6 +107,8 @@ func NewRenderConstants() *RenderConstants {
IgnoreError: common.ParamIgnoreError,
Terms: common.ParamTerms,
MaxReplayCount: common.ParamMaxReplayCount,
MoveEndpoint: common.MoveEndpoint,
Org: common.ParamOrg,
}
}

View File

@@ -36,4 +36,5 @@ func (s *Server) setupEnterprise(router *http.ServeMux, rg *RouteGenerator, priv
router.Handle(rg.Put(common.OrgEndpoint, arg(common.ParamOrg), common.MembersEndpoint), privateWrite.ThenFunc(s.joinOrg))
router.Handle(rg.Delete(common.OrgEndpoint, arg(common.ParamOrg), common.MembersEndpoint), privateWrite.ThenFunc(s.leaveOrg))
router.Handle(rg.Delete(common.OrgEndpoint, arg(common.ParamOrg), common.DeleteEndpoint), privateWrite.ThenFunc(s.deleteOrg))
router.Handle(rg.Post(common.OrgEndpoint, arg(common.ParamOrg), common.PropertyEndpoint, arg(common.ParamProperty), common.MoveEndpoint), privateWrite.ThenFunc(s.moveProperty))
}

View File

@@ -0,0 +1,30 @@
<div id="move-form" class="bg-white px-4 pb-4 pt-5 sm:p-6 sm:pb-6 sm:flex-1 sm:flex">
<div class="sm:flex-1 sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:text-left sm:flex-1">
<h3 class="text-base font-semibold leading-6 text-gray-900" id="modal-title">Move property</h3>
<div class="mt-2">
<div class="space-y-4">
<div>
<label for="{{ .Const.Days }}" class="pc-internal-form-label"> Organization </label>
<div class="mt-2">
<select name="{{ .Const.Org }}" class="pc-internal-form-select">
{{ range $org := $.Params.Orgs }}
{{ if and (eq $org.Level $.Const.OrgLevelOwner) (ne $.Params.Org.ID $org.ID) }}
<option value="{{$org.ID}}">{{ $org.Name }}</option>
{{ end }}
{{ end }}
</select>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<button type="submit" class="pc-internal-form-button pc-internal-form-button-primary sm:ml-4 sm:w-auto">
Move
</button>
<button type="button" class="pc-internal-form-button pc-internal-form-button-secondary sm:w-auto" @click="moveOpen = false">Cancel</button>
</div>

View File

@@ -34,7 +34,7 @@
</div>
</div>
<div class="divide-y divide-gray-200" x-data="{deleteOpen: false}">
<div class="divide-y divide-gray-200" x-data="{deleteOpen: false, moveOpen: false}">
<div class="relative z-10" aria-labelledby="modal-title" role="dialog" aria-modal="true"
x-show="deleteOpen"
x-transition:enter="ease-out duration-300"
@@ -89,7 +89,38 @@
</div>
</div>
</div>
{{ if $.Platform.Enterprise }}
<div class="relative z-10" aria-labelledby="modal-title" role="dialog" aria-modal="true"
x-show="moveOpen"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
>
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"
x-show="moveOpen"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<div class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:w-full sm:max-w-lg sm:flex">
<form class="sm:flex-1 sm:flex sm:flex-col"
hx-disabled-elt="input, button"
hx-post="{{ partsURL .Const.OrgEndpoint .Params.Org.ID .Const.PropertyEndpoint .Params.Property.ID .Const.MoveEndpoint }}">
{{template "settings-move-form.html" .}}
</form>
</div>
</div>
</div>
</div>
{{ end }}
<div class="grid max-w-4xl grid-cols-1 gap-x-10 gap-y-10 px-4 py-12 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base font-semibold leading-7 text-gray-900">Basic settings</h2>
@@ -107,6 +138,18 @@
{{template "settings-basic-form.html" .}}
</form>
</div>
{{ if $.Platform.Enterprise }}
<div class="grid max-w-4xl grid-cols-1 gap-x-10 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base font-semibold leading-7 text-gray-900">Move property</h2>
<p class="mt-1 text-sm leading-6 text-gray-600">Property will be moved from "{{.Params.Org.Name}}" to another organization you <i>own</i>.</p>
</div>
<div class="flex items-start md:col-span-2">
<button type="submit" {{ if not .Params.CanMove }}disabled{{ end }} class="pc-internal-form-button {{ if .Params.CanMove }}pc-internal-form-button-secondary{{ else }}pc-internal-form-button-disabled{{ end }}" @click="moveOpen = true">Move</button>
</div>
</div>
{{ end }}
<div class="grid max-w-4xl grid-cols-1 gap-x-10 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base font-semibold leading-7 text-gray-900">Delete property</h2>