~netlandish/links

0c06023b46617a604c7d38617411e43bec51644c — Peter Sanchez 20 days ago 03aaf41
Prevent the deletion of domains if they're actively in use.
M api/graph/generated.go => api/graph/generated.go +63 -0
@@ 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))
		}

M api/graph/model/models_gen.go => api/graph/model/models_gen.go +3 -2
@@ 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 {

M api/graph/schema.graphqls => api/graph/schema.graphqls +1 -0
@@ 714,6 714,7 @@ input AddQRCodeInput {
type DeletePayload {
    success: Boolean!
    objectId: ID!
    message: String
}

type AddMemberPayload {

M api/graph/schema.resolvers.go => api/graph/schema.resolvers.go +21 -6
@@ 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

M core/routes.go => core/routes.go +3 -1
@@ 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"))

M migrations/0001_initial.up.sql => migrations/0001_initial.up.sql +2 -2
@@ 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,

M models/domains.go => models/domains.go +31 -0
@@ 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
}

M models/schema.sql => models/schema.sql +2 -2
@@ 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,

M templates/domain_list.html => templates/domain_list.html +1 -1
@@ 34,7 34,7 @@
                            {{end}}
                        </td>
                        <td>
                            <a class="button primary outline is-small" href="{{ reverse "core:domain_delete" $.slug .ID }}">{{$.pd.Data.delete}}</a>
                            {{ if .TmplCanDelete $.context }}<a class="button primary outline is-small" href="{{ reverse "core:domain_delete" $.slug .ID }}">{{$.pd.Data.delete}}</a>{{ end }}
                        </td>
                    </tr>
                    {{end}}

M values.go => values.go +2 -5
@@ 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