backend.go (10655B)
1 package tui 2 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 8 "git.ryansepassi.com/git/gotextlog.git/internal/config" 9 "git.ryansepassi.com/git/gotextlog.git/internal/sshkey" 10 "git.ryansepassi.com/git/gotextlog.git/internal/textlog" 11 ) 12 13 type OperationKind string 14 15 const ( 16 OpFeed OperationKind = "feed" 17 OpSearch OperationKind = "search" 18 OpThread OperationKind = "thread" 19 OpProfile OperationKind = "profile" 20 OpTag OperationKind = "tag" 21 OpMe OperationKind = "me" 22 OpRequestCode OperationKind = "request-code" 23 OpVerifyCode OperationKind = "verify-code" 24 OpListKeys OperationKind = "list-keys" 25 OpKeyLogin OperationKind = "key-login" 26 OpRevoke OperationKind = "revoke" 27 OpUpdateBio OperationKind = "update-bio" 28 OpCreatePost OperationKind = "create-post" 29 OpEditPost OperationKind = "edit-post" 30 OpDeletePost OperationKind = "delete-post" 31 OpFollow OperationKind = "follow" 32 OpBlock OperationKind = "block" 33 OpReport OperationKind = "report" 34 ) 35 36 type Operation struct { 37 Kind OperationKind 38 Feed string 39 ID int 40 ParentID *int 41 Handle, Tag, Query, Cursor, Text, Email, Code string 42 Limit, Page int 43 Enabled bool 44 Reason textlog.ReportReason 45 Key sshkey.Key 46 } 47 48 type ActivityEntry struct { 49 Post textlog.Post 50 Context string 51 Compact bool 52 } 53 54 type Result struct { 55 Posts []textlog.Post 56 Entries []ActivityEntry 57 Replies []textlog.Reply 58 Post *textlog.Post 59 Root *textlog.Post 60 Profile *textlog.Profile 61 Users []textlog.UserReference 62 Tags []textlog.TagReference 63 Followers []textlog.UserReference 64 Following []textlog.UserReference 65 FollowingTags []textlog.TagReference 66 Blocks []textlog.UserReference 67 Me *textlog.CurrentUser 68 Session *textlog.Session 69 Keys []sshkey.Key 70 NextCursor string 71 } 72 73 type Backend interface { 74 Execute(context.Context, Operation) (Result, error) 75 Firehose(context.Context) (<-chan textlog.Post, <-chan error) 76 } 77 78 type clientBackend struct { 79 client *textlog.Client 80 keySource sshkey.Source 81 keySourceErr error 82 } 83 84 func NewClientBackend(client *textlog.Client) Backend { 85 source, err := sshkey.NewDefaultSource() 86 return &clientBackend{client: client, keySource: source, keySourceErr: err} 87 } 88 89 func NewClientBackendWithKeySource(client *textlog.Client, source sshkey.Source) Backend { 90 return &clientBackend{client: client, keySource: source} 91 } 92 func (b *clientBackend) Configure(cfg config.Config) { 93 b.client.BaseURL = cfg.BaseURL 94 b.client.Token = cfg.Token 95 } 96 func (b *clientBackend) Firehose(ctx context.Context) (<-chan textlog.Post, <-chan error) { 97 return b.client.Firehose(ctx) 98 } 99 100 func cursorValue(cursor *string) string { 101 if cursor == nil { 102 return "" 103 } 104 return *cursor 105 } 106 107 func (b *clientBackend) Execute(ctx context.Context, op Operation) (Result, error) { 108 limit := op.Limit 109 if limit == 0 { 110 limit = 20 111 } 112 switch op.Kind { 113 case OpFeed: 114 switch op.Feed { 115 case "latest": 116 value, err := b.client.Latest(ctx, limit, op.Cursor) 117 return Result{Posts: value.Data, NextCursor: cursorValue(value.Pagination.NextCursor)}, err 118 case "hot": 119 value, err := b.client.Hot(ctx, limit, op.Cursor) 120 return Result{Posts: value.Data, NextCursor: cursorValue(value.Pagination.NextCursor)}, err 121 case "for-you", "to-me": 122 var value textlog.ActivityCollection 123 var err error 124 if op.Feed == "for-you" { 125 value, err = b.client.ForYou(ctx, limit, op.Cursor) 126 } else { 127 value, err = b.client.ToMe(ctx, limit, op.Cursor) 128 } 129 if err != nil { 130 return Result{}, err 131 } 132 return Result{Entries: activityEntries(value.Data, op.Page), NextCursor: cursorValue(value.Pagination.NextCursor)}, nil 133 default: 134 return Result{}, fmt.Errorf("unknown feed %q", op.Feed) 135 } 136 case OpSearch: 137 value, err := b.client.Search(ctx, op.Query, limit, op.Cursor) 138 return Result{Posts: value.Data, NextCursor: cursorValue(value.Pagination.NextCursor)}, err 139 case OpThread: 140 postEnvelope, err := b.client.Post(ctx, op.ID) 141 if err != nil { 142 return Result{}, err 143 } 144 focused := postEnvelope.Data 145 root := focused 146 seen := map[int]bool{focused.ID: true} 147 for root.ParentID != nil { 148 ancestorID := *root.ParentID 149 if root.TopID != nil { 150 ancestorID = *root.TopID 151 } 152 if seen[ancestorID] { 153 break 154 } 155 seen[ancestorID] = true 156 ancestor, ancestorErr := b.client.Post(ctx, ancestorID) 157 if ancestorErr != nil { 158 return Result{}, ancestorErr 159 } 160 root = ancestor.Data 161 } 162 163 var allReplies []textlog.Reply 164 cursor := "" 165 seenCursors := make(map[string]bool) 166 for { 167 replies, repliesErr := b.client.Replies(ctx, root.ID, 5, 100, cursor) 168 if repliesErr != nil { 169 return Result{}, repliesErr 170 } 171 allReplies = append(allReplies, replies.Data...) 172 if replies.Pagination.NextCursor == nil || *replies.Pagination.NextCursor == "" { 173 break 174 } 175 cursor = *replies.Pagination.NextCursor 176 if seenCursors[cursor] { 177 return Result{}, fmt.Errorf("replies pagination repeated cursor %q", cursor) 178 } 179 seenCursors[cursor] = true 180 } 181 return Result{Post: &focused, Root: &root, Replies: allReplies}, nil 182 case OpProfile: 183 profile, err := b.client.Profile(ctx, op.Handle) 184 if err != nil { 185 return Result{}, err 186 } 187 notes, err := b.client.UserNotes(ctx, op.Handle, 100, "") 188 if err != nil { 189 return Result{}, err 190 } 191 replies, err := b.client.UserReplies(ctx, op.Handle, 100, "") 192 if err != nil { 193 return Result{}, err 194 } 195 following, err := b.client.UserFollowing(ctx, op.Handle, 100, "") 196 if err != nil { 197 return Result{}, err 198 } 199 tags, err := b.client.UserFollowingTags(ctx, op.Handle, 100, "") 200 if err != nil { 201 return Result{}, err 202 } 203 followers, err := b.client.UserFollowers(ctx, op.Handle, 100, "") 204 if err != nil { 205 return Result{}, err 206 } 207 result := Result{Profile: &profile.Data, Posts: notes.Data, Replies: postsAsReplies(replies.Data), Following: following.Data, 208 FollowingTags: tags.Data, Followers: followers.Data} 209 if op.Enabled { 210 blocks, blockErr := b.client.UserBlocks(ctx, op.Handle, 100, "") 211 if blockErr == nil { 212 result.Blocks = blocks.Data 213 } 214 } 215 return result, nil 216 case OpTag: 217 details, err := b.client.Tag(ctx, op.Tag) 218 if err != nil { 219 return Result{}, err 220 } 221 posts, err := b.client.TagPosts(ctx, op.Tag, 100, "") 222 if err != nil { 223 return Result{}, err 224 } 225 followers, err := b.client.TagFollowers(ctx, op.Tag, 100, "") 226 if err != nil { 227 return Result{}, err 228 } 229 return Result{Tags: []textlog.TagReference{details.Data}, Posts: posts.Data, Followers: followers.Data}, nil 230 case OpMe: 231 value, err := b.client.Me(ctx) 232 return Result{Me: &value.Data}, err 233 case OpRequestCode: 234 _, err := b.client.RequestCode(ctx, op.Email) 235 return Result{}, err 236 case OpVerifyCode: 237 value, err := b.client.VerifyCode(ctx, op.Email, op.Code) 238 if err == nil { 239 b.client.Token = value.Data.Token 240 } 241 return Result{Session: &value.Data, Me: &value.Data.User}, err 242 case OpListKeys: 243 if b.keySourceErr != nil { 244 return Result{}, b.keySourceErr 245 } 246 if b.keySource == nil { 247 return Result{}, fmt.Errorf("SSH key source is unavailable") 248 } 249 keys, err := b.keySource.Keys(ctx) 250 return Result{Keys: keys}, err 251 case OpKeyLogin: 252 if b.keySourceErr != nil { 253 return Result{}, b.keySourceErr 254 } 255 if b.keySource == nil { 256 return Result{}, fmt.Errorf("SSH key source is unavailable") 257 } 258 challenge, err := b.client.RequestKeyChallenge(ctx, op.Key.PublicKey, op.Handle) 259 if err != nil { 260 return Result{}, err 261 } 262 signature, err := b.keySource.Sign(ctx, op.Key, challenge.Data.Message) 263 if err != nil { 264 return Result{}, err 265 } 266 value, err := b.client.VerifyKey(ctx, challenge.Data.ChallengeID, signature) 267 if err == nil { 268 b.client.Token = value.Data.Token 269 } 270 return Result{Session: &value.Data, Me: &value.Data.User}, err 271 case OpRevoke: 272 _, err := b.client.Revoke(ctx) 273 if err == nil { 274 b.client.Token = "" 275 } 276 return Result{}, err 277 case OpUpdateBio: 278 value, err := b.client.UpdateBio(ctx, op.Text) 279 return Result{Me: &value.Data}, err 280 case OpCreatePost: 281 value, err := b.client.CreatePost(ctx, op.Text, op.ParentID) 282 return Result{Post: &value.Data}, err 283 case OpEditPost: 284 value, err := b.client.EditPost(ctx, op.ID, op.Text) 285 return Result{Post: &value.Data}, err 286 case OpDeletePost: 287 _, err := b.client.DeletePost(ctx, op.ID) 288 return Result{}, err 289 case OpFollow: 290 _, err := b.client.Follow(ctx, op.Handle, op.Enabled) 291 return Result{}, err 292 case OpBlock: 293 _, err := b.client.Block(ctx, op.Handle, op.Enabled) 294 return Result{}, err 295 case OpReport: 296 _, err := b.client.Report(ctx, op.ID, op.Reason) 297 return Result{}, err 298 default: 299 return Result{}, fmt.Errorf("unknown operation %q", op.Kind) 300 } 301 } 302 303 func postsAsReplies(posts []textlog.Post) []textlog.Reply { 304 out := make([]textlog.Reply, len(posts)) 305 for i, post := range posts { 306 out[i] = textlog.Reply{Post: post} 307 } 308 return out 309 } 310 311 func activityEntries(activities []textlog.Activity, page int) []ActivityEntry { 312 entries := make([]ActivityEntry, 0, len(activities)) 313 for index, activity := range activities { 314 var post textlog.Post 315 if json.Unmarshal(activity.Payload, &post) == nil && post.ID != 0 { 316 contextText := "" 317 if activity.Type == "reply" { 318 contextText = "replied to you" 319 } 320 if activity.Type == "mention" { 321 contextText = "mentioned you" 322 } 323 entries = append(entries, ActivityEntry{Post: post, Context: contextText}) 324 continue 325 } 326 var payload struct { 327 Actor textlog.UserReference `json:"actor"` 328 Target struct { 329 Handle string `json:"handle"` 330 Tag string `json:"tag"` 331 } `json:"target"` 332 } 333 if json.Unmarshal(activity.Payload, &payload) != nil || payload.Actor.Handle == "" { 334 continue 335 } 336 contextText := "" 337 switch activity.Type { 338 case "user_follow": 339 contextText = "followed you" 340 if payload.Target.Handle != "" { 341 contextText = "followed @" + payload.Target.Handle 342 } 343 case "tag_follow": 344 contextText = "followed #" + payload.Target.Tag 345 case "signup": 346 contextText = "signed up" 347 } 348 if contextText == "" { 349 continue 350 } 351 entries = append(entries, ActivityEntry{Post: textlog.Post{ID: -(page*1000 + index + 1), CreatedAt: activity.CreatedAt, 352 Author: payload.Actor}, Context: contextText, Compact: true}) 353 } 354 return entries 355 }