@{{ ident.Handle }}
+ Account has not posted! +{% endfor %} +
diff --git a/api/socialscrumble/betaaccountjoin.go b/api/socialscrumble/betaaccountjoin.go index 696f57c..8137fb7 100644 --- a/api/socialscrumble/betaaccountjoin.go +++ b/api/socialscrumble/betaaccountjoin.go @@ -8,5 +8,5 @@ package socialscrumble type BetaAccountJoin struct { LexiconTypeID string `json:"$type" cborgen:"$type,const=social.scrumble.beta.account.join"` CreatedAt *string `json:"createdAt,omitempty" cborgen:"createdAt,omitempty"` - Scene *string `json:"scene,omitempty" cborgen:"scene,omitempty"` + Scene string `json:"scene" cborgen:"scene"` } diff --git a/go.mod b/go.mod index 69b016f..903c3cd 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect + github.com/rivo/uniseg v0.1.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect diff --git a/go.sum b/go.sum index 8f224e3..9750e35 100644 --- a/go.sum +++ b/go.sum @@ -142,6 +142,8 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/indexer/indexer.go b/indexer/indexer.go index 3ef5da9..2cfb76d 100644 --- a/indexer/indexer.go +++ b/indexer/indexer.go @@ -82,22 +82,83 @@ func (idx *Indexer) HandleRecord(ctx context.Context, evt *tapclient.RecordEvent if err != nil { return nil // skip } + _ = collection rkey, err := syntax.ParseRecordKey(evt.Rkey) if err != nil { return nil // skip } + var version syntax.CID + if evt.Action != "delete" { + version, err = syntax.ParseCID(evt.CID) + if err != nil { + logger.Warn("invalid record CID", "err", err, "cid", evt.CID) + return nil + } + } switch evt.Collection { case "social.scrumble.beta.account.profile": if evt.Rkey != "self" { return nil // skip } - if evt.Action != "delete" { - // TODO: validate lexicon using catalog - var profile socialscrumble.BetaAccountProfile - if err := json.Unmarshal(evt.Record, &profile); err != nil { - logger.Warn("invalid profile record", "err", err) - return nil + // TODO: filter down profile indexing? + if evt.Action == "delete" { + if err := idx.store.DeleteProfile(ctx, did); err != nil { + return err + } + } else { + if err := idx.store.UpsertProfile(ctx, did, version, evt.Record); err != nil { + return err + } + } + case "social.scrumble.beta.account.join": + rkeyDID, err := syntax.ParseDID(rkey.String()) + if err != nil { + return nil // skip + } + if !idx.WantScene(rkeyDID) { + return nil // skip + } + if evt.Action == "delete" { + if err := idx.store.DeleteAccountJoin(ctx, did, rkeyDID); err != nil { + return err + } + } else { + if err := idx.store.UpsertAccountJoin(ctx, did, rkeyDID, version, evt.Record); err != nil { + return err + } + } + case "social.scrumble.beta.scene.declaration": + if evt.Rkey != "self" { + return nil // skip + } + if !idx.WantScene(did) { + return nil // skip + } + if evt.Action == "delete" { + if err := idx.store.DeleteSceneDeclaration(ctx, did); err != nil { + return err + } + } else { + if err := idx.store.UpsertSceneDeclaration(ctx, did, version, evt.Record); err != nil { + return err + } + } + case "social.scrumble.beta.scene.member": + if !idx.WantScene(did) { + return nil // skip + } + rkeyDID, err := syntax.ParseDID(rkey.String()) + if err != nil { + return nil // skip + } + if evt.Action == "delete" { + if err := idx.store.DeleteSceneMember(ctx, did, rkeyDID); err != nil { + return err + } + } else { + if err := idx.store.UpsertSceneMember(ctx, did, rkeyDID, version, evt.Record); err != nil { + return err } } case "social.scrumble.beta.collection.item": @@ -110,7 +171,6 @@ func (idx *Indexer) HandleRecord(ctx context.Context, evt *tapclient.RecordEvent return err } } else { - // TODO: validate lexicon using catalog var item socialscrumble.BetaCollectionItem if err := json.Unmarshal(evt.Record, &item); err != nil { logger.Warn("invalid item record", "err", err) @@ -124,7 +184,7 @@ func (idx *Indexer) HandleRecord(ctx context.Context, evt *tapclient.RecordEvent if !idx.WantScene(scene) { return nil // skip } - if err := idx.store.UpsertItem(ctx, scene, did, rkey, evt.CID); err != nil { + if err := idx.store.UpsertItem(ctx, scene, did, rkey, version, evt.Record); err != nil { return err } } @@ -132,18 +192,6 @@ func (idx *Indexer) HandleRecord(ctx context.Context, evt *tapclient.RecordEvent // ignore return nil } - - // update record table for all collection types (if we got this far) - if evt.Action == "delete" { - if err := idx.store.DeleteRecord(ctx, did, collection, rkey); err != nil { - return err - } - } else { - // TODO: parse version CID? - if err := idx.store.UpsertRecord(ctx, did, collection, rkey, evt.CID, evt.Record); err != nil { - return err - } - } return nil } diff --git a/lexicons/social/scrumble/beta/account/join.json b/lexicons/social/scrumble/beta/account/join.json index 42d99d8..d4aeff0 100644 --- a/lexicons/social/scrumble/beta/account/join.json +++ b/lexicons/social/scrumble/beta/account/join.json @@ -8,7 +8,7 @@ "key": "any", "record": { "type": "object", - "required": [], + "required": ["scene"], "properties": { "scene": { "type": "string", diff --git a/server.go b/server.go index 8aa6155..edf7d51 100644 --- a/server.go +++ b/server.go @@ -16,6 +16,7 @@ import ( "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/lexicon" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/bluesky-social/indigo/util/svcutil" "github.com/gorilla/sessions" @@ -30,6 +31,9 @@ var UserAgent = "scrumble-server" //go:embed static/* var StaticFS embed.FS +//go:embed lexicons +var LexiconFS embed.FS + var OAuthScopes = []string{ "atproto", "repo:social.scrumble.beta.account.profile", @@ -43,6 +47,7 @@ var OAuthScopes = []string{ type Server struct { logger *slog.Logger dir identity.Directory + cat lexicon.Catalog config ServerConfig store *store.Store echo *echo.Echo @@ -60,7 +65,13 @@ type ServerConfig struct { func NewServer(db *pgx.Conn, config ServerConfig) (*Server, error) { - st, err := store.NewStore(db) + // load/parse embedded lexicon catalog (NOTE: maybe move to main.go?) + cat := lexicon.NewBaseCatalog() + if err := cat.LoadEmbedFS(LexiconFS); err != nil { + return nil, err + } + + st, err := store.NewStore(db, cat) if err != nil { return nil, err } @@ -106,6 +117,7 @@ func NewServer(db *pgx.Conn, config ServerConfig) (*Server, error) { return &Server{ logger: slog.Default(), dir: config.Directory, + cat: cat, config: config, store: st, indexer: idx, @@ -195,6 +207,7 @@ func (srv *Server) startWithListener(listen net.Listener) error { e.GET("/s/:scene/item/:account/:rkey", srv.WebItemView) e.GET("/s/:scene/item", srv.WebItemCreate) e.POST("/s/:scene/item", srv.WebItemCreate) + e.GET("/u/:username", srv.WebAccountView) if srv.config.DevMode { e.GET("/dev/index", srv.WebDevIndex) diff --git a/store/account.go b/store/account.go new file mode 100644 index 0000000..bee387b --- /dev/null +++ b/store/account.go @@ -0,0 +1,90 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + + "tangled.org/bnewbold.net/scrumble/store/sqlcdb" + + "github.com/bluesky-social/indigo/atproto/atdata" + "github.com/bluesky-social/indigo/atproto/lexicon" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/jackc/pgx/v5" + "tangled.org/bnewbold.net/scrumble/api/socialscrumble" +) + +var ProfileNSID = syntax.NSID("social.scrumble.beta.account.profile") + +// this method assumes the did, handle, and status have already been parsed/cleaned +func (st *Store) UpsertIdentity(ctx context.Context, did syntax.DID, handle syntax.Handle, upstreamStatus string) error { + q := sqlcdb.New(st.conn) + return q.UpsertIdentity(ctx, sqlcdb.UpsertIdentityParams{ + Did: did.String(), + Handle: new(handle.String()), + UpstreamStatus: upstreamStatus, + }) +} + +func (st *Store) UpsertProfile(ctx context.Context, accountDID syntax.DID, version syntax.CID, val json.RawMessage) error { + + // lexicon and data validation + obj, err := atdata.UnmarshalJSON(val) + if err != nil { + return fmt.Errorf("record data validation failed: %w", err) + } + if err := lexicon.ValidateRecord(st.cat, obj, ProfileNSID.String(), 0); err != nil { + return fmt.Errorf("profile record lex validation failed: %w", err) + } + + tx, err := st.conn.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return err + } + defer tx.Rollback(ctx) + q := sqlcdb.New(tx) + + // XXX: also an account row? + + if err := q.UpsertRecord(ctx, sqlcdb.UpsertRecordParams{ + AccountDid: accountDID.String(), + Collection: ProfileNSID.String(), + Rkey: "self", + Version: version.String(), + DataJson: val, + }); err != nil { + return err + } + + return tx.Commit(ctx) +} + +func (st *Store) DeleteProfile(ctx context.Context, accountDID syntax.DID) error { + q := sqlcdb.New(st.conn) + + // TODO: update account row/status? + + if err := q.DeleteRecord(ctx, sqlcdb.DeleteRecordParams{ + AccountDid: accountDID.String(), + Collection: ProfileNSID.String(), + Rkey: "self", + }); err != nil { + return err + } + + return nil +} + +func (st *Store) GetProfile(ctx context.Context, accountDID syntax.DID) (*socialscrumble.BetaAccountProfile, error) { + + row, err := st.GetRecord(ctx, accountDID, ProfileNSID, syntax.RecordKey("self")) + if err != nil { + return nil, err + } + + var profile socialscrumble.BetaAccountProfile + if err := json.Unmarshal(row.DataJson, &profile); err != nil { + return nil, err + } + return &profile, nil +} diff --git a/store/example_data.go b/store/example_data.go index bc31cbd..9d42c32 100644 --- a/store/example_data.go +++ b/store/example_data.go @@ -14,6 +14,7 @@ type exampleAccount struct { DID syntax.DID Handle syntax.Handle Status string + Scenes []syntax.DID } type exampleScene struct { @@ -22,6 +23,7 @@ type exampleScene struct { DisplayName string Description string Members []syntax.DID + CreatedAt string } type exampleItem struct { @@ -63,6 +65,7 @@ func (st *Store) InsertExampleData(ctx context.Context) (*identity.MockDirectory Handle: syntax.Handle("jupiter.solar.town"), DisplayName: "Jupiter", Description: "gas giant with too many moons", + CreatedAt: "2025-05-12T04:11:19.412Z", Members: []syntax.DID{micromegas.DID, saturnian.DID}, } earth := exampleScene{ @@ -70,12 +73,15 @@ func (st *Store) InsertExampleData(ctx context.Context) (*identity.MockDirectory Handle: syntax.Handle("earth.solar.town"), DisplayName: "Earth", Description: "third rock from the sun", + CreatedAt: "2026-05-12T04:11:12.167Z", Members: []syntax.DID{micromegas.DID, saturnian.DID}, } scenes := []exampleScene{ jupiter, earth, } + micromegas.Scenes = []syntax.DID{jupiter.DID, earth.DID} + saturnian.Scenes = []syntax.DID{jupiter.DID, earth.DID} items := []exampleItem{ exampleItem{ @@ -100,6 +106,41 @@ func (st *Store) InsertExampleData(ctx context.Context) (*identity.MockDirectory if err := st.UpsertIdentity(ctx, acc.DID, acc.Handle, acc.Status); err != nil { return nil, err } + profile := socialscrumble.BetaAccountProfile{ + LexiconTypeID: ProfileNSID.String(), + CreatedAt: nil, // TODO + } + data, err := json.Marshal(profile) + if err != nil { + return nil, err + } + version, err := VersionForRecord(data) + if err != nil { + return nil, err + } + if err := st.UpsertProfile(ctx, acc.DID, version, data); err != nil { + return nil, err + } + + for _, sceneDID := range acc.Scenes { + join := socialscrumble.BetaAccountJoin{ + LexiconTypeID: AccountJoinNSID.String(), + Scene: sceneDID.String(), + CreatedAt: nil, // TODO + } + jdata, err := json.Marshal(join) + if err != nil { + return nil, err + } + jversion, err := VersionForRecord(jdata) + if err != nil { + return nil, err + } + if err := st.UpsertAccountJoin(ctx, acc.DID, sceneDID, jversion, jdata); err != nil { + return nil, err + } + } + dir.Insert(identity.Identity{ DID: acc.DID, Handle: acc.Handle, @@ -108,18 +149,45 @@ func (st *Store) InsertExampleData(ctx context.Context) (*identity.MockDirectory for _, scene := range scenes { decl := socialscrumble.BetaSceneDeclaration{ - LexiconTypeID: "social.scrumble.beta.scene.declaration", + LexiconTypeID: SceneDeclarationNSID.String(), DisplayName: new(scene.DisplayName), Description: new(scene.Description), + CreatedAt: scene.CreatedAt, + Config: socialscrumble.BetaSceneDeclaration_SceneConfig{}, + Guidelines: []socialscrumble.BetaSceneDeclaration_SceneGuideline{}, } data, err := json.Marshal(decl) if err != nil { return nil, err } - version := "XXX" - if err := st.UpsertRecord(ctx, scene.DID, syntax.NSID("social.scrumble.beta.scene.declaration"), syntax.RecordKey("self"), version, data); err != nil { + version, err := VersionForRecord(data) + if err != nil { + return nil, err + } + if err := st.UpsertSceneDeclaration(ctx, scene.DID, version, data); err != nil { return nil, err } + + for _, memberDID := range scene.Members { + member := socialscrumble.BetaSceneMember{ + LexiconTypeID: SceneMemberNSID.String(), + Did: memberDID.String(), + Role: "member", // TODO + CreatedAt: syntax.DatetimeNow().String(), // TODO + } + mdata, err := json.Marshal(member) + if err != nil { + return nil, err + } + mversion, err := VersionForRecord(mdata) + if err != nil { + return nil, err + } + if err := st.UpsertSceneMember(ctx, scene.DID, memberDID, mversion, mdata); err != nil { + return nil, err + } + } + dir.Insert(identity.Identity{ DID: scene.DID, Handle: scene.Handle, @@ -128,7 +196,7 @@ func (st *Store) InsertExampleData(ctx context.Context) (*identity.MockDirectory for _, item := range items { rec := socialscrumble.BetaCollectionItem{ - LexiconTypeID: "social.scrumble.beta.collection.item", + LexiconTypeID: ItemNSID.String(), Scene: item.SceneDID.String(), CreatedAt: item.CreatedAt, Item: &socialscrumble.BetaCollectionItem_Item{ @@ -142,11 +210,11 @@ func (st *Store) InsertExampleData(ctx context.Context) (*identity.MockDirectory if err != nil { return nil, err } - version := "XXX" - if err := st.UpsertRecord(ctx, item.AccountDID, syntax.NSID("social.scrumble.beta.collection.item"), item.RKey, version, data); err != nil { + version, err := VersionForRecord(data) + if err != nil { return nil, err } - if err := st.UpsertItem(ctx, item.SceneDID, item.AccountDID, item.RKey, version); err != nil { + if err := st.UpsertItem(ctx, item.SceneDID, item.AccountDID, item.RKey, version, data); err != nil { return nil, err } } diff --git a/store/item.go b/store/item.go new file mode 100644 index 0000000..b7abdfb --- /dev/null +++ b/store/item.go @@ -0,0 +1,160 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + + "tangled.org/bnewbold.net/scrumble/store/sqlcdb" + + "github.com/bluesky-social/indigo/atproto/atdata" + "github.com/bluesky-social/indigo/atproto/lexicon" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/jackc/pgx/v5" + "tangled.org/bnewbold.net/scrumble/api/socialscrumble" +) + +var ItemNSID = syntax.NSID("social.scrumble.beta.collection.item") + +type SceneItem struct { + SceneDID syntax.DID + AccountDID syntax.DID + RKey syntax.RecordKey + Record socialscrumble.BetaCollectionItem +} + +func (st *Store) UpsertItem(ctx context.Context, sceneDID syntax.DID, accountDID syntax.DID, rkey syntax.RecordKey, version syntax.CID, val json.RawMessage) error { + + // lexicon and data validation + obj, err := atdata.UnmarshalJSON(val) + if err != nil { + return fmt.Errorf("record data validation failed: %w", err) + } + if err := lexicon.ValidateRecord(st.cat, obj, ItemNSID.String(), 0); err != nil { + return fmt.Errorf("item record lex validation failed: %w", err) + } + + tx, err := st.conn.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return err + } + defer tx.Rollback(ctx) + q := sqlcdb.New(tx) + + if err := q.UpsertRecord(ctx, sqlcdb.UpsertRecordParams{ + AccountDid: accountDID.String(), + Collection: ItemNSID.String(), + Rkey: rkey.String(), + Version: version.String(), + DataJson: val, + }); err != nil { + return err + } + if err := q.UpsertItem(ctx, sqlcdb.UpsertItemParams{ + SceneDid: sceneDID.String(), + AccountDid: accountDID.String(), + Rkey: rkey.String(), + Version: version.String(), + }); err != nil { + return err + } + + return tx.Commit(ctx) +} + +func (st *Store) DeleteItem(ctx context.Context, accountDID syntax.DID, rkey syntax.RecordKey) error { + tx, err := st.conn.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return err + } + defer tx.Rollback(ctx) + q := sqlcdb.New(tx) + + if err := q.DeleteItem(ctx, sqlcdb.DeleteItemParams{ + AccountDid: accountDID.String(), + Rkey: rkey.String(), + }); err != nil { + return err + } + + if err := q.DeleteRecord(ctx, sqlcdb.DeleteRecordParams{ + AccountDid: accountDID.String(), + Collection: ItemNSID.String(), + Rkey: rkey.String(), + }); err != nil { + return err + } + + return tx.Commit(ctx) +} + +func (st *Store) GetItem(ctx context.Context, accountDID syntax.DID, rkey syntax.RecordKey) (*SceneItem, error) { + q := sqlcdb.New(st.conn) + row, err := q.GetRecord(ctx, sqlcdb.GetRecordParams{ + AccountDid: accountDID.String(), + Collection: ItemNSID.String(), + Rkey: rkey.String(), + }) + if err != nil { + return nil, err + } + var record socialscrumble.BetaCollectionItem + if err := json.Unmarshal(row.DataJson, &record); err != nil { + return nil, err + } + + item := SceneItem{ + SceneDID: syntax.DID(record.Scene), + AccountDID: accountDID, + RKey: rkey, + Record: record, + } + + return &item, nil +} + +func (st *Store) ListSceneItems(ctx context.Context, sceneDID syntax.DID) ([]SceneItem, error) { + q := sqlcdb.New(st.conn) + rows, err := q.ListSceneItems(ctx, sceneDID.String()) + if err != nil { + return nil, err + } + + items := make([]SceneItem, len(rows)) + for i, row := range rows { + var record socialscrumble.BetaCollectionItem + if err := json.Unmarshal(row.Record.DataJson, &record); err != nil { + return nil, err + } + items[i] = SceneItem{ + SceneDID: sceneDID, + AccountDID: syntax.DID(row.Record.AccountDid), + RKey: syntax.RecordKey(row.Record.Rkey), + Record: record, + } + } + return items, nil +} + +func (st *Store) ListAccountItems(ctx context.Context, accountDID syntax.DID) ([]SceneItem, error) { + q := sqlcdb.New(st.conn) + rows, err := q.ListAccountItems(ctx, accountDID.String()) + if err != nil { + return nil, err + } + + items := make([]SceneItem, len(rows)) + for i, row := range rows { + var record socialscrumble.BetaCollectionItem + if err := json.Unmarshal(row.Record.DataJson, &record); err != nil { + return nil, err + } + items[i] = SceneItem{ + SceneDID: syntax.DID(row.Item.SceneDid), + AccountDID: syntax.DID(row.Record.AccountDid), + RKey: syntax.RecordKey(row.Record.Rkey), + Record: record, + } + } + return items, nil +} diff --git a/store/membership.go b/store/membership.go new file mode 100644 index 0000000..3d38a87 --- /dev/null +++ b/store/membership.go @@ -0,0 +1,117 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + + "tangled.org/bnewbold.net/scrumble/store/sqlcdb" + + "github.com/bluesky-social/indigo/atproto/atdata" + "github.com/bluesky-social/indigo/atproto/lexicon" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/jackc/pgx/v5" +) + +var AccountJoinNSID = syntax.NSID("social.scrumble.beta.account.join") +var SceneMemberNSID = syntax.NSID("social.scrumble.beta.scene.member") + +func (st *Store) UpsertAccountJoin(ctx context.Context, accountDID syntax.DID, sceneDID syntax.DID, version syntax.CID, val json.RawMessage) error { + + // lexicon and data validation + obj, err := atdata.UnmarshalJSON(val) + if err != nil { + return fmt.Errorf("record data validation failed: %w", err) + } + if err := lexicon.ValidateRecord(st.cat, obj, AccountJoinNSID.String(), 0); err != nil { + return fmt.Errorf("account join record lex validation failed: %w", err) + } + if obj["scene"] != sceneDID.String() { + return fmt.Errorf("join record scene mismatch") + } + + tx, err := st.conn.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return err + } + defer tx.Rollback(ctx) + q := sqlcdb.New(tx) + + // XXX: also a membership row? + + if err := q.UpsertRecord(ctx, sqlcdb.UpsertRecordParams{ + AccountDid: accountDID.String(), + Collection: AccountJoinNSID.String(), + Rkey: sceneDID.String(), + Version: version.String(), + DataJson: val, + }); err != nil { + return err + } + + return tx.Commit(ctx) +} + +func (st *Store) DeleteAccountJoin(ctx context.Context, accountDID syntax.DID, sceneDID syntax.DID) error { + q := sqlcdb.New(st.conn) + + if err := q.DeleteRecord(ctx, sqlcdb.DeleteRecordParams{ + AccountDid: accountDID.String(), + Collection: AccountJoinNSID.String(), + Rkey: sceneDID.String(), + }); err != nil { + return err + } + + return nil +} + +func (st *Store) UpsertSceneMember(ctx context.Context, sceneDID syntax.DID, memberDID syntax.DID, version syntax.CID, val json.RawMessage) error { + + // lexicon and data validation + obj, err := atdata.UnmarshalJSON(val) + if err != nil { + return fmt.Errorf("record data validation failed: %w", err) + } + if err := lexicon.ValidateRecord(st.cat, obj, SceneMemberNSID.String(), 0); err != nil { + return fmt.Errorf("scene member record lex validation failed: %w", err) + } + if obj["did"] != memberDID.String() { + return fmt.Errorf("member record scene mismatch") + } + + tx, err := st.conn.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return err + } + defer tx.Rollback(ctx) + q := sqlcdb.New(tx) + + // XXX: also a membership row? + + if err := q.UpsertRecord(ctx, sqlcdb.UpsertRecordParams{ + AccountDid: sceneDID.String(), + Collection: SceneMemberNSID.String(), + Rkey: memberDID.String(), + Version: version.String(), + DataJson: val, + }); err != nil { + return err + } + + return tx.Commit(ctx) +} + +func (st *Store) DeleteSceneMember(ctx context.Context, sceneDID syntax.DID, memberDID syntax.DID) error { + q := sqlcdb.New(st.conn) + + if err := q.DeleteRecord(ctx, sqlcdb.DeleteRecordParams{ + AccountDid: sceneDID.String(), + Collection: SceneMemberNSID.String(), + Rkey: memberDID.String(), + }); err != nil { + return err + } + + return nil +} diff --git a/store/scene.go b/store/scene.go new file mode 100644 index 0000000..71d61f4 --- /dev/null +++ b/store/scene.go @@ -0,0 +1,83 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + + "tangled.org/bnewbold.net/scrumble/store/sqlcdb" + + "github.com/bluesky-social/indigo/atproto/atdata" + "github.com/bluesky-social/indigo/atproto/lexicon" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/jackc/pgx/v5" + "tangled.org/bnewbold.net/scrumble/api/socialscrumble" +) + +var SceneDeclarationNSID = syntax.NSID("social.scrumble.beta.scene.declaration") + +func (st *Store) GetSceneDeclaration(ctx context.Context, did syntax.DID) (*socialscrumble.BetaSceneDeclaration, error) { + q := sqlcdb.New(st.conn) + row, err := q.GetRecord(ctx, sqlcdb.GetRecordParams{ + AccountDid: did.String(), + Collection: SceneDeclarationNSID.String(), + Rkey: "self", + }) + if err != nil { + return nil, err + } + var decl socialscrumble.BetaSceneDeclaration + if err := json.Unmarshal(row.DataJson, &decl); err != nil { + return nil, err + } + return &decl, nil +} + +func (st *Store) UpsertSceneDeclaration(ctx context.Context, did syntax.DID, version syntax.CID, val json.RawMessage) error { + + // lexicon and data validation + obj, err := atdata.UnmarshalJSON(val) + if err != nil { + return fmt.Errorf("record data validation failed: %w", err) + } + if err := lexicon.ValidateRecord(st.cat, obj, SceneDeclarationNSID.String(), 0); err != nil { + return fmt.Errorf("scene declaration record lex validation failed: %w", err) + } + + tx, err := st.conn.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return err + } + defer tx.Rollback(ctx) + q := sqlcdb.New(tx) + + // XXX: also a scene row? + + if err := q.UpsertRecord(ctx, sqlcdb.UpsertRecordParams{ + AccountDid: did.String(), + Collection: SceneDeclarationNSID.String(), + Rkey: "self", + Version: version.String(), + DataJson: val, + }); err != nil { + return err + } + + return tx.Commit(ctx) +} + +func (st *Store) DeleteSceneDeclaration(ctx context.Context, sceneDID syntax.DID) error { + q := sqlcdb.New(st.conn) + + // TODO: update scene row status? + + if err := q.DeleteRecord(ctx, sqlcdb.DeleteRecordParams{ + AccountDid: sceneDID.String(), + Collection: ItemNSID.String(), + Rkey: "self", + }); err != nil { + return err + } + + return nil +} diff --git a/store/schemas/query.sql b/store/schemas/query.sql index 08a18a9..ce0f8ea 100644 --- a/store/schemas/query.sql +++ b/store/schemas/query.sql @@ -52,6 +52,19 @@ WHERE ORDER BY item.created_at DESC LIMIT 30; +-- name: ListAccountItems :many +SELECT sqlc.embed(item), sqlc.embed(record) +FROM item +JOIN record ON + record.rkey = item.rkey AND + record.account_did = item.account_did AND + record.collection = 'social.scrumble.beta.collection.item' +WHERE + -- TODO: filter by account status (etc) + item.account_did = $1 +ORDER BY item.created_at DESC +LIMIT 30; + -- ### OAuth Queries ### -- name: GetOAuthSession :one diff --git a/store/sqlcdb/query.sql.go b/store/sqlcdb/query.sql.go index 624e90d..05fef3a 100644 --- a/store/sqlcdb/query.sql.go +++ b/store/sqlcdb/query.sql.go @@ -169,6 +169,56 @@ func (q *Queries) InsertOAuthAuthRequest(ctx context.Context, arg InsertOAuthAut return err } +const listAccountItems = `-- name: ListAccountItems :many +SELECT item.scene_did, item.account_did, item.rkey, item.version, item.created_at, record.collection, record.account_did, record.rkey, record.version, record.data_json +FROM item +JOIN record ON + record.rkey = item.rkey AND + record.account_did = item.account_did AND + record.collection = 'social.scrumble.beta.collection.item' +WHERE + -- TODO: filter by account status (etc) + item.account_did = $1 +ORDER BY item.created_at DESC +LIMIT 30 +` + +type ListAccountItemsRow struct { + Item Item + Record Record +} + +func (q *Queries) ListAccountItems(ctx context.Context, accountDid string) ([]ListAccountItemsRow, error) { + rows, err := q.db.Query(ctx, listAccountItems, accountDid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAccountItemsRow + for rows.Next() { + var i ListAccountItemsRow + if err := rows.Scan( + &i.Item.SceneDid, + &i.Item.AccountDid, + &i.Item.Rkey, + &i.Item.Version, + &i.Item.CreatedAt, + &i.Record.Collection, + &i.Record.AccountDid, + &i.Record.Rkey, + &i.Record.Version, + &i.Record.DataJson, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listSceneItems = `-- name: ListSceneItems :many SELECT item.scene_did, item.account_did, item.rkey, item.version, item.created_at, record.collection, record.account_did, record.rkey, record.version, record.data_json FROM item diff --git a/store/store.go b/store/store.go index 7a37335..5affb09 100644 --- a/store/store.go +++ b/store/store.go @@ -6,21 +6,23 @@ import ( "tangled.org/bnewbold.net/scrumble/store/sqlcdb" + "github.com/bluesky-social/indigo/atproto/lexicon" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/golang-migrate/migrate/v4" _ "github.com/golang-migrate/migrate/v4/database/postgres" _ "github.com/golang-migrate/migrate/v4/source/file" "github.com/jackc/pgx/v5" - "tangled.org/bnewbold.net/scrumble/api/socialscrumble" ) type Store struct { conn *pgx.Conn + cat lexicon.Catalog } -func NewStore(conn *pgx.Conn) (*Store, error) { +func NewStore(conn *pgx.Conn, cat lexicon.Catalog) (*Store, error) { return &Store{ conn: conn, + cat: cat, }, nil } @@ -51,27 +53,19 @@ func WipeDatabase(dburl string) error { return m.Drop() } -// this method assumes the did, handle, and status have already been parsed/cleaned -func (st *Store) UpsertIdentity(ctx context.Context, did syntax.DID, handle syntax.Handle, upstreamStatus string) error { - q := sqlcdb.New(st.conn) - return q.UpsertIdentity(ctx, sqlcdb.UpsertIdentityParams{ - Did: did.String(), - Handle: new(handle.String()), - UpstreamStatus: upstreamStatus, - }) -} - -func (st *Store) UpsertRecord(ctx context.Context, did syntax.DID, collection syntax.NSID, rkey syntax.RecordKey, version string, val json.RawMessage) error { +// low-level method for directly creating/updating any record type +func (st *Store) UpsertRecord(ctx context.Context, did syntax.DID, collection syntax.NSID, rkey syntax.RecordKey, version syntax.CID, val json.RawMessage) error { q := sqlcdb.New(st.conn) return q.UpsertRecord(ctx, sqlcdb.UpsertRecordParams{ AccountDid: did.String(), Collection: collection.String(), Rkey: rkey.String(), - Version: version, + Version: version.String(), DataJson: val, }) } +// low-level method for directly deleting any record type func (st *Store) DeleteRecord(ctx context.Context, did syntax.DID, collection syntax.NSID, rkey syntax.RecordKey) error { q := sqlcdb.New(st.conn) return q.DeleteRecord(ctx, sqlcdb.DeleteRecordParams{ @@ -81,84 +75,16 @@ func (st *Store) DeleteRecord(ctx context.Context, did syntax.DID, collection sy }) } -func (st *Store) UpsertItem(ctx context.Context, sceneDID syntax.DID, accountDID syntax.DID, rkey syntax.RecordKey, version string) error { - q := sqlcdb.New(st.conn) - return q.UpsertItem(ctx, sqlcdb.UpsertItemParams{ - SceneDid: sceneDID.String(), - AccountDid: accountDID.String(), - Rkey: rkey.String(), - Version: version, - }) -} - -func (st *Store) DeleteItem(ctx context.Context, accountDID syntax.DID, rkey syntax.RecordKey) error { +// low-level method for directly fetching a record +func (st *Store) GetRecord(ctx context.Context, did syntax.DID, collection syntax.NSID, rkey syntax.RecordKey) (*sqlcdb.Record, error) { q := sqlcdb.New(st.conn) - return q.DeleteItem(ctx, sqlcdb.DeleteItemParams{ - AccountDid: accountDID.String(), - Rkey: rkey.String(), - }) -} - -func (st *Store) GetSceneDeclaration(ctx context.Context, did syntax.DID) (*socialscrumble.BetaSceneDeclaration, error) { - q := sqlcdb.New(st.conn) - row, err := q.GetRecord(ctx, sqlcdb.GetRecordParams{ + record, err := q.GetRecord(ctx, sqlcdb.GetRecordParams{ AccountDid: did.String(), - Collection: "social.scrumble.beta.scene.declaration", - Rkey: "self", - }) - if err != nil { - return nil, err - } - var decl socialscrumble.BetaSceneDeclaration - if err := json.Unmarshal(row.DataJson, &decl); err != nil { - return nil, err - } - return &decl, nil -} - -func (st *Store) GetItem(ctx context.Context, did syntax.DID, rkey syntax.RecordKey) (*socialscrumble.BetaCollectionItem, error) { - q := sqlcdb.New(st.conn) - row, err := q.GetRecord(ctx, sqlcdb.GetRecordParams{ - AccountDid: did.String(), - Collection: "social.scrumble.beta.collection.item", + Collection: collection.String(), Rkey: rkey.String(), }) if err != nil { return nil, err } - var record socialscrumble.BetaCollectionItem - if err := json.Unmarshal(row.DataJson, &record); err != nil { - return nil, err - } return &record, nil } - -type SceneItem struct { - SceneDID syntax.DID - AccountDID syntax.DID - RKey syntax.RecordKey - Record socialscrumble.BetaCollectionItem -} - -func (st *Store) ListSceneItems(ctx context.Context, sceneDID syntax.DID) ([]SceneItem, error) { - q := sqlcdb.New(st.conn) - rows, err := q.ListSceneItems(ctx, sceneDID.String()) - if err != nil { - return nil, err - } - - items := make([]SceneItem, len(rows)) - for i, row := range rows { - var record socialscrumble.BetaCollectionItem - if err := json.Unmarshal(row.Record.DataJson, &record); err != nil { - return nil, err - } - items[i] = SceneItem{ - SceneDID: sceneDID, - AccountDID: syntax.DID(row.Record.AccountDid), - RKey: syntax.RecordKey(row.Record.Rkey), - Record: record, - } - } - return items, nil -} diff --git a/store/util.go b/store/util.go new file mode 100644 index 0000000..40ed2f1 --- /dev/null +++ b/store/util.go @@ -0,0 +1,33 @@ +package store + +import ( + "encoding/json" + + "github.com/bluesky-social/indigo/atproto/atdata" + "github.com/bluesky-social/indigo/atproto/syntax" + + "github.com/ipfs/go-cid" + "github.com/multiformats/go-multihash" +) + +// computes AT data model CID for arbitrary JSON. for DAG-CBOR CID type (eg, records) +func VersionForRecord(data json.RawMessage) (syntax.CID, error) { + // TODO: upstream this in to indigo + + obj, err := atdata.UnmarshalJSON(data) + if err != nil { + return "", err + } + + b, err := atdata.MarshalCBOR(obj) + if err != nil { + return "", err + } + + builder := cid.NewPrefixV1(cid.DagCBOR, multihash.SHA2_256) + c, err := builder.Sum(b) + if err != nil { + return "", err + } + return syntax.CID(c.String()), err +} diff --git a/templates/macros.html b/templates/macros.html index 979eefd..03b0f1e 100644 --- a/templates/macros.html +++ b/templates/macros.html @@ -6,7 +6,8 @@
{{ item.Record.Item.BetaCollectionItem_TextItem.Text }} {% endmacro %} diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..14b19aa --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block main_content %} +{% import "macros.html" item_view %} +
Account has not posted! +{% endfor %} +
@{{ ident.Handle }}
+