gotextlog

gotextlog
git clone https://git.ryansepassi.com/git/gotextlog.git
Log | Files | Refs | README

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 }