A community based topic aggregation platform built on atproto
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}