diff --git a/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-defs.md b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-defs.md new file mode 100644 index 000000000..66910beba --- /dev/null +++ b/js/docs/src/content/docs/lex-reference/moderation/place-stream-moderation-defs.md @@ -0,0 +1,63 @@ +--- +title: place.stream.moderation.defs +description: Reference for the place.stream.moderation.defs lexicon +--- +**Lexicon Version:** 1 + +## Definitions + + +### `permissionView` + +**Type:** `object` + +**Properties:** + +| Name | Type | Req'd | Description | Constraints | +|------|------|----------|-------------|-------------| +| `uri` | `string` | ✅ | AT-URI of the permission record | Format: `at-uri` | +| `cid` | `string` | ✅ | Content identifier of the permission record | Format: `cid` | +| `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | ✅ | The streamer who granted these permissions | | +| `record` | `unknown` | ✅ | The permission record itself | | + +--- + +## Lexicon Source +```json +{ + "lexicon": 1, + "id": "place.stream.moderation.defs", + "defs": { + "permissionView": { + "type": "object", + "required": [ + "uri", + "cid", + "author", + "record" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the permission record" + }, + "cid": { + "type": "string", + "format": "cid", + "description": "Content identifier of the permission record" + }, + "author": { + "type": "ref", + "ref": "app.bsky.actor.defs#profileViewBasic", + "description": "The streamer who granted these permissions" + }, + "record": { + "type": "unknown", + "description": "The permission record itself" + } + } + } + } +} +``` diff --git a/lexicons/place/stream/moderation/defs.json b/lexicons/place/stream/moderation/defs.json new file mode 100644 index 000000000..16318710d --- /dev/null +++ b/lexicons/place/stream/moderation/defs.json @@ -0,0 +1,31 @@ +{ + "lexicon": 1, + "id": "place.stream.moderation.defs", + "defs": { + "permissionView": { + "type": "object", + "required": ["uri", "cid", "author", "record"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "AT-URI of the permission record" + }, + "cid": { + "type": "string", + "format": "cid", + "description": "Content identifier of the permission record" + }, + "author": { + "type": "ref", + "ref": "app.bsky.actor.defs#profileViewBasic", + "description": "The streamer who granted these permissions" + }, + "record": { + "type": "unknown", + "description": "The permission record itself" + } + } + } + } +} diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go index 17a35edf5..8e493b95f 100644 --- a/pkg/atproto/sync.go +++ b/pkg/atproto/sync.go @@ -2,7 +2,6 @@ package atproto import ( "context" - "encoding/json" "errors" "fmt" "reflect" @@ -431,43 +430,23 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD } log.Debug(ctx, "creating moderation delegation", "streamerDID", userDID, "moderatorDID", rec.Moderator) - permissionsJSON, err := json.Marshal(rec.Permissions) - if err != nil { - return fmt.Errorf("failed to marshal permissions: %w", err) - } - - // Parse optional expiration time - var expirationTime *time.Time - if rec.ExpirationTime != nil { - t, err := time.Parse(time.RFC3339, *rec.ExpirationTime) - if err != nil { - log.Warn(ctx, "failed to parse expiration time", "value", *rec.ExpirationTime, "err", err) - } else { - expirationTime = &t - } - } - - delegation := &model.ModerationDelegation{ - RKey: rkey.String(), - CID: cid, - RepoDID: userDID, - Repo: repo, - ModeratorDID: rec.Moderator, - Permissions: permissionsJSON, - ExpirationTime: expirationTime, - Record: *recCBOR, - CreatedAt: now, - IndexedAt: now, - } - - err = atsync.Model.CreateModerationDelegation(ctx, delegation) + err = atsync.Model.CreateModerationDelegation(ctx, rec, aturi) if err != nil { return fmt.Errorf("failed to create moderation delegation: %w", err) } - // Publish moderation permission record to WebSocket bus for real-time updates + view := &streamplace.ModerationDefs_PermissionView{ + Uri: aturi.String(), + Cid: cid, + Author: &bsky.ActorDefs_ProfileViewBasic{ + Did: userDID, + Handle: repo.Handle, + }, + Record: &lexutil.LexiconTypeDecoder{Val: rec}, + } + // Publish moderation permission view to WebSocket bus for real-time updates // This allows moderators to see their permissions instantly without page refresh - go atsync.Bus.Publish(userDID, rec) + go atsync.Bus.Publish(userDID, view) default: log.Debug(ctx, "unhandled record type", "type", reflect.TypeOf(rec)) diff --git a/pkg/model/model.go b/pkg/model/model.go index dc652ec6b..4dfd831ff 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -108,12 +108,12 @@ type Model interface { GetMetadataConfiguration(ctx context.Context, repoDID string) (*MetadataConfiguration, error) DeleteMetadataConfiguration(ctx context.Context, repoDID string) error - CreateModerationDelegation(ctx context.Context, delegation *ModerationDelegation) error + CreateModerationDelegation(ctx context.Context, rec *streamplace.ModerationPermission, aturi syntax.ATURI) error DeleteModerationDelegation(ctx context.Context, rkey string) error - GetModerationDelegation(ctx context.Context, streamerDID, moderatorDID string) (*ModerationDelegation, error) - GetModerationDelegations(ctx context.Context, streamerDID, moderatorDID string) ([]*ModerationDelegation, error) - GetModeratorDelegations(ctx context.Context, moderatorDID string) ([]*ModerationDelegation, error) - GetStreamerModerators(ctx context.Context, streamerDID string) ([]*ModerationDelegation, error) + GetModerationDelegation(ctx context.Context, streamerDID, moderatorDID string) (*streamplace.ModerationDefs_PermissionView, error) + GetModerationDelegations(ctx context.Context, streamerDID, moderatorDID string) ([]*streamplace.ModerationDefs_PermissionView, error) + GetModeratorDelegations(ctx context.Context, moderatorDID string) ([]*streamplace.ModerationDefs_PermissionView, error) + GetStreamerModerators(ctx context.Context, streamerDID string) ([]*streamplace.ModerationDefs_PermissionView, error) CreateAuditLog(ctx context.Context, log *ModerationAuditLog) error GetAuditLogs(ctx context.Context, streamerDID string, limit int, before *time.Time) ([]*ModerationAuditLog, error) diff --git a/pkg/model/moderation_delegation.go b/pkg/model/moderation_delegation.go index 6316a1360..b0eb2fa5e 100644 --- a/pkg/model/moderation_delegation.go +++ b/pkg/model/moderation_delegation.go @@ -1,27 +1,85 @@ package model import ( + "bytes" "context" "errors" + "fmt" "time" + "github.com/bluesky-social/indigo/api/bsky" + "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" "gorm.io/gorm" + "stream.place/streamplace/pkg/aqtime" + "stream.place/streamplace/pkg/spid" + "stream.place/streamplace/pkg/streamplace" ) type ModerationDelegation struct { - RKey string `gorm:"primaryKey;column:rkey"` - CID string `gorm:"column:cid"` - RepoDID string `json:"repoDID" gorm:"column:repo_did;index:idx_repo_moderator,priority:1"` - Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"` - ModeratorDID string `gorm:"column:moderator_did;index:idx_repo_moderator,priority:2;index:idx_moderator"` - Permissions []byte `gorm:"column:permissions"` // JSON array stored as bytes - ExpirationTime *time.Time `gorm:"column:expiration_time"` // Optional expiration timestamp - Record []byte `gorm:"column:record"` // Full CBOR record - CreatedAt time.Time `gorm:"column:created_at"` - IndexedAt time.Time `gorm:"column:indexed_at"` + RKey string `gorm:"primaryKey;column:rkey"` + CID string `gorm:"column:cid"` + RepoDID string `json:"repoDID" gorm:"column:repo_did;index:idx_repo_moderator,priority:1"` + Repo *Repo `json:"repo,omitempty" gorm:"foreignKey:DID;references:RepoDID"` + ModeratorDID string `gorm:"column:moderator_did;index:idx_repo_moderator,priority:2;index:idx_moderator"` + Record []byte `gorm:"column:record"` // Full CBOR record + CreatedAt time.Time `gorm:"column:created_at"` + IndexedAt time.Time `gorm:"column:indexed_at"` } -func (m *DBModel) CreateModerationDelegation(ctx context.Context, delegation *ModerationDelegation) error { +func (md *ModerationDelegation) ToPermissionView() (*streamplace.ModerationDefs_PermissionView, error) { + rec, err := lexutil.CborDecodeValue(md.Record) + if err != nil { + return nil, fmt.Errorf("error decoding moderation permission: %w", err) + } + + uri := fmt.Sprintf("at://%s/place.stream.moderation.permission/%s", md.RepoDID, md.RKey) + + view := &streamplace.ModerationDefs_PermissionView{ + Author: &bsky.ActorDefs_ProfileViewBasic{ + Did: md.RepoDID, + }, + Cid: md.CID, + Record: &lexutil.LexiconTypeDecoder{Val: rec}, + Uri: uri, + } + + if md.Repo != nil { + view.Author.Handle = md.Repo.Handle + } + + return view, nil +} + +func (m *DBModel) CreateModerationDelegation(ctx context.Context, rec *streamplace.ModerationPermission, aturi syntax.ATURI) error { + repoDID, err := aturi.Authority().AsDID() + if err != nil { + return fmt.Errorf("invalid ATURI authority: %w", err) + } + cid, err := spid.GetCID(rec) + if err != nil { + return fmt.Errorf("failed to get CID: %w", err) + } + rkey := aturi.RecordKey().String() + + buf := bytes.Buffer{} + err = rec.MarshalCBOR(&buf) + if err != nil { + return fmt.Errorf("failed to marshal moderation permission: %w", err) + } + + now := aqtime.FromTime(time.Now().UTC()) + + delegation := &ModerationDelegation{ + RKey: rkey, + CID: cid.String(), + RepoDID: repoDID.String(), + ModeratorDID: rec.Moderator, + Record: buf.Bytes(), + CreatedAt: now.Time().UTC(), + IndexedAt: now.Time().UTC(), + } + return m.DB.WithContext(ctx).Create(delegation).Error } @@ -29,7 +87,7 @@ func (m *DBModel) DeleteModerationDelegation(ctx context.Context, rkey string) e return m.DB.WithContext(ctx).Where("rkey = ?", rkey).Delete(&ModerationDelegation{}).Error } -func (m *DBModel) GetModerationDelegation(ctx context.Context, streamerDID, moderatorDID string) (*ModerationDelegation, error) { +func (m *DBModel) GetModerationDelegation(ctx context.Context, streamerDID, moderatorDID string) (*streamplace.ModerationDefs_PermissionView, error) { var delegation ModerationDelegation err := m.DB.WithContext(ctx).Preload("Repo"). Where("repo_did = ? AND moderator_did = ?", streamerDID, moderatorDID). @@ -41,12 +99,12 @@ func (m *DBModel) GetModerationDelegation(ctx context.Context, streamerDID, mode if err != nil { return nil, err } - return &delegation, nil + return delegation.ToPermissionView() } // GetModerationDelegations returns ALL delegation records for a moderator from a specific streamer. // This allows multiple separate permission records (e.g., one for "ban", one for "hide") to be merged. -func (m *DBModel) GetModerationDelegations(ctx context.Context, streamerDID, moderatorDID string) ([]*ModerationDelegation, error) { +func (m *DBModel) GetModerationDelegations(ctx context.Context, streamerDID, moderatorDID string) ([]*streamplace.ModerationDefs_PermissionView, error) { var delegations []*ModerationDelegation err := m.DB.WithContext(ctx).Preload("Repo"). Where("repo_did = ? AND moderator_did = ?", streamerDID, moderatorDID). @@ -54,10 +112,19 @@ func (m *DBModel) GetModerationDelegations(ctx context.Context, streamerDID, mod if err != nil { return nil, err } - return delegations, nil + + views := make([]*streamplace.ModerationDefs_PermissionView, len(delegations)) + for i, d := range delegations { + view, err := d.ToPermissionView() + if err != nil { + return nil, err + } + views[i] = view + } + return views, nil } -func (m *DBModel) GetModeratorDelegations(ctx context.Context, moderatorDID string) ([]*ModerationDelegation, error) { +func (m *DBModel) GetModeratorDelegations(ctx context.Context, moderatorDID string) ([]*streamplace.ModerationDefs_PermissionView, error) { var delegations []*ModerationDelegation err := m.DB.WithContext(ctx).Preload("Repo"). Where("moderator_did = ?", moderatorDID). @@ -65,10 +132,19 @@ func (m *DBModel) GetModeratorDelegations(ctx context.Context, moderatorDID stri if err != nil { return nil, err } - return delegations, nil + + views := make([]*streamplace.ModerationDefs_PermissionView, len(delegations)) + for i, d := range delegations { + view, err := d.ToPermissionView() + if err != nil { + return nil, err + } + views[i] = view + } + return views, nil } -func (m *DBModel) GetStreamerModerators(ctx context.Context, streamerDID string) ([]*ModerationDelegation, error) { +func (m *DBModel) GetStreamerModerators(ctx context.Context, streamerDID string) ([]*streamplace.ModerationDefs_PermissionView, error) { var delegations []*ModerationDelegation err := m.DB.WithContext(ctx).Preload("Repo"). Where("repo_did = ?", streamerDID). @@ -76,5 +152,14 @@ func (m *DBModel) GetStreamerModerators(ctx context.Context, streamerDID string) if err != nil { return nil, err } - return delegations, nil + + views := make([]*streamplace.ModerationDefs_PermissionView, len(delegations)) + for i, d := range delegations { + view, err := d.ToPermissionView() + if err != nil { + return nil, err + } + views[i] = view + } + return views, nil } diff --git a/pkg/moderation/permissions.go b/pkg/moderation/permissions.go index 504d866c3..64e2fe56f 100644 --- a/pkg/moderation/permissions.go +++ b/pkg/moderation/permissions.go @@ -2,11 +2,11 @@ package moderation import ( "context" - "encoding/json" "fmt" "time" "stream.place/streamplace/pkg/model" + "stream.place/streamplace/pkg/streamplace" ) // Permission scope constants @@ -81,20 +81,26 @@ func (pc *PermissionChecker) HasPermission(ctx context.Context, moderatorDID, st } // Check all delegation records and merge their permissions - for _, delegation := range delegations { - // Skip expired delegations - if delegation.ExpirationTime != nil && time.Now().After(*delegation.ExpirationTime) { - continue + for _, delegationView := range delegations { + // Extract the actual permission record from the view + permRecord, ok := delegationView.Record.Val.(*streamplace.ModerationPermission) + if !ok { + return false, fmt.Errorf("failed to cast record to ModerationPermission") } - // Parse permissions JSON array - var permissions []string - if err := json.Unmarshal(delegation.Permissions, &permissions); err != nil { - return false, fmt.Errorf("failed to unmarshal permissions: %w", err) + // Skip expired delegations + if permRecord.ExpirationTime != nil { + expirationTime, err := time.Parse(time.RFC3339, *permRecord.ExpirationTime) + if err != nil { + return false, fmt.Errorf("failed to parse expiration time: %w", err) + } + if time.Now().After(expirationTime) { + continue + } } // Check if this delegation has the required permission - for _, p := range permissions { + for _, p := range permRecord.Permissions { if p == permission { return true, nil } diff --git a/pkg/streamplace/moderationdefs.go b/pkg/streamplace/moderationdefs.go new file mode 100644 index 000000000..4790d8ae2 --- /dev/null +++ b/pkg/streamplace/moderationdefs.go @@ -0,0 +1,22 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +// Lexicon schema: place.stream.moderation.defs + +package streamplace + +import ( + appbsky "github.com/bluesky-social/indigo/api/bsky" + lexutil "github.com/bluesky-social/indigo/lex/util" +) + +// ModerationDefs_PermissionView is a "permissionView" in the place.stream.moderation.defs schema. +type ModerationDefs_PermissionView struct { + // author: The streamer who granted these permissions + Author *appbsky.ActorDefs_ProfileViewBasic `json:"author" cborgen:"author"` + // cid: Content identifier of the permission record + Cid string `json:"cid" cborgen:"cid"` + // record: The permission record itself + Record *lexutil.LexiconTypeDecoder `json:"record" cborgen:"record"` + // uri: AT-URI of the permission record + Uri string `json:"uri" cborgen:"uri"` +}