A community based topic aggregation platform built on atproto
at main 338 lines 17 kB view raw
1package communities 2 3import ( 4 "fmt" 5 "log" 6 "strings" 7 "sync" 8 "time" 9 10 "Coves/internal/core/blobs" 11) 12 13// imageProxyConfigOnce ensures thread-safe initialization of the image proxy config. 14var imageProxyConfigOnce sync.Once 15 16// imageProxyConfig holds the immutable configuration after initialization. 17// Access only through GetImageProxyConfig(). 18var imageProxyConfig = blobs.ImageURLConfig{ 19 ProxyEnabled: false, // Default to disabled until configured 20} 21 22// imageProxyConfigInitialized tracks whether SetImageProxyConfig has been called. 23var imageProxyConfigInitialized bool 24 25// SetImageProxyConfig initializes the image proxy configuration. 26// This should be called once during server startup. Subsequent calls are no-ops 27// and will log a warning. This design ensures thread-safety and prevents 28// accidental config changes during runtime. 29func SetImageProxyConfig(config blobs.ImageURLConfig) { 30 imageProxyConfigOnce.Do(func() { 31 imageProxyConfig = config 32 imageProxyConfigInitialized = true 33 }) 34 // Log warning if called multiple times (indicates a programming error) 35 if imageProxyConfigInitialized && config != imageProxyConfig { 36 log.Printf("WARN: SetImageProxyConfig called multiple times with different config (ignored)") 37 } 38} 39 40// GetImageProxyConfig returns the current image proxy configuration. 41// Thread-safe for concurrent access. 42func GetImageProxyConfig() blobs.ImageURLConfig { 43 return imageProxyConfig 44} 45 46// ResetImageProxyConfigForTesting resets the config state for testing purposes. 47// This should ONLY be used in tests, never in production code. 48func ResetImageProxyConfigForTesting() { 49 imageProxyConfigOnce = sync.Once{} 50 imageProxyConfig = blobs.ImageURLConfig{ProxyEnabled: false} 51 imageProxyConfigInitialized = false 52} 53 54// Community represents a Coves community indexed from the firehose 55// Communities are federated, instance-scoped forums built on atProto 56type Community struct { 57 CreatedAt time.Time `json:"createdAt" db:"created_at"` 58 UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` 59 RecordURI string `json:"recordUri,omitempty" db:"record_uri"` 60 FederatedFrom string `json:"federatedFrom,omitempty" db:"federated_from"` 61 DisplayName string `json:"displayName" db:"display_name"` 62 Description string `json:"description" db:"description"` 63 PDSURL string `json:"-" db:"pds_url"` 64 AvatarCID string `json:"avatarCid,omitempty" db:"avatar_cid"` 65 BannerCID string `json:"bannerCid,omitempty" db:"banner_cid"` 66 OwnerDID string `json:"ownerDid" db:"owner_did"` 67 CreatedByDID string `json:"createdByDid" db:"created_by_did"` 68 HostedByDID string `json:"hostedByDid" db:"hosted_by_did"` 69 PDSEmail string `json:"-" db:"pds_email"` 70 PDSPassword string `json:"-" db:"pds_password_encrypted"` 71 Name string `json:"name" db:"name"` // Short name (e.g., "gardening") 72 DisplayHandle string `json:"displayHandle,omitempty" db:"-"` // UI hint: !gardening@coves.social (computed, not stored) 73 RecordCID string `json:"recordCid,omitempty" db:"record_cid"` 74 FederatedID string `json:"federatedId,omitempty" db:"federated_id"` 75 PDSAccessToken string `json:"-" db:"pds_access_token"` 76 SigningKeyPEM string `json:"-" db:"signing_key_encrypted"` 77 ModerationType string `json:"moderationType,omitempty" db:"moderation_type"` 78 Handle string `json:"handle" db:"handle"` // Canonical atProto handle (e.g., gardening.community.coves.social) 79 PDSRefreshToken string `json:"-" db:"pds_refresh_token"` 80 Visibility string `json:"visibility" db:"visibility"` 81 RotationKeyPEM string `json:"-" db:"rotation_key_encrypted"` 82 DID string `json:"did" db:"did"` 83 ContentWarnings []string `json:"contentWarnings,omitempty" db:"content_warnings"` 84 DescriptionFacets []byte `json:"descriptionFacets,omitempty" db:"description_facets"` 85 PostCount int `json:"postCount" db:"post_count"` 86 SubscriberCount int `json:"subscriberCount" db:"subscriber_count"` 87 MemberCount int `json:"memberCount" db:"member_count"` 88 ID int `json:"id" db:"id"` 89 AllowExternalDiscovery bool `json:"allowExternalDiscovery" db:"allow_external_discovery"` 90 Viewer *CommunityViewerState `json:"viewer,omitempty" db:"-"` 91} 92 93// CommunityViewerState contains viewer-specific state for community list views. 94// This is a simplified version - detailed views use the full viewerState from lexicon. 95// 96// Fields use *bool to represent three states: 97// - nil: State not queried (unauthenticated request) 98// - true: User has this relationship 99// - false: User does not have this relationship 100type CommunityViewerState struct { 101 Subscribed *bool `json:"subscribed,omitempty"` 102 Member *bool `json:"member,omitempty"` 103} 104 105// CommunityView is the API view for community lists 106// Based on social.coves.community.defs#communityView lexicon 107type CommunityView struct { 108 DID string `json:"did"` 109 Handle string `json:"handle,omitempty"` 110 Name string `json:"name"` 111 DisplayName string `json:"displayName,omitempty"` 112 DisplayHandle string `json:"displayHandle,omitempty"` 113 Avatar string `json:"avatar,omitempty"` // URL, not CID 114 Visibility string `json:"visibility,omitempty"` 115 SubscriberCount int `json:"subscriberCount"` 116 MemberCount int `json:"memberCount"` 117 PostCount int `json:"postCount"` 118 Viewer *CommunityViewerState `json:"viewer,omitempty"` 119} 120 121// CommunityViewDetailed is the full API view for single community lookups 122// Based on social.coves.community.defs#communityViewDetailed lexicon 123type CommunityViewDetailed struct { 124 DID string `json:"did"` 125 Handle string `json:"handle,omitempty"` 126 Name string `json:"name"` 127 DisplayName string `json:"displayName,omitempty"` 128 DisplayHandle string `json:"displayHandle,omitempty"` 129 Description string `json:"description,omitempty"` 130 Avatar string `json:"avatar,omitempty"` // URL 131 Banner string `json:"banner,omitempty"` // URL 132 CreatedByDID string `json:"createdBy,omitempty"` 133 HostedByDID string `json:"hostedBy,omitempty"` 134 Visibility string `json:"visibility,omitempty"` 135 ModerationType string `json:"moderationType,omitempty"` 136 ContentWarnings []string `json:"contentWarnings,omitempty"` 137 CreatedAt time.Time `json:"createdAt"` 138 AllowExternalDiscovery bool `json:"allowExternalDiscovery"` 139 SubscriberCount int `json:"subscriberCount"` 140 MemberCount int `json:"memberCount"` 141 PostCount int `json:"postCount"` 142 Viewer *CommunityViewerState `json:"viewer,omitempty"` 143} 144 145// Subscription represents a lightweight feed follow (user subscribes to see posts) 146type Subscription struct { 147 SubscribedAt time.Time `json:"subscribedAt" db:"subscribed_at"` 148 UserDID string `json:"userDid" db:"user_did"` 149 CommunityDID string `json:"communityDid" db:"community_did"` 150 RecordURI string `json:"recordUri,omitempty" db:"record_uri"` 151 RecordCID string `json:"recordCid,omitempty" db:"record_cid"` 152 ContentVisibility int `json:"contentVisibility" db:"content_visibility"` // Feed slider: 1-5 (1=best content only, 5=all content) 153 ID int `json:"id" db:"id"` 154} 155 156// CommunityBlock represents a user blocking a community 157// Block records live in the user's repository (at://user_did/social.coves.community.block/{rkey}) 158type CommunityBlock struct { 159 BlockedAt time.Time `json:"blockedAt" db:"blocked_at"` 160 UserDID string `json:"userDid" db:"user_did"` 161 CommunityDID string `json:"communityDid" db:"community_did"` 162 RecordURI string `json:"recordUri,omitempty" db:"record_uri"` 163 RecordCID string `json:"recordCid,omitempty" db:"record_cid"` 164 ID int `json:"id" db:"id"` 165} 166 167// Membership represents active participation with reputation tracking 168type Membership struct { 169 JoinedAt time.Time `json:"joinedAt" db:"joined_at"` 170 LastActiveAt time.Time `json:"lastActiveAt" db:"last_active_at"` 171 UserDID string `json:"userDid" db:"user_did"` 172 CommunityDID string `json:"communityDid" db:"community_did"` 173 ID int `json:"id" db:"id"` 174 ReputationScore int `json:"reputationScore" db:"reputation_score"` 175 ContributionCount int `json:"contributionCount" db:"contribution_count"` 176 IsBanned bool `json:"isBanned" db:"is_banned"` 177 IsModerator bool `json:"isModerator" db:"is_moderator"` 178} 179 180// ModerationAction represents a moderation action taken against a community 181type ModerationAction struct { 182 CreatedAt time.Time `json:"createdAt" db:"created_at"` 183 ExpiresAt *time.Time `json:"expiresAt,omitempty" db:"expires_at"` 184 CommunityDID string `json:"communityDid" db:"community_did"` 185 Action string `json:"action" db:"action"` 186 Reason string `json:"reason,omitempty" db:"reason"` 187 InstanceDID string `json:"instanceDid" db:"instance_did"` 188 ID int `json:"id" db:"id"` 189 Broadcast bool `json:"broadcast" db:"broadcast"` 190} 191 192// CreateCommunityRequest represents input for creating a new community 193type CreateCommunityRequest struct { 194 Name string `json:"name"` 195 DisplayName string `json:"displayName,omitempty"` 196 Description string `json:"description"` 197 Language string `json:"language,omitempty"` 198 Visibility string `json:"visibility"` 199 CreatedByDID string `json:"createdByDid"` 200 HostedByDID string `json:"hostedByDid"` 201 AvatarBlob []byte `json:"avatarBlob,omitempty"` 202 BannerBlob []byte `json:"bannerBlob,omitempty"` 203 AvatarMimeType string `json:"avatarMimeType,omitempty"` 204 BannerMimeType string `json:"bannerMimeType,omitempty"` 205 Rules []string `json:"rules,omitempty"` 206 Categories []string `json:"categories,omitempty"` 207 AllowExternalDiscovery bool `json:"allowExternalDiscovery"` 208} 209 210// UpdateCommunityRequest represents input for updating community metadata 211type UpdateCommunityRequest struct { 212 CommunityDID string `json:"communityDid"` 213 UpdatedByDID string `json:"updatedByDid"` // User making the update (for authorization) 214 DisplayName *string `json:"displayName,omitempty"` 215 Description *string `json:"description,omitempty"` 216 AvatarBlob []byte `json:"avatarBlob,omitempty"` 217 BannerBlob []byte `json:"bannerBlob,omitempty"` 218 AvatarMimeType string `json:"avatarMimeType,omitempty"` 219 BannerMimeType string `json:"bannerMimeType,omitempty"` 220 Visibility *string `json:"visibility,omitempty"` 221 AllowExternalDiscovery *bool `json:"allowExternalDiscovery,omitempty"` 222 ModerationType *string `json:"moderationType,omitempty"` 223 ContentWarnings []string `json:"contentWarnings,omitempty"` 224} 225 226// ListCommunitiesRequest represents query parameters for listing communities 227type ListCommunitiesRequest struct { 228 Sort string `json:"sort,omitempty"` // Enum: popular, active, new, alphabetical 229 Visibility string `json:"visibility,omitempty"` // Filter: public, unlisted, private 230 Category string `json:"category,omitempty"` // Optional: filter by category (future) 231 Language string `json:"language,omitempty"` // Optional: filter by language (future) 232 SubscriberDID string `json:"subscriberDid,omitempty"` // If set, filter to only subscribed communities 233 Limit int `json:"limit"` // 1-100, default 50 234 Offset int `json:"offset"` // Pagination offset 235} 236 237// SearchCommunitiesRequest represents query parameters for searching communities 238type SearchCommunitiesRequest struct { 239 Query string `json:"query"` 240 Visibility string `json:"visibility,omitempty"` 241 Limit int `json:"limit"` 242 Offset int `json:"offset"` 243} 244 245// GetDisplayHandle returns the user-facing display format for a community handle 246// Following Bluesky's pattern where client adds @ prefix for users, but for communities we use ! prefix 247// Example: "c-gardening.coves.social" -> "!gardening@coves.social" 248// 249// Handles various domain formats correctly: 250// - "c-gaming.coves.social" -> "!gaming@coves.social" 251// - "c-gaming.coves.co.uk" -> "!gaming@coves.co.uk" 252// - "c-test.dev.coves.social" -> "!test@dev.coves.social" 253func (c *Community) GetDisplayHandle() string { 254 // Handle format: c-{name}.{instance} 255 if !strings.HasPrefix(c.Handle, "c-") { 256 log.Printf("DEBUG: GetDisplayHandle: handle %q missing c- prefix, returning raw handle", c.Handle) 257 return c.Handle // Fallback for invalid format 258 } 259 260 // Remove "c-" prefix and find first dot 261 afterPrefix := c.Handle[2:] 262 dotIndex := strings.Index(afterPrefix, ".") 263 if dotIndex == -1 { 264 log.Printf("DEBUG: GetDisplayHandle: handle %q has no dot after c- prefix, returning raw handle", c.Handle) 265 return c.Handle 266 } 267 268 // Edge case: "c-." would result in empty name 269 if dotIndex == 0 { 270 log.Printf("DEBUG: GetDisplayHandle: handle %q has empty name after c- prefix, returning raw handle", c.Handle) 271 return c.Handle 272 } 273 274 name := afterPrefix[:dotIndex] 275 instanceDomain := afterPrefix[dotIndex+1:] 276 277 return fmt.Sprintf("!%s@%s", name, instanceDomain) 278} 279 280// GetPDSURL implements blobs.BlobOwner interface. 281// Returns the community's PDS URL for blob uploads. 282func (c *Community) GetPDSURL() string { 283 return c.PDSURL 284} 285 286// GetPDSAccessToken implements blobs.BlobOwner interface. 287// Returns the community's PDS access token for blob upload authentication. 288func (c *Community) GetPDSAccessToken() string { 289 return c.PDSAccessToken 290} 291 292// ToCommunityView converts a Community to a CommunityView for API responses 293// Uses avatar_small preset (24px) for list views 294func (c *Community) ToCommunityView() *CommunityView { 295 view := &CommunityView{ 296 DID: c.DID, 297 Handle: c.Handle, 298 Name: c.Name, 299 DisplayName: c.DisplayName, 300 DisplayHandle: c.GetDisplayHandle(), 301 Avatar: blobs.HydrateImageURL(GetImageProxyConfig(), c.PDSURL, c.DID, c.AvatarCID, "avatar_small"), 302 Visibility: c.Visibility, 303 SubscriberCount: c.SubscriberCount, 304 MemberCount: c.MemberCount, 305 PostCount: c.PostCount, 306 Viewer: c.Viewer, 307 } 308 309 return view 310} 311 312// ToCommunityViewDetailed converts a Community to a CommunityViewDetailed for API responses 313// Uses avatar preset (80px) for detail views and banner preset for banners 314func (c *Community) ToCommunityViewDetailed() *CommunityViewDetailed { 315 view := &CommunityViewDetailed{ 316 DID: c.DID, 317 Handle: c.Handle, 318 Name: c.Name, 319 DisplayName: c.DisplayName, 320 DisplayHandle: c.GetDisplayHandle(), 321 Description: c.Description, 322 Avatar: blobs.HydrateImageURL(GetImageProxyConfig(), c.PDSURL, c.DID, c.AvatarCID, "avatar"), 323 Banner: blobs.HydrateImageURL(GetImageProxyConfig(), c.PDSURL, c.DID, c.BannerCID, "banner"), 324 CreatedByDID: c.CreatedByDID, 325 HostedByDID: c.HostedByDID, 326 Visibility: c.Visibility, 327 ModerationType: c.ModerationType, 328 ContentWarnings: c.ContentWarnings, 329 CreatedAt: c.CreatedAt, 330 AllowExternalDiscovery: c.AllowExternalDiscovery, 331 SubscriberCount: c.SubscriberCount, 332 MemberCount: c.MemberCount, 333 PostCount: c.PostCount, 334 Viewer: c.Viewer, 335 } 336 337 return view 338}