diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go index 985bc052..a5201aab 100644 --- a/pkg/atproto/sync.go +++ b/pkg/atproto/sync.go @@ -891,6 +891,17 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD log.Debug(ctx, "place.stream.like detected", "subject", rec.Subject, "repo", repo.Handle) + // A user can only like a subject once — refuse to index a duplicate + // rather than inflating the count with a second row. + existing, err := atsync.Model.GetLikeBySubjectAndUser(ctx, rec.Subject, userDID) + if err != nil { + return fmt.Errorf("check existing like: %w", err) + } + if existing != nil { + log.Debug(ctx, "ignoring duplicate like", "subject", rec.Subject, "repo", userDID) + return nil + } + like := &model.Like{ CID: cid, URI: aturi.String(), diff --git a/pkg/model/like.go b/pkg/model/like.go index b2cb3652..ba8ae266 100644 --- a/pkg/model/like.go +++ b/pkg/model/like.go @@ -13,8 +13,11 @@ import ( ) type Like struct { - CID string `json:"cid" gorm:"primaryKey;column:cid"` - URI string `json:"uri" gorm:"column:uri"` + // URI is the primary key: it's unique per like record (repo + rkey). CID + // is NOT — two users liking the same subject at the same createdAt produce + // byte-identical records and thus the same CID, which would collide. + URI string `json:"uri" gorm:"primaryKey;column:uri"` + CID string `json:"cid" gorm:"column:cid"` Subject string `json:"subject" gorm:"column:subject;index:idx_likes_subject"` RepoDID string `json:"repoDID" gorm:"column:repo_did"` Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"` @@ -70,10 +73,13 @@ func (m *DBModel) GetLike(uri string) (*Like, error) { func (m *DBModel) GetLikeBySubjectAndUser(ctx context.Context, subject string, repoDID string) (*Like, error) { var like Like - err := m.DB.Preload("Repo").Where("subject = ? AND repo_did = ?", subject, repoDID).First(&like).Error + err := m.DB.WithContext(ctx).Preload("Repo").Where("subject = ? AND repo_did = ?", subject, repoDID).First(&like).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } + if err != nil { + return nil, err + } return &like, nil } diff --git a/pkg/model/video_list_test.go b/pkg/model/video_list_test.go index 878b49ea..a8c78538 100644 --- a/pkg/model/video_list_test.go +++ b/pkg/model/video_list_test.go @@ -11,15 +11,15 @@ import ( "stream.place/streamplace/pkg/streamplace" ) -// putLike writes a place.stream.like whose subject is the given video URI. -func putLike(t *testing.T, m Model, subject, cid string) { +// putLike writes a place.stream.like by `liker` whose subject is the given URI. +func putLike(t *testing.T, m Model, subject, liker, cid string) { t.Helper() now := time.Now().UTC() require.NoError(t, m.CreateLike(context.Background(), &Like{ CID: cid, - URI: "at://did:plc:liker/place.stream.like/" + cid, + URI: "at://" + liker + "/place.stream.like/" + cid, Subject: subject, - RepoDID: "did:plc:liker", + RepoDID: liker, IndexedAt: &now, CreatedAt: now, })) @@ -41,11 +41,11 @@ func TestVideoLikeCount(t *testing.T) { require.NotNil(t, view) require.Equal(t, int64(0), view.LikeCount) - putLike(t, m, videoURI, "likecid1") - putLike(t, m, videoURI, "likecid2") - putLike(t, m, videoURI, "likecid3") + putLike(t, m, videoURI, "did:plc:l1", "likecid1") + putLike(t, m, videoURI, "did:plc:l2", "likecid2") + putLike(t, m, videoURI, "did:plc:l3", "likecid3") // A like on a different subject must not count toward this video. - putLike(t, m, "at://did:plc:alice/place.stream.video/other", "likecid4") + putLike(t, m, "at://did:plc:alice/place.stream.video/other", "did:plc:l1", "likecid4") view, err = m.GetVideoView(ctx, videoURI) require.NoError(t, err) @@ -63,6 +63,30 @@ func TestVideoLikeCount(t *testing.T) { require.Equal(t, int64(3), found.LikeCount) } +// TestGetLikeBySubjectAndUser covers the lookup the indexer uses to refuse a +// double-like: same (subject, user) is found; a different user or subject isn't. +func TestGetLikeBySubjectAndUser(t *testing.T) { + m, err := MakeDB(":memory:") + require.NoError(t, err) + ctx := context.Background() + + const subject = "at://did:plc:alice/place.stream.video/v1" + putLike(t, m, subject, "did:plc:liker1", "c1") + + got, err := m.GetLikeBySubjectAndUser(ctx, subject, "did:plc:liker1") + require.NoError(t, err) + require.NotNil(t, got, "same subject + user should be found") + require.Equal(t, subject, got.Subject) + + got, err = m.GetLikeBySubjectAndUser(ctx, subject, "did:plc:liker2") + require.NoError(t, err) + require.Nil(t, got, "a different user may still like the subject") + + got, err = m.GetLikeBySubjectAndUser(ctx, "at://did:plc:alice/place.stream.video/v2", "did:plc:liker1") + require.NoError(t, err) + require.Nil(t, got, "a different subject is independent") +} + const testServerDID = "did:web:us.example.com" // putTrackVideo writes a place.stream.media.track (backed by blobCID) and a