From 0c06023b46617a604c7d38617411e43bec51644c Mon Sep 17 00:00:00 2001 From: Peter Sanchez Date: Fri, 6 Dec 2024 13:35:30 -0600 Subject: [PATCH] Prevent the deletion of domains if they're actively in use. --- api/graph/generated.go | 63 ++++++++++++++++++++++++++++++++++ api/graph/model/models_gen.go | 5 +-- api/graph/schema.graphqls | 1 + api/graph/schema.resolvers.go | 27 +++++++++++---- core/routes.go | 4 ++- migrations/0001_initial.up.sql | 4 +-- models/domains.go | 31 +++++++++++++++++ models/schema.sql | 4 +-- templates/domain_list.html | 2 +- values.go | 7 ++-- 10 files changed, 129 insertions(+), 19 deletions(-) diff --git a/api/graph/generated.go b/api/graph/generated.go index 93953ee..2d0d0eb 100644 --- a/api/graph/generated.go +++ b/api/graph/generated.go @@ -115,6 +115,7 @@ type ComplexityRoot struct { } DeletePayload struct { + Message func(childComplexity int) int ObjectID func(childComplexity int) int Success func(childComplexity int) int } @@ -772,6 +773,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.BillingSettings.Status(childComplexity), true + case "DeletePayload.message": + if e.complexity.DeletePayload.Message == nil { + break + } + + return e.complexity.DeletePayload.Message(childComplexity), true + case "DeletePayload.objectId": if e.complexity.DeletePayload.ObjectID == nil { break @@ -6581,6 +6589,47 @@ func (ec *executionContext) fieldContext_DeletePayload_objectId(_ context.Contex return fc, nil } +func (ec *executionContext) _DeletePayload_message(ctx context.Context, field graphql.CollectedField, obj *model.DeletePayload) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_DeletePayload_message(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Message, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2áš–string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_DeletePayload_message(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DeletePayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Domain_id(ctx context.Context, field graphql.CollectedField, obj *models.Domain) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Domain_id(ctx, field) if err != nil { @@ -10644,6 +10693,8 @@ func (ec *executionContext) fieldContext_Mutation_deleteLink(ctx context.Context return ec.fieldContext_DeletePayload_success(ctx, field) case "objectId": return ec.fieldContext_DeletePayload_objectId(ctx, field) + case "message": + return ec.fieldContext_DeletePayload_message(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type DeletePayload", field.Name) }, @@ -11666,6 +11717,8 @@ func (ec *executionContext) fieldContext_Mutation_deleteDomain(ctx context.Conte return ec.fieldContext_DeletePayload_success(ctx, field) case "objectId": return ec.fieldContext_DeletePayload_objectId(ctx, field) + case "message": + return ec.fieldContext_DeletePayload_message(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type DeletePayload", field.Name) }, @@ -11981,6 +12034,8 @@ func (ec *executionContext) fieldContext_Mutation_deleteLinkShort(ctx context.Co return ec.fieldContext_DeletePayload_success(ctx, field) case "objectId": return ec.fieldContext_DeletePayload_objectId(ctx, field) + case "message": + return ec.fieldContext_DeletePayload_message(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type DeletePayload", field.Name) }, @@ -12526,6 +12581,8 @@ func (ec *executionContext) fieldContext_Mutation_deleteListing(ctx context.Cont return ec.fieldContext_DeletePayload_success(ctx, field) case "objectId": return ec.fieldContext_DeletePayload_objectId(ctx, field) + case "message": + return ec.fieldContext_DeletePayload_message(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type DeletePayload", field.Name) }, @@ -12619,6 +12676,8 @@ func (ec *executionContext) fieldContext_Mutation_deleteListingLink(ctx context. return ec.fieldContext_DeletePayload_success(ctx, field) case "objectId": return ec.fieldContext_DeletePayload_objectId(ctx, field) + case "message": + return ec.fieldContext_DeletePayload_message(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type DeletePayload", field.Name) }, @@ -12819,6 +12878,8 @@ func (ec *executionContext) fieldContext_Mutation_deleteQRCode(ctx context.Conte return ec.fieldContext_DeletePayload_success(ctx, field) case "objectId": return ec.fieldContext_DeletePayload_objectId(ctx, field) + case "message": + return ec.fieldContext_DeletePayload_message(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type DeletePayload", field.Name) }, @@ -25736,6 +25797,8 @@ func (ec *executionContext) _DeletePayload(ctx context.Context, sel ast.Selectio if out.Values[i] == graphql.Null { out.Invalids++ } + case "message": + out.Values[i] = ec._DeletePayload_message(ctx, field, obj) default: panic("unknown field " + strconv.Quote(field.Name)) } diff --git a/api/graph/model/models_gen.go b/api/graph/model/models_gen.go index 7124f61..66c3dcb 100644 --- a/api/graph/model/models_gen.go +++ b/api/graph/model/models_gen.go @@ -110,8 +110,9 @@ type CompleteRegisterInput struct { } type DeletePayload struct { - Success bool `json:"success"` - ObjectID string `json:"objectId"` + Success bool `json:"success"` + ObjectID string `json:"objectId"` + Message *string `json:"message,omitempty"` } type DomainCursor struct { diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls index 7a85710..d11907d 100644 --- a/api/graph/schema.graphqls +++ b/api/graph/schema.graphqls @@ -714,6 +714,7 @@ input AddQRCodeInput { type DeletePayload { success: Boolean! objectId: ID! + message: String } type AddMemberPayload { diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go index 90f4274..57f73c0 100644 --- a/api/graph/schema.resolvers.go +++ b/api/graph/schema.resolvers.go @@ -1932,7 +1932,7 @@ func (r *mutationResolver) DeleteDomain(ctx context.Context, id int) (*model.Del return nil, err } if len(domains) == 0 { - return nil, fmt.Errorf(lt.Translate("invalid domain ID [%d] given", id)) + return nil, fmt.Errorf(lt.Translate("invalid domain ID")) } domain := domains[0] @@ -1956,6 +1956,12 @@ func (r *mutationResolver) DeleteDomain(ctx context.Context, id int) (*model.Del } } + if !domain.CanDelete(ctx) { + msg := lt.Translate("Unable to delete. Domain has active short links or link listings") + deletePayload.Message = &msg + return deletePayload, nil + } + err = domain.Delete(ctx) if err != nil { return nil, err @@ -3984,6 +3990,13 @@ func (r *mutationResolver) UpdateAdminDomain(ctx context.Context, input model.Up ldomain := domains[0] + if string(input.Service) != ldomain.Service && !ldomain.CanDelete(ctx) { + validator.Error(lt.Translate("Domain in use. Can not change service.")). + WithField("service"). + WithCode(valid.ErrValidationCode) + return nil, nil + } + var org *models.Organization if input.OrgSlug != nil { opts := &database.FilterOptions{ @@ -5354,7 +5367,14 @@ func (r *queryResolver) GetListing(ctx context.Context, input *model.GetListingD if tokenUser == nil { return nil, valid.ErrAuthorization } + validator := valid.New(ctx) + if input.After != nil && input.Before != nil { + validator.Error("You can not send both after and before cursors"). + WithCode(valid.ErrValidationGlobalCode) + return nil, nil + } + opts := &database.FilterOptions{ Filter: sq.And{ sq.Eq{"l.domain_id": input.DomainID}, @@ -5405,11 +5425,6 @@ func (r *queryResolver) GetListing(ctx context.Context, input *model.GetListingD }, OrderBy: "ll.link_order ASC", } - if input.After != nil && input.Before != nil { - validator.Error("You can not send both after and before cursors"). - WithCode(valid.ErrValidationGlobalCode) - return nil, nil - } numElements := 10 var hasPrevPage bool diff --git a/core/routes.go b/core/routes.go index 87cbbba..872283d 100644 --- a/core/routes.go +++ b/core/routes.go @@ -555,6 +555,7 @@ func (s *Service) DomainDelete(c echo.Context) error { DeleteDomain struct { Success bool `json:"success"` ID string `json:"objectId"` + Message string `json:"message"` } `json:"deleteDomain"` } @@ -564,6 +565,7 @@ func (s *Service) DomainDelete(c echo.Context) error { deleteDomain(id: $id) { success objectId + message } }`) op.Var("id", id) @@ -572,7 +574,7 @@ func (s *Service) DomainDelete(c echo.Context) error { return err } if !result.DeleteDomain.Success { - messages.Error(c, lt.Translate("Something went wrong. The domain could not be deleted.")) + messages.Error(c, lt.Translate("%s", result.DeleteDomain.Message)) redirect := c.Request().Header.Get("Referer") if redirect == "" { redirect = c.Echo().Reverse(s.RouteName("domain_list")) diff --git a/migrations/0001_initial.up.sql b/migrations/0001_initial.up.sql index 02b9579..0f2eaea 100644 --- a/migrations/0001_initial.up.sql +++ b/migrations/0001_initial.up.sql @@ -383,7 +383,7 @@ CREATE TABLE link_shorts ( title VARCHAR ( 150 ) DEFAULT '', url TEXT NOT NULL, short_code TEXT DEFAULT '', - domain_id INT REFERENCES domains (id) ON DELETE CASCADE NOT NULL, + domain_id INT REFERENCES domains (id) ON DELETE RESTRICT NOT NULL, org_id INT REFERENCES organizations (id) ON DELETE CASCADE NOT NULL, user_id INT REFERENCES users (id) ON DELETE SET NULL, created_on TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -415,7 +415,7 @@ CREATE TABLE listings ( slug VARCHAR ( 150 ) NOT NULL, image VARCHAR(1024) DEFAULT '', metadata JSONB DEFAULT '{}', - domain_id INT REFERENCES domains (id) ON DELETE CASCADE NOT NULL, + domain_id INT REFERENCES domains (id) ON DELETE RESTRICT NOT NULL, org_id INT REFERENCES organizations (id) ON DELETE CASCADE NOT NULL, user_id INT REFERENCES users (id) ON DELETE SET NULL, is_default BOOLEAN DEFAULT FALSE, diff --git a/models/domains.go b/models/domains.go index f9dde88..2c6a0c4 100644 --- a/models/domains.go +++ b/models/domains.go @@ -8,6 +8,7 @@ import ( "time" sq "github.com/Masterminds/squirrel" + "github.com/labstack/echo/v4" "golang.org/x/net/idna" "netlandish.com/x/gobwebs/database" "netlandish.com/x/gobwebs/timezone" @@ -266,3 +267,33 @@ func (d *Domain) IsStatusApproved() bool { func (d *Domain) IsStatusError() bool { return d.Status == DomainStatusError } + +// Hack for templates to avoid more hacks +func (d *Domain) TmplCanDelete(c echo.Context) bool { + return d.CanDelete(c.Request().Context()) +} + +func (d *Domain) CanDelete(ctx context.Context) bool { + if d.ID == 0 { + return false + } + + if d.IsServiceLink() { + return true + } + + canDelete := true // Default to true so default return value is false + query := `SELECT EXISTS( + SELECT 1 + FROM domains d + LEFT JOIN listings l ON l.domain_id = d.id + LEFT JOIN link_shorts s ON s.domain_id = d.id + WHERE l.domain_id=$1 OR s.domain_id=$1);` + + // Ignore any error here and just return false + database.WithTx(ctx, database.TxOptionsRO, func(tx *sql.Tx) error { + row := tx.QueryRowContext(ctx, query, d.ID) + return row.Scan(&canDelete) + }) + return !canDelete +} diff --git a/models/schema.sql b/models/schema.sql index 730a514..1080fd1 100644 --- a/models/schema.sql +++ b/models/schema.sql @@ -338,7 +338,7 @@ CREATE TABLE link_shorts ( title VARCHAR ( 150 ) DEFAULT '', url TEXT NOT NULL, short_code TEXT DEFAULT '', - domain_id INT REFERENCES domains (id) ON DELETE CASCADE NOT NULL, + domain_id INT REFERENCES domains (id) ON DELETE RESTRICT NOT NULL, org_id INT REFERENCES organizations (id) ON DELETE CASCADE NOT NULL, user_id INT REFERENCES users (id) ON DELETE SET NULL, created_on TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -370,7 +370,7 @@ CREATE TABLE listings ( slug VARCHAR ( 150 ) NOT NULL, image VARCHAR(1024) DEFAULT '', metadata JSONB DEFAULT '{}', - domain_id INT REFERENCES domains (id) ON DELETE CASCADE NOT NULL, + domain_id INT REFERENCES domains (id) ON DELETE RESTRICT NOT NULL, org_id INT REFERENCES organizations (id) ON DELETE CASCADE NOT NULL, user_id INT REFERENCES users (id) ON DELETE SET NULL, is_default BOOLEAN DEFAULT FALSE, diff --git a/templates/domain_list.html b/templates/domain_list.html index 0a59edb..287b06e 100644 --- a/templates/domain_list.html +++ b/templates/domain_list.html @@ -34,7 +34,7 @@ {{end}} - {{$.pd.Data.delete}} + {{ if .TmplCanDelete $.context }}{{$.pd.Data.delete}}{{ end }} {{end}} diff --git a/values.go b/values.go index 93b986b..5df976c 100644 --- a/values.go +++ b/values.go @@ -84,6 +84,8 @@ var InvalidSlugs = []string{ "api", "faq", "status", + "help", + "getting-started", } // Interval used to display data in analytics engagement chart @@ -110,11 +112,6 @@ const ( // comments. Also for our case, we want to ensure at least one dot ('.') is // included in the given domain name. func IsDomainName(s string) bool { - // The root domain name is valid. See golang.org/issue/45715. - if s == "." { - return true - } - // See RFC 1035, RFC 3696. // Presentation format has dots before every label except the first, and the // terminal empty label is optional here because we assume fully-qualified -- 2.45.2