~netlandish/links

efcb2dab443080e8bfc72427b5543f26cdb78922 — Peter Sanchez 5 months ago 3e2196b
Using hash ID's for viewing public links to avoid scraping.

References: https://todo.code.netlandish.com/~netlandish/links/74
M api/api_test.go => api/api_test.go +11 -3
@@ 625,13 625,21 @@ func TestAPI(t *testing.T) {
		c.NoError(err)
		org := orgs[0]

		// We know this has already been saved earlier. We need the hash to fetch it so
		// let's query the db
		ols, err := models.GetOrgLinks(dbCtx,
			&database.FilterOptions{Filter: sq.Eq{"ol.id": 3}, Limit: 1},
		)
		c.NoError(err)
		ol := ols[0]

		type GraphQLResponse struct {
			Link models.OrgLink `json:"getOrgLink"`
		}
		var result GraphQLResponse
		op := gqlclient.NewOperation(
			`query getOrgLink($id: Int!) {
				getOrgLink(id: $id) {
			`query getOrgLink($hash: String!) {
				getOrgLink(hash: $hash) {
					id
					title
					url


@@ 651,7 659,7 @@ func TestAPI(t *testing.T) {
					}
				}
			}`)
		op.Var("id", 3)
		op.Var("hash", ol.Hash)
		err = links.Execute(ctx, op, &result)
		c.NoError(err)
		c.Equal("New title", result.Link.Title)

M api/graph/generated.go => api/graph/generated.go +75 -78
@@ 259,8 259,8 @@ type ComplexityRoot struct {
		BaseURLID   func(childComplexity int) int
		CreatedOn   func(childComplexity int) int
		Description func(childComplexity int) int
		Hash        func(childComplexity int) int
		ID          func(childComplexity int) int
		NoteHash    func(childComplexity int) int
		OrgID       func(childComplexity int) int
		OrgSlug     func(childComplexity int) int
		Starred     func(childComplexity int) int


@@ 368,7 368,7 @@ type ComplexityRoot struct {
		GetListing            func(childComplexity int, input *model.GetListingDetailInput) int
		GetListingLink        func(childComplexity int, slug string, id int, domainID int) int
		GetListings           func(childComplexity int, input *model.GetListingInput) int
		GetOrgLink            func(childComplexity int, id *int, hash *string) int
		GetOrgLink            func(childComplexity int, hash string) int
		GetOrgLinks           func(childComplexity int, input *model.GetLinkInput) int
		GetOrgMembers         func(childComplexity int, orgSlug string) int
		GetOrganization       func(childComplexity int, id int) int


@@ 477,7 477,7 @@ type QueryResolver interface {
	GetOrganization(ctx context.Context, id int) (*models.Organization, error)
	GetPaymentHistory(ctx context.Context, input *model.GetPaymentInput) (*model.PaymentCursor, error)
	GetPopularLinks(ctx context.Context, input *model.PopularLinksInput) ([]*models.BaseURL, error)
	GetOrgLink(ctx context.Context, id *int, hash *string) (*models.OrgLink, error)
	GetOrgLink(ctx context.Context, hash string) (*models.OrgLink, error)
	GetOrgLinks(ctx context.Context, input *model.GetLinkInput) (*model.OrgLinkCursor, error)
	GetOrgMembers(ctx context.Context, orgSlug string) ([]*models.User, error)
	GetDomains(ctx context.Context, orgSlug *string, service *int) ([]*models.Domain, error)


@@ 1625,19 1625,19 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in

		return e.complexity.OrgLink.Description(childComplexity), true

	case "OrgLink.id":
		if e.complexity.OrgLink.ID == nil {
	case "OrgLink.hash":
		if e.complexity.OrgLink.Hash == nil {
			break
		}

		return e.complexity.OrgLink.ID(childComplexity), true
		return e.complexity.OrgLink.Hash(childComplexity), true

	case "OrgLink.noteHash":
		if e.complexity.OrgLink.NoteHash == nil {
	case "OrgLink.id":
		if e.complexity.OrgLink.ID == nil {
			break
		}

		return e.complexity.OrgLink.NoteHash(childComplexity), true
		return e.complexity.OrgLink.ID(childComplexity), true

	case "OrgLink.orgId":
		if e.complexity.OrgLink.OrgID == nil {


@@ 2232,7 2232,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
			return 0, false
		}

		return e.complexity.Query.GetOrgLink(childComplexity, args["id"].(*int), args["hash"].(*string)), true
		return e.complexity.Query.GetOrgLink(childComplexity, args["hash"].(string)), true

	case "Query.getOrgLinks":
		if e.complexity.Query.GetOrgLinks == nil {


@@ 3405,24 3405,15 @@ func (ec *executionContext) field_Query_getListings_args(ctx context.Context, ra
func (ec *executionContext) field_Query_getOrgLink_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
	var err error
	args := map[string]interface{}{}
	var arg0 *int
	if tmp, ok := rawArgs["id"]; ok {
		ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id"))
		arg0, err = ec.unmarshalOInt2ᚖint(ctx, tmp)
		if err != nil {
			return nil, err
		}
	}
	args["id"] = arg0
	var arg1 *string
	var arg0 string
	if tmp, ok := rawArgs["hash"]; ok {
		ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("hash"))
		arg1, err = ec.unmarshalOString2ᚖstring(ctx, tmp)
		arg0, err = ec.unmarshalNString2string(ctx, tmp)
		if err != nil {
			return nil, err
		}
	}
	args["hash"] = arg1
	args["hash"] = arg0
	return args, nil
}



@@ 8899,6 8890,8 @@ func (ec *executionContext) fieldContext_Mutation_addLink(ctx context.Context, f
				return ec.fieldContext_OrgLink_description(ctx, field)
			case "url":
				return ec.fieldContext_OrgLink_url(ctx, field)
			case "hash":
				return ec.fieldContext_OrgLink_hash(ctx, field)
			case "baseUrlId":
				return ec.fieldContext_OrgLink_baseUrlId(ctx, field)
			case "orgId":


@@ 8915,8 8908,6 @@ func (ec *executionContext) fieldContext_Mutation_addLink(ctx context.Context, f
				return ec.fieldContext_OrgLink_archiveUrl(ctx, field)
			case "type":
				return ec.fieldContext_OrgLink_type(ctx, field)
			case "noteHash":
				return ec.fieldContext_OrgLink_noteHash(ctx, field)
			case "tags":
				return ec.fieldContext_OrgLink_tags(ctx, field)
			case "author":


@@ 9022,6 9013,8 @@ func (ec *executionContext) fieldContext_Mutation_updateLink(ctx context.Context
				return ec.fieldContext_OrgLink_description(ctx, field)
			case "url":
				return ec.fieldContext_OrgLink_url(ctx, field)
			case "hash":
				return ec.fieldContext_OrgLink_hash(ctx, field)
			case "baseUrlId":
				return ec.fieldContext_OrgLink_baseUrlId(ctx, field)
			case "orgId":


@@ 9038,8 9031,6 @@ func (ec *executionContext) fieldContext_Mutation_updateLink(ctx context.Context
				return ec.fieldContext_OrgLink_archiveUrl(ctx, field)
			case "type":
				return ec.fieldContext_OrgLink_type(ctx, field)
			case "noteHash":
				return ec.fieldContext_OrgLink_noteHash(ctx, field)
			case "tags":
				return ec.fieldContext_OrgLink_tags(ctx, field)
			case "author":


@@ 9234,6 9225,8 @@ func (ec *executionContext) fieldContext_Mutation_addNote(ctx context.Context, f
				return ec.fieldContext_OrgLink_description(ctx, field)
			case "url":
				return ec.fieldContext_OrgLink_url(ctx, field)
			case "hash":
				return ec.fieldContext_OrgLink_hash(ctx, field)
			case "baseUrlId":
				return ec.fieldContext_OrgLink_baseUrlId(ctx, field)
			case "orgId":


@@ 9250,8 9243,6 @@ func (ec *executionContext) fieldContext_Mutation_addNote(ctx context.Context, f
				return ec.fieldContext_OrgLink_archiveUrl(ctx, field)
			case "type":
				return ec.fieldContext_OrgLink_type(ctx, field)
			case "noteHash":
				return ec.fieldContext_OrgLink_noteHash(ctx, field)
			case "tags":
				return ec.fieldContext_OrgLink_tags(ctx, field)
			case "author":


@@ 12114,6 12105,50 @@ func (ec *executionContext) fieldContext_OrgLink_url(ctx context.Context, field 
	return fc, nil
}

func (ec *executionContext) _OrgLink_hash(ctx context.Context, field graphql.CollectedField, obj *models.OrgLink) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_OrgLink_hash(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.Hash, nil
	})
	if err != nil {
		ec.Error(ctx, err)
		return graphql.Null
	}
	if resTmp == nil {
		if !graphql.HasFieldError(ctx, fc) {
			ec.Errorf(ctx, "must not be null")
		}
		return graphql.Null
	}
	res := resTmp.(string)
	fc.Result = res
	return ec.marshalNString2string(ctx, field.Selections, res)
}

func (ec *executionContext) fieldContext_OrgLink_hash(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
	fc = &graphql.FieldContext{
		Object:     "OrgLink",
		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) _OrgLink_baseUrlId(ctx context.Context, field graphql.CollectedField, obj *models.OrgLink) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_OrgLink_baseUrlId(ctx, field)
	if err != nil {


@@ 12609,47 12644,6 @@ func (ec *executionContext) fieldContext_OrgLink_type(ctx context.Context, field
	return fc, nil
}

func (ec *executionContext) _OrgLink_noteHash(ctx context.Context, field graphql.CollectedField, obj *models.OrgLink) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_OrgLink_noteHash(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.NoteHash, 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.marshalOString2string(ctx, field.Selections, res)
}

func (ec *executionContext) fieldContext_OrgLink_noteHash(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
	fc = &graphql.FieldContext{
		Object:     "OrgLink",
		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) _OrgLink_tags(ctx context.Context, field graphql.CollectedField, obj *models.OrgLink) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_OrgLink_tags(ctx, field)
	if err != nil {


@@ 13003,6 12997,8 @@ func (ec *executionContext) fieldContext_OrgLinkCursor_result(ctx context.Contex
				return ec.fieldContext_OrgLink_description(ctx, field)
			case "url":
				return ec.fieldContext_OrgLink_url(ctx, field)
			case "hash":
				return ec.fieldContext_OrgLink_hash(ctx, field)
			case "baseUrlId":
				return ec.fieldContext_OrgLink_baseUrlId(ctx, field)
			case "orgId":


@@ 13019,8 13015,6 @@ func (ec *executionContext) fieldContext_OrgLinkCursor_result(ctx context.Contex
				return ec.fieldContext_OrgLink_archiveUrl(ctx, field)
			case "type":
				return ec.fieldContext_OrgLink_type(ctx, field)
			case "noteHash":
				return ec.fieldContext_OrgLink_noteHash(ctx, field)
			case "tags":
				return ec.fieldContext_OrgLink_tags(ctx, field)
			case "author":


@@ 16286,7 16280,7 @@ func (ec *executionContext) _Query_getOrgLink(ctx context.Context, field graphql
	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
		directive0 := func(rctx context.Context) (interface{}, error) {
			ctx = rctx // use context from middleware stack in children
			return ec.resolvers.Query().GetOrgLink(rctx, fc.Args["id"].(*int), fc.Args["hash"].(*string))
			return ec.resolvers.Query().GetOrgLink(rctx, fc.Args["hash"].(string))
		}
		directive1 := func(ctx context.Context) (interface{}, error) {
			scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "LINKS")


@@ 16343,6 16337,8 @@ func (ec *executionContext) fieldContext_Query_getOrgLink(ctx context.Context, f
				return ec.fieldContext_OrgLink_description(ctx, field)
			case "url":
				return ec.fieldContext_OrgLink_url(ctx, field)
			case "hash":
				return ec.fieldContext_OrgLink_hash(ctx, field)
			case "baseUrlId":
				return ec.fieldContext_OrgLink_baseUrlId(ctx, field)
			case "orgId":


@@ 16359,8 16355,6 @@ func (ec *executionContext) fieldContext_Query_getOrgLink(ctx context.Context, f
				return ec.fieldContext_OrgLink_archiveUrl(ctx, field)
			case "type":
				return ec.fieldContext_OrgLink_type(ctx, field)
			case "noteHash":
				return ec.fieldContext_OrgLink_noteHash(ctx, field)
			case "tags":
				return ec.fieldContext_OrgLink_tags(ctx, field)
			case "author":


@@ 24797,6 24791,13 @@ func (ec *executionContext) _OrgLink(ctx context.Context, sel ast.SelectionSet, 
			if out.Values[i] == graphql.Null {
				atomic.AddUint32(&invalids, 1)
			}
		case "hash":

			out.Values[i] = ec._OrgLink_hash(ctx, field, obj)

			if out.Values[i] == graphql.Null {
				atomic.AddUint32(&invalids, 1)
			}
		case "baseUrlId":
			field := field



@@ 24863,10 24864,6 @@ func (ec *executionContext) _OrgLink(ctx context.Context, sel ast.SelectionSet, 
			if out.Values[i] == graphql.Null {
				atomic.AddUint32(&invalids, 1)
			}
		case "noteHash":

			out.Values[i] = ec._OrgLink_noteHash(ctx, field, obj)

		case "tags":

			out.Values[i] = ec._OrgLink_tags(ctx, field, obj)

M api/graph/schema.graphqls => api/graph/schema.graphqls +2 -2
@@ 163,6 163,7 @@ type OrgLink {
    title: String!
    description: String!
    url: String!
    hash: String!
    baseUrlId: NullInt! @access(scope: LINKS, kind: RO)
    orgId: Int! @access(scope: LINKS, kind: RO)
    userId: Int @access(scope: LINKS, kind: RO)


@@ 171,7 172,6 @@ type OrgLink {
    starred: Boolean! @access(scope: LINKS, kind: RO)
    archiveUrl: String!
    type: Int!
    noteHash: String
    tags: [Tag]!
    author: String!
    orgSlug: String! @access(scope: LINKS, kind: RO)


@@ 688,7 688,7 @@ type Query {
    getPopularLinks(input: PopularLinksInput): [BaseURL]! @access(scope: LINKS, kind: RO)

    "Returns a specific organization link"
    getOrgLink(id: Int, hash: String): OrgLink @access(scope: LINKS, kind: RO)
    getOrgLink(hash: String!): OrgLink @access(scope: LINKS, kind: RO)

    "Returns an array of organization links"
    getOrgLinks(input: GetLinkInput): OrgLinkCursor! @access(scope: LINKS, kind: RO)

M api/graph/schema.resolvers.go => api/graph/schema.resolvers.go +19 -41
@@ 269,6 269,7 @@ func (r *mutationResolver) AddLink(ctx context.Context, input *model.LinkInput) 
		URL:        input.URL,
		UserID:     int(user.ID),
		Type:       models.OrgLinkType,
		Hash:       ksuid.New().String(),
	}
	if input.Description != nil {
		OrgLink.Description = *input.Description


@@ 595,7 596,8 @@ func (r *mutationResolver) AddNote(ctx context.Context, input *model.NoteInput) 
		return nil, valid.ErrAuthorization
	}
	user := tokenUser.User.(*models.User)
	lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user)
	c := server.EchoForContext(ctx)
	lang := links.GetLangFromRequest(c.Request(), user)
	lt := localizer.GetLocalizer(lang)

	validator := valid.New(ctx)


@@ 650,7 652,6 @@ func (r *mutationResolver) AddNote(ctx context.Context, input *model.NoteInput) 
			WithCode(valid.ErrNotFoundCode)
		return nil, nil
	}

	org := orgs[0]

	// If the owner if not creating the note, add extra validation


@@ 672,7 673,6 @@ func (r *mutationResolver) AddNote(ctx context.Context, input *model.NoteInput) 
	}

	// Create the note url
	c := server.EchoForContext(ctx)
	noteURL, noteHash := links.CreateNoteURL(c)

	// If the note is public we create a based link


@@ 701,7 701,7 @@ func (r *mutationResolver) AddNote(ctx context.Context, input *model.NoteInput) 
		URL:         noteURL,
		UserID:      int(user.ID),
		Type:        models.NoteType,
		NoteHash:    noteHash,
		Hash:        noteHash,
	}

	err = OrgLinkNote.Store(ctx)


@@ 4533,7 4533,7 @@ func (r *queryResolver) GetPopularLinks(ctx context.Context, input *model.Popula
}

// GetOrgLink is the resolver for the getOrgLink field.
func (r *queryResolver) GetOrgLink(ctx context.Context, id *int, hash *string) (*models.OrgLink, error) {
func (r *queryResolver) GetOrgLink(ctx context.Context, hash string) (*models.OrgLink, error) {
	tokenUser := oauth2.ForContext(ctx)
	if tokenUser == nil {
		return nil, valid.ErrAuthorization


@@ 4544,49 4544,27 @@ func (r *queryResolver) GetOrgLink(ctx context.Context, id *int, hash *string) (

	ctx = timezone.Context(ctx, links.GetUserTZ(user))

	if id == nil && hash == nil {
		validator := valid.New(ctx)
		validator.Error(lt.Translate("No identifier id or hash was provided")).
			WithCode(valid.ErrValidationGlobalCode)
		return nil, nil
	}

	opts := &database.FilterOptions{
		Filter: sq.Or{
			sq.Eq{"o.owner_id": user.ID}, // If the user is the org owner
			// if the user is a member of the org is able to see private and public links
			sq.And{
				sq.Eq{"ou.user_id": user.ID},
				sq.Eq{"ou.is_active": true},
				sq.Eq{"ol.visibility": []int{
					models.OrgLinkVisibilityPublic,
					models.OrgLinkVisibilityPrivate},
		Filter: sq.And{
			sq.Eq{"ol.hash": hash},
			sq.Or{
				sq.Eq{"o.owner_id": user.ID}, // If the user is the org owner
				// if the user is a member of the org is able to see private and public links
				sq.And{
					sq.Eq{"ou.user_id": user.ID},
					sq.Eq{"ou.is_active": true},
					sq.Eq{"ol.visibility": []int{
						models.OrgLinkVisibilityPublic,
						models.OrgLinkVisibilityPrivate},
					},
				},
				// Otherwise the user is only able to see public links
				sq.Eq{"ol.visibility": models.OrgLinkVisibilityPublic},
			},
			// Otherwise the user is only able to see public links
			sq.Eq{"ol.visibility": models.OrgLinkVisibilityPublic},
		},
		Limit: 1,
	}

	if id != nil {
		opts.Filter = sq.And{
			opts.Filter,
			sq.And{
				sq.Eq{"ol.id": *id},
				sq.Eq{"ol.type": models.OrgLinkType},
			},
		}
	} else {
		opts.Filter = sq.And{
			opts.Filter,
			sq.And{
				sq.Eq{"ol.note_hash": *hash},
				sq.Eq{"ol.type": models.NoteType},
			},
		}
	}

	orgLinks, err := models.GetOrgLinks(ctx, opts)
	if err != nil {
		return nil, err

M core/import.go => core/import.go +12 -12
@@ 36,7 36,7 @@ const (
// import helper funcs
type importObj interface {
	GetURL() string
	GetNoteHash() string
	GetHash() string
	GetTitle() string
	GetDescription() string
	IsPublic() bool


@@ 44,7 44,7 @@ type importObj interface {
	IsNote() bool
	SetURL(string)
	SetIsNote(bool)
	SetNoteHash(string)
	SetHash(string)
}

// Especific pinboard object representation


@@ 60,8 60,8 @@ type pinBoardObj struct {
	Tags        string

	// We are not parsing this data from the json
	noteHash string
	isNote   bool
	hash   string
	isNote bool
}

func (p pinBoardObj) GetURL() string {


@@ 92,12 92,12 @@ func (p *pinBoardObj) SetURL(url string) {
	p.Href = url
}

func (p *pinBoardObj) SetNoteHash(hash string) {
	p.noteHash = hash
func (p *pinBoardObj) SetHash(hash string) {
	p.hash = hash
}

func (p pinBoardObj) GetNoteHash() string {
	return p.noteHash
func (p pinBoardObj) GetHash() string {
	return p.hash
}

func (p pinBoardObj) IsUnread() bool {


@@ 140,12 140,12 @@ func (h *htmlObj) SetURL(_ string) {
	// method importer
}

func (h *htmlObj) SetNoteHash(_ string) {
func (h *htmlObj) SetHash(_ string) {
	// NOTE we don't need to implement this
	// method importer
}

func (h htmlObj) GetNoteHash() string {
func (h htmlObj) GetHash() string {
	return ""
}



@@ 188,7 188,7 @@ func processBaseURLs(obj importObj, tmpURLMap map[string]bool, urlList []string,
		noteURL, noteHash := links.CreateNoteURL(c)
		obj.SetURL(noteURL)
		obj.SetIsNote(true)
		obj.SetNoteHash(noteHash)
		obj.SetHash(noteHash)
	}
	// Get a list of base url to make a query
	// of existing ones


@@ 277,7 277,7 @@ func processOrgLinks(obj importObj, baseURLMap map[string]int,
		UserID:      int(user.ID),
		Type:        linkType,
		Visibility:  vis,
		NoteHash:    obj.GetNoteHash(),
		Hash:        obj.GetHash(),
		Unread:      obj.IsUnread(),
	}
}

M core/routes.go => core/routes.go +32 -31
@@ 65,8 65,8 @@ func (s *Service) RegisterRoutes() {
	s.eg.GET("/q/:hash", s.QRRedirect).Name = s.RouteName("qr_redirect")
	s.eg.GET("/tour", s.FeatureTour).Name = s.RouteName("feature_tour")
	s.eg.GET("/note/:hash", s.NoteDetail).Name = s.RouteName("note_detail")
	s.eg.GET("/link/:id", s.OrgLinkDetail).Name = s.RouteName("link_detail")
	s.eg.GET("/click/:id", s.OrgLinkRedirect).Name = s.RouteName("link_redirect")
	s.eg.GET("/link/:hash", s.OrgLinkDetail).Name = s.RouteName("link_detail")
	s.eg.GET("/click/:hash", s.OrgLinkRedirect).Name = s.RouteName("link_redirect")
	s.eg.GET("/tag-autocomplete", s.TagAutocomplete).Name = s.RouteName("tag_autocomplete")

	s.eg.Use(auth.AuthRequired())


@@ 1298,7 1298,7 @@ func (s *Service) OrgLinksCreate(c echo.Context) error {
								archive: $archive,
								orgSlug: $slug,
								tags: $tags}) {
						id
						hash
					}
				}`)
		op.Var("title", form.Title)


@@ 1329,7 1329,7 @@ func (s *Service) OrgLinksCreate(c echo.Context) error {
		if form.Redirect != "" {
			redirect = form.Redirect
		} else {
			redirect = c.Echo().Reverse(s.RouteName("link_detail"), result.Link.ID)
			redirect = c.Echo().Reverse(s.RouteName("link_detail"), result.Link.Hash)
		}
		return c.Redirect(http.StatusMovedPermanently, redirect)
	}


@@ 1382,16 1382,16 @@ func (s *Service) OrgLinksCreate(c echo.Context) error {
		}
	} else {
		// OrgLink
		id, err = strconv.Atoi(c.QueryParam("linkid"))
		if err == nil {
		hash := c.QueryParam("linkid")
		if hash != "" {
			type GraphQLResponse struct {
				Link models.OrgLink `json:"getOrgLink"`
			}
			var result GraphQLResponse

			op := gqlclient.NewOperation(
				`query getOrgLink($id: Int) {
				getOrgLink(id: $id) {
				`query getOrgLink($hash: String!) {
				getOrgLink(hash: $hash) {
					title
					description
					url


@@ 1402,7 1402,7 @@ func (s *Service) OrgLinksCreate(c echo.Context) error {
					}
				}
			}`)
			op.Var("id", id)
			op.Var("hash", hash)
			err = links.Execute(c.Request().Context(), op, &result)
			if err != nil {
				if graphError, ok := err.(*gqlclient.Error); ok {


@@ 1674,7 1674,7 @@ func (s *Service) UserFeed(c echo.Context) error {
				desc = l.BaseURLData.Meta.Description
			}
			item := links.Item{
				GUID:        fmt.Sprintf("%s%s", domain, c.Echo().Reverse(s.RouteName("link_detail"), l.ID)),
				GUID:        fmt.Sprintf("%s%s", domain, c.Echo().Reverse(s.RouteName("link_detail"), l.Hash)),
				Title:       l.Title,
				Description: desc,
				Link:        l.URL,


@@ 1764,7 1764,7 @@ func (s *Service) OrgLinksList(c echo.Context) error {
						starred
						archiveUrl
						type
						noteHash
						hash
						tags {
							id
							name


@@ 1997,7 1997,7 @@ func (s *Service) OrgLinksList(c echo.Context) error {
				desc = l.BaseURLData.Meta.Description
			}
			item := links.Item{
				GUID:        fmt.Sprintf("%s%s", domain, c.Echo().Reverse(s.RouteName("link_detail"), l.ID)),
				GUID:        fmt.Sprintf("%s%s", domain, c.Echo().Reverse(s.RouteName("link_detail"), l.Hash)),
				Title:       l.Title,
				Description: desc,
				Link:        l.URL,


@@ 2072,8 2072,8 @@ func (s *Service) OrgLinksList(c echo.Context) error {
}

func (s *Service) OrgLinkRedirect(c echo.Context) error {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
	hash := c.Param("hash")
	if hash == "" {
		return echo.NotFoundHandler(c)
	}
	type GraphQLResponse struct {


@@ 2082,14 2082,14 @@ func (s *Service) OrgLinkRedirect(c echo.Context) error {

	var result GraphQLResponse
	op := gqlclient.NewOperation(
		`query getOrgLink($id: Int) {
				getOrgLink(id: $id) {
					id
		`query getOrgLink($hash: String!) {
				getOrgLink(hash: $hash) {
					hash
					url
				}
			}`)
	op.Var("id", id)
	err = links.Execute(c.Request().Context(), op, &result)
	op.Var("hash", hash)
	err := links.Execute(c.Request().Context(), op, &result)
	if err != nil {
		if graphError, ok := err.(*gqlclient.Error); ok {
			err = links.ParseInputErrors(c, graphError, gobwebs.Map{})


@@ 2101,8 2101,8 @@ func (s *Service) OrgLinkRedirect(c echo.Context) error {

// OrgLinkDetail ...
func (s *Service) OrgLinkDetail(c echo.Context) error {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
	hash := c.Param("hash")
	if hash == "" {
		return echo.NotFoundHandler(c)
	}
	type GraphQLResponse struct {


@@ 2111,14 2111,15 @@ func (s *Service) OrgLinkDetail(c echo.Context) error {

	var result GraphQLResponse
	op := gqlclient.NewOperation(
		`query getOrgLink($id: Int) {
				getOrgLink(id: $id) {
		`query getOrgLink($hash: String!) {
				getOrgLink(hash: $hash) {
					id
					title
					description
					url
					userId
					type
					hash
					createdOn
					tags {
						id


@@ 2127,8 2128,8 @@ func (s *Service) OrgLinkDetail(c echo.Context) error {
					}
				}
			}`)
	op.Var("id", id)
	err = links.Execute(c.Request().Context(), op, &result)
	op.Var("hash", hash)
	err := links.Execute(c.Request().Context(), op, &result)
	if err != nil {
		if graphError, ok := err.(*gqlclient.Error); ok {
			err = links.ParseInputErrors(c, graphError, gobwebs.Map{})


@@ 2367,7 2368,7 @@ func (s *Service) OrgLinkUpdate(c echo.Context) error {
		}
		messages.Success(c, lt.Translate("Link successfully updated."))
		return c.Redirect(http.StatusMovedPermanently,
			c.Echo().Reverse(s.RouteName("link_detail"), result.Link.ID))
			c.Echo().Reverse(s.RouteName("link_detail"), result.Link.Hash))

	}
	var canEdit bool


@@ 3218,7 3219,7 @@ func (s *Service) NoteDetail(c echo.Context) error {

	var result GraphQLResponse
	op := gqlclient.NewOperation(
		`query getOrgLink($hash: String) {
		`query getOrgLink($hash: String!) {
				getOrgLink(hash: $hash) {
					id
					title


@@ 3375,15 3376,15 @@ func (s *Service) NoteCreate(c echo.Context) error {
		OrgSlug:    formSlug,
	}

	id := c.QueryParam("linkid")
	if id != "" {
	hash := c.QueryParam("linkid")
	if hash != "" {
		type GraphQLResponse struct {
			Link models.OrgLink `json:"getOrgLink"`
		}
		var result GraphQLResponse

		op := gqlclient.NewOperation(
			`query getOrgLink($hash: String) {
			`query getOrgLink($hash: String!) {
				getOrgLink(hash: $hash) {
					title
					description


@@ 3394,7 3395,7 @@ func (s *Service) NoteCreate(c echo.Context) error {
					}
				}
			}`)
		op.Var("hash", id)
		op.Var("hash", hash)
		err = links.Execute(c.Request().Context(), op, &result)
		if err != nil {
			if graphError, ok := err.(*gqlclient.Error); ok {

M core/routes_test.go => core/routes_test.go +4 -4
@@ 302,14 302,14 @@ func TestHandlers(t *testing.T) {
			Context: e.NewContext(request, recorder),
			User:    loggedInUser,
		}
		ctx.SetPath("/link/:id/edit")
		ctx.SetParamNames("id")
		ctx.SetParamValues("1")
		ctx.SetPath("/link/:hash")
		ctx.SetParamNames("hash")
		ctx.SetParamValues("abcdefg") // link hash is "abcdefg" in mock json file
		// We get the error from the API call
		err = test.MakeRequestWithDomain(srv, coreService.OrgLinkDetail, ctx, domains[0])
		c.NoError(err)
		htmlBody := recorder.Body.String()
		c.True(strings.Contains(htmlBody, "/click/30")) // link ID is 30 in mock json file
		c.True(strings.Contains(htmlBody, "/click/abcdefg"))
	})

	t.Run("link delete error", func(t *testing.T) {

M core/samples/detail_link.json => core/samples/detail_link.json +1 -0
@@ 2,6 2,7 @@
    "data": {
        "getOrgLink": {
            "id": 30,
	    "hash": "abcdefg",
            "title": "Detail org",
            "url": "https://www.detail.org",
            "baseUrlId": {

M migrations/0001_initial.up.sql => migrations/0001_initial.up.sql +1 -1
@@ 100,7 100,7 @@ CREATE TABLE org_links (
  url TEXT NOT NULL,
  description TEXT DEFAULT '',
  "type" INT NOT NULL DEFAULT 0,
  note_hash TEXT DEFAULT '',
  hash VARCHAR(128) UNIQUE NOT NULL DEFAULT substr(encode(sha256(random()::text::bytea), 'hex'), 0, 27),
  base_url_id INT REFERENCES base_urls (id) ON DELETE CASCADE,
  org_id INT REFERENCES organizations (id) ON DELETE CASCADE NOT NULL,
  user_id INT REFERENCES users (id) ON DELETE SET NULL,

M models/models.go => models/models.go +2 -2
@@ 90,7 90,7 @@ type OrgLink struct {
	Starred     bool          `db:"starred" json:"starred"`
	ArchiveURL  string        `db:"archive_url" json:"archiveUrl"`
	Type        int           `db:"type" json:"type"`
	NoteHash    string        `db:"note_hash" json:"noteHash"`
	Hash        string        `db:"hash" json:"hash"`
	CreatedOn   time.Time     `db:"created_on" json:"createdOn"`
	UpdatedOn   time.Time     `db:"updated_on" json:"updatedOn"`



@@ 346,7 346,7 @@ type ExportOrgLink struct {
	Visibility  int         `db:"visibility" json:"visibility"`
	Unread      bool        `db:"unread" json:"unread"`
	Starred     bool        `db:"starred" json:"starred"`
	NoteHash    string      `db:"note_hash" json:"note_hash"`
	Hash        string      `db:"hash" json:"hash"`
	Tags        []ExportTag `db:"-" json:"tags"`
	CreatedOn   time.Time   `db:"created_on" json:"created_on"`
}

M models/org_link.go => models/org_link.go +12 -16
@@ 36,7 36,7 @@ func GetOrgLinks(ctx context.Context, opts *database.FilterOptions) ([]*OrgLink,
		q := opts.GetBuilder(nil)
		rows, err := q.
			Columns("ol.id", "ol.title", "ol.url", "ol.description", "ol.base_url_id", "ol.org_id", "ol.user_id",
				"ol.visibility", "ol.unread", "ol.starred", "ol.archive_url", "ol.type", "ol.note_hash",
				"ol.visibility", "ol.unread", "ol.starred", "ol.archive_url", "ol.type", "ol.hash",
				"ol.created_on", "ol.updated_on", "o.slug", "u.full_name", "json_agg(t)::jsonb", "b.data").
			From("org_links ol").
			Join("organizations o ON o.id = ol.org_id").


@@ 63,7 63,7 @@ func GetOrgLinks(ctx context.Context, opts *database.FilterOptions) ([]*OrgLink,
			var o OrgLink
			var tags string
			if err = rows.Scan(&o.ID, &o.Title, &o.URL, &o.Description, &o.BaseURLID, &o.OrgID,
				&o.UserID, &o.Visibility, &o.Unread, &o.Starred, &o.ArchiveURL, &o.Type, &o.NoteHash,
				&o.UserID, &o.Visibility, &o.Unread, &o.Starred, &o.ArchiveURL, &o.Type, &o.Hash,
				&o.CreatedOn, &o.UpdatedOn, &o.OrgSlug, &o.Author, &tags, &o.BaseURLData); err != nil {
				return err
			}


@@ 104,7 104,7 @@ func (o *OrgLink) Load(ctx context.Context) error {
	err := database.WithTx(ctx, database.TxOptionsRO, func(tx *sql.Tx) error {
		err := sq.
			Select("id", "title", "url", "description", "base_url_id", "org_id", "user_id",
				"visibility", "unread", "starred", "archive_url", "type", "note_hash",
				"visibility", "unread", "starred", "archive_url", "type", "hash",
				"created_on", "updated_on").
			From("org_links").
			Where("id = ?", o.ID).


@@ 112,7 112,7 @@ func (o *OrgLink) Load(ctx context.Context) error {
			RunWith(tx).
			ScanContext(ctx, &o.ID, &o.Title, &o.URL, &o.Description, &o.BaseURLID,
				&o.OrgID, &o.UserID, &o.Visibility, &o.Unread, &o.Starred, &o.ArchiveURL,
				&o.Type, &o.NoteHash, &o.CreatedOn, &o.UpdatedOn)
				&o.Type, &o.Hash, &o.CreatedOn, &o.UpdatedOn)
		if err != nil {
			if err == sql.ErrNoRows {
				return nil


@@ 133,9 133,9 @@ func (o *OrgLink) Store(ctx context.Context) error {
			err = sq.
				Insert("org_links").
				Columns("title", "url", "description", "base_url_id", "org_id", "user_id", "visibility",
					"unread", "starred", "archive_url", "type", "note_hash").
					"unread", "starred", "archive_url", "type", "hash").
				Values(o.Title, o.URL, o.Description, o.BaseURLID, o.OrgID, o.UserID, o.Visibility,
					o.Unread, o.Starred, o.ArchiveURL, o.Type, o.NoteHash).
					o.Unread, o.Starred, o.ArchiveURL, o.Type, o.Hash).
				Suffix(`RETURNING id, created_on, updated_on`).
				PlaceholderFormat(sq.Dollar).
				RunWith(tx).


@@ 154,7 154,7 @@ func (o *OrgLink) Store(ctx context.Context) error {
				Set("starred", o.Starred).
				Set("archive_url", o.ArchiveURL).
				Set("type", o.Type).
				Set("note_hash", o.NoteHash).
				Set("hash", o.Hash).
				Where("id = ?", o.ID).
				Suffix(`RETURNING (updated_on)`).
				PlaceholderFormat(sq.Dollar).


@@ 208,11 208,7 @@ func (o *OrgLink) TagsToString() string {
// to a users bookmarks
func (o *OrgLink) QueryParams() url.Values {
	qs := url.Values{}
	if o.Type == NoteType {
		qs.Set("linkid", o.NoteHash)
	} else {
		qs.Set("linkid", fmt.Sprint(o.ID))
	}
	qs.Set("linkid", o.Hash)
	return qs
}



@@ 265,11 261,11 @@ func OrgLinkStoreBatch(ctx context.Context, links []*OrgLink) error {
		batch := sq.
			Insert("org_links").
			Columns("title", "url", "description", "base_url_id", "org_id", "user_id", "visibility",
				"note_hash", "type", "unread")
				"hash", "type", "unread")

		for _, link := range links {
			batch = batch.Values(link.Title, link.URL, link.Description, link.BaseURLID, link.OrgID,
				link.UserID, link.Visibility, link.NoteHash, link.Type, link.Unread)
				link.UserID, link.Visibility, link.Hash, link.Type, link.Unread)
		}
		_, err := batch.
			PlaceholderFormat(sq.Dollar).


@@ 290,7 286,7 @@ func ExportOrgLinks(ctx context.Context, opts *database.FilterOptions) ([]*Expor
		q := opts.GetBuilder(nil)
		rows, err := q.
			Columns("ol.id", "ol.title", "ol.url", "ol.description", "ol.visibility",
				"ol.unread", "ol.starred", "ol.note_hash", "ol.created_on", "json_agg(t)::jsonb").
				"ol.unread", "ol.starred", "ol.hash", "ol.created_on", "json_agg(t)::jsonb").
			From("org_links ol").
			LeftJoin("tag_links tl ON tl.org_link_id = ol.id").
			LeftJoin("tags t ON t.id = tl.tag_id").


@@ 311,7 307,7 @@ func ExportOrgLinks(ctx context.Context, opts *database.FilterOptions) ([]*Expor
			var o ExportOrgLink
			var tags string
			if err = rows.Scan(&o.ID, &o.Title, &o.URL, &o.Description, &o.Visibility,
				&o.Unread, &o.Starred, &o.NoteHash, &o.CreatedOn, &tags); err != nil {
				&o.Unread, &o.Starred, &o.Hash, &o.CreatedOn, &tags); err != nil {
				return err
			}
			re := regexp.MustCompile(`(,\s)?null,?`)

M models/schema.sql => models/schema.sql +3 -1
@@ 1,3 1,5 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE OR REPLACE FUNCTION update_updated_on_column()
RETURNS TRIGGER AS $$
BEGIN


@@ 100,7 102,7 @@ CREATE TABLE org_links (
  url TEXT NOT NULL,
  description TEXT DEFAULT '',
  "type" INT NOT NULL DEFAULT 0,
  note_hash TEXT DEFAULT '',
  hash VARCHAR(128) UNIQUE NOT NULL DEFAULT substr(encode(sha256(random()::text::bytea), 'hex'), 0, 27),
  base_url_id INT REFERENCES base_urls (id) ON DELETE CASCADE,
  org_id INT REFERENCES organizations (id) ON DELETE CASCADE NOT NULL,
  user_id INT REFERENCES users (id) ON DELETE SET NULL,

M templates/link_detail.html => templates/link_detail.html +2 -2
@@ 10,7 10,7 @@
  <header class="d-flex flex-row justify-between items-center">
    <div class="d-flex flex-row items-center">
      <div class="ml-1">
        <h2><a class="text-dark" href="{{reverse "core:link_redirect" .link.ID}}">{{.link.Title}}</a></h2>
        <h2><a class="text-dark" href="{{reverse "core:link_redirect" .link.Hash}}">{{.link.Title}}</a></h2>
        <div class="d-flex flex-row items-center">
            {{if .IsPrivate}}
            <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width:20px; margin-right: 7px;">


@@ 29,7 29,7 @@
    </div>
  </header>
  {{if eq .link.Type 0}}
  <p><a href="{{reverse "core:link_redirect" .link.ID}}" target="_blank">{{stripCommonProtocol .link.URL}}</a></p>
  <p><a href="{{reverse "core:link_redirect" .link.Hash}}" target="_blank">{{stripCommonProtocol .link.URL}}</a></p>
  {{end}}
  <p>{{.link.Description}}</p>
  {{if .link.Tags}}