Add user page and follow/unfollow calls
[bloat] / service / service.go
1 package service
2
3 import (
4         "bytes"
5         "context"
6         "encoding/json"
7         "errors"
8         "io"
9         "mime/multipart"
10         "net/http"
11         "net/url"
12         "strings"
13
14         "mastodon"
15         "web/model"
16         "web/renderer"
17         "web/util"
18 )
19
20 var (
21         ErrInvalidArgument = errors.New("invalid argument")
22         ErrInvalidToken    = errors.New("invalid token")
23         ErrInvalidClient   = errors.New("invalid client")
24 )
25
26 type Service interface {
27         ServeHomePage(ctx context.Context, client io.Writer) (err error)
28         GetAuthUrl(ctx context.Context, instance string) (url string, sessionID string, err error)
29         GetUserToken(ctx context.Context, sessionID string, c *mastodon.Client, token string) (accessToken string, err error)
30         ServeErrorPage(ctx context.Context, client io.Writer, err error)
31         ServeSigninPage(ctx context.Context, client io.Writer) (err error)
32         ServeTimelinePage(ctx context.Context, client io.Writer, c *mastodon.Client, maxID string, sinceID string, minID string) (err error)
33         ServeThreadPage(ctx context.Context, client io.Writer, c *mastodon.Client, id string, reply bool) (err error)
34         ServeNotificationPage(ctx context.Context, client io.Writer, c *mastodon.Client, maxID string, minID string) (err error)
35         ServeUserPage(ctx context.Context, client io.Writer, c *mastodon.Client, id string, maxID string, minID string) (err error)
36         Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error)
37         UnLike(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error)
38         Retweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error)
39         UnRetweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error)
40         PostTweet(ctx context.Context, client io.Writer, c *mastodon.Client, content string, replyToID string, files []*multipart.FileHeader) (id string, err error)
41         Follow(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error)
42         UnFollow(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error)
43 }
44
45 type service struct {
46         clientName    string
47         clientScope   string
48         clientWebsite string
49         renderer      renderer.Renderer
50         sessionRepo   model.SessionRepository
51         appRepo       model.AppRepository
52 }
53
54 func NewService(clientName string, clientScope string, clientWebsite string,
55         renderer renderer.Renderer, sessionRepo model.SessionRepository,
56         appRepo model.AppRepository) Service {
57         return &service{
58                 clientName:    clientName,
59                 clientScope:   clientScope,
60                 clientWebsite: clientWebsite,
61                 renderer:      renderer,
62                 sessionRepo:   sessionRepo,
63                 appRepo:       appRepo,
64         }
65 }
66
67 func (svc *service) GetAuthUrl(ctx context.Context, instance string) (
68         redirectUrl string, sessionID string, err error) {
69         var instanceURL string
70         if strings.HasPrefix(instance, "https://") {
71                 instanceURL = instance
72                 instance = strings.TrimPrefix(instance, "https://")
73         } else {
74                 instanceURL = "https://" + instance
75         }
76
77         sessionID = util.NewSessionId()
78         err = svc.sessionRepo.Add(model.Session{
79                 ID:             sessionID,
80                 InstanceDomain: instance,
81         })
82         if err != nil {
83                 return
84         }
85
86         app, err := svc.appRepo.Get(instance)
87         if err != nil {
88                 if err != model.ErrAppNotFound {
89                         return
90                 }
91
92                 var mastoApp *mastodon.Application
93                 mastoApp, err = mastodon.RegisterApp(ctx, &mastodon.AppConfig{
94                         Server:       instanceURL,
95                         ClientName:   svc.clientName,
96                         Scopes:       svc.clientScope,
97                         Website:      svc.clientWebsite,
98                         RedirectURIs: svc.clientWebsite + "/oauth_callback",
99                 })
100                 if err != nil {
101                         return
102                 }
103
104                 app = model.App{
105                         InstanceDomain: instance,
106                         InstanceURL:    instanceURL,
107                         ClientID:       mastoApp.ClientID,
108                         ClientSecret:   mastoApp.ClientSecret,
109                 }
110
111                 err = svc.appRepo.Add(app)
112                 if err != nil {
113                         return
114                 }
115         }
116
117         u, err := url.Parse("/oauth/authorize")
118         if err != nil {
119                 return
120         }
121
122         q := make(url.Values)
123         q.Set("scope", "read write follow")
124         q.Set("client_id", app.ClientID)
125         q.Set("response_type", "code")
126         q.Set("redirect_uri", svc.clientWebsite+"/oauth_callback")
127         u.RawQuery = q.Encode()
128
129         redirectUrl = instanceURL + u.String()
130
131         return
132 }
133
134 func (svc *service) GetUserToken(ctx context.Context, sessionID string, c *mastodon.Client,
135         code string) (token string, err error) {
136         if len(code) < 1 {
137                 err = ErrInvalidArgument
138                 return
139         }
140
141         session, err := svc.sessionRepo.Get(sessionID)
142         if err != nil {
143                 return
144         }
145
146         app, err := svc.appRepo.Get(session.InstanceDomain)
147         if err != nil {
148                 return
149         }
150
151         data := &bytes.Buffer{}
152         err = json.NewEncoder(data).Encode(map[string]string{
153                 "client_id":     app.ClientID,
154                 "client_secret": app.ClientSecret,
155                 "grant_type":    "authorization_code",
156                 "code":          code,
157                 "redirect_uri":  svc.clientWebsite + "/oauth_callback",
158         })
159         if err != nil {
160                 return
161         }
162
163         resp, err := http.Post(app.InstanceURL+"/oauth/token", "application/json", data)
164         if err != nil {
165                 return
166         }
167         defer resp.Body.Close()
168
169         var res struct {
170                 AccessToken string `json:"access_token"`
171         }
172
173         err = json.NewDecoder(resp.Body).Decode(&res)
174         if err != nil {
175                 return
176         }
177         /*
178                 err = c.AuthenticateToken(ctx, code, svc.clientWebsite+"/oauth_callback")
179                 if err != nil {
180                         return
181                 }
182                 err = svc.sessionRepo.Update(sessionID, c.GetAccessToken(ctx))
183         */
184
185         return res.AccessToken, nil
186 }
187
188 func (svc *service) ServeHomePage(ctx context.Context, client io.Writer) (err error) {
189         err = svc.renderer.RenderHomePage(ctx, client)
190         if err != nil {
191                 return
192         }
193
194         return
195 }
196
197 func (svc *service) ServeErrorPage(ctx context.Context, client io.Writer, err error) {
198         svc.renderer.RenderErrorPage(ctx, client, err)
199 }
200
201 func (svc *service) ServeSigninPage(ctx context.Context, client io.Writer) (err error) {
202         err = svc.renderer.RenderSigninPage(ctx, client)
203         if err != nil {
204                 return
205         }
206
207         return
208 }
209
210 func (svc *service) ServeTimelinePage(ctx context.Context, client io.Writer,
211         c *mastodon.Client, maxID string, sinceID string, minID string) (err error) {
212
213         var hasNext, hasPrev bool
214         var nextLink, prevLink string
215
216         var pg = mastodon.Pagination{
217                 MaxID: maxID,
218                 MinID: minID,
219                 Limit: 20,
220         }
221
222         statuses, err := c.GetTimelineHome(ctx, &pg)
223         if err != nil {
224                 return err
225         }
226
227         if len(maxID) > 0 && len(statuses) > 0 {
228                 hasPrev = true
229                 prevLink = "/timeline?min_id=" + statuses[0].ID
230         }
231         if len(minID) > 0 && len(pg.MinID) > 0 {
232                 newStatuses, err := c.GetTimelineHome(ctx, &mastodon.Pagination{MinID: pg.MinID, Limit: 20})
233                 if err != nil {
234                         return err
235                 }
236                 newStatusesLen := len(newStatuses)
237                 if newStatusesLen == 20 {
238                         hasPrev = true
239                         prevLink = "/timeline?min_id=" + pg.MinID
240                 } else {
241                         i := 20 - newStatusesLen - 1
242                         if len(statuses) > i {
243                                 hasPrev = true
244                                 prevLink = "/timeline?min_id=" + statuses[i].ID
245                         }
246                 }
247         }
248         if len(pg.MaxID) > 0 {
249                 hasNext = true
250                 nextLink = "/timeline?max_id=" + pg.MaxID
251         }
252
253         navbarData, err := svc.getNavbarTemplateData(ctx, client, c)
254         if err != nil {
255                 return
256         }
257
258         data := renderer.NewTimelinePageTemplateData(statuses, hasNext, nextLink, hasPrev, prevLink, navbarData)
259         err = svc.renderer.RenderTimelinePage(ctx, client, data)
260         if err != nil {
261                 return
262         }
263
264         return
265 }
266
267 func (svc *service) ServeThreadPage(ctx context.Context, client io.Writer, c *mastodon.Client, id string, reply bool) (err error) {
268         status, err := c.GetStatus(ctx, id)
269         if err != nil {
270                 return
271         }
272
273         u, err := c.GetAccountCurrentUser(ctx)
274         if err != nil {
275                 return
276         }
277
278         var content string
279         var replyToID string
280         if reply {
281                 replyToID = id
282                 if u.ID != status.Account.ID {
283                         content += "@" + status.Account.Acct + " "
284                 }
285                 for i := range status.Mentions {
286                         if status.Mentions[i].ID != u.ID && status.Mentions[i].ID != status.Account.ID {
287                                 content += "@" + status.Mentions[i].Acct + " "
288                         }
289                 }
290         }
291
292         context, err := c.GetStatusContext(ctx, id)
293         if err != nil {
294                 return
295         }
296
297         statuses := append(append(context.Ancestors, status), context.Descendants...)
298
299         replyMap := make(map[string][]mastodon.ReplyInfo)
300
301         for i := range statuses {
302                 statuses[i].ShowReplies = true
303                 statuses[i].ReplyMap = replyMap
304                 addToReplyMap(replyMap, statuses[i].InReplyToID, statuses[i].ID, i+1)
305         }
306
307         navbarData, err := svc.getNavbarTemplateData(ctx, client, c)
308         if err != nil {
309                 return
310         }
311
312         data := renderer.NewThreadPageTemplateData(statuses, replyToID, content, replyMap, navbarData)
313         err = svc.renderer.RenderThreadPage(ctx, client, data)
314         if err != nil {
315                 return
316         }
317
318         return
319 }
320
321 func (svc *service) ServeNotificationPage(ctx context.Context, client io.Writer, c *mastodon.Client, maxID string, minID string) (err error) {
322         var hasNext bool
323         var nextLink string
324
325         var pg = mastodon.Pagination{
326                 MaxID: maxID,
327                 MinID: minID,
328                 Limit: 20,
329         }
330
331         notifications, err := c.GetNotifications(ctx, &pg)
332         if err != nil {
333                 return
334         }
335
336         var unreadCount int
337         for i := range notifications {
338                 switch notifications[i].Type {
339                 case "reblog", "favourite":
340                         if notifications[i].Status != nil {
341                                 notifications[i].Status.HideAccountInfo = true
342                         }
343                 }
344                 if notifications[i].Pleroma != nil && notifications[i].Pleroma.IsSeen {
345                         unreadCount++
346                 }
347         }
348
349         if unreadCount > 0 {
350                 err := c.ReadNotifications(ctx, notifications[0].ID)
351                 if err != nil {
352                         return err
353                 }
354         }
355
356         if len(pg.MaxID) > 0 {
357                 hasNext = true
358                 nextLink = "/notifications?max_id=" + pg.MaxID
359         }
360
361         navbarData, err := svc.getNavbarTemplateData(ctx, client, c)
362         if err != nil {
363                 return
364         }
365
366         data := renderer.NewNotificationPageTemplateData(notifications, hasNext, nextLink, navbarData)
367         err = svc.renderer.RenderNotificationPage(ctx, client, data)
368         if err != nil {
369                 return
370         }
371
372         return
373 }
374
375 func (svc *service) ServeUserPage(ctx context.Context, client io.Writer, c *mastodon.Client, id string, maxID string, minID string) (err error) {
376         user, err := c.GetAccount(ctx, id)
377         if err != nil {
378                 return
379         }
380
381         var hasNext bool
382         var nextLink string
383
384         var pg = mastodon.Pagination{
385                 MaxID: maxID,
386                 MinID: minID,
387                 Limit: 20,
388         }
389
390         statuses, err := c.GetAccountStatuses(ctx, id, &pg)
391         if err != nil {
392                 return
393         }
394
395         if len(pg.MaxID) > 0 {
396                 hasNext = true
397                 nextLink = "/user/" + id + "?max_id=" + pg.MaxID
398         }
399
400         navbarData, err := svc.getNavbarTemplateData(ctx, client, c)
401         if err != nil {
402                 return
403         }
404
405         data := renderer.NewUserPageTemplateData(user, statuses, hasNext, nextLink, navbarData)
406         err = svc.renderer.RenderUserPage(ctx, client, data)
407         if err != nil {
408                 return
409         }
410
411         return
412 }
413
414 func (svc *service) getNavbarTemplateData(ctx context.Context, client io.Writer, c *mastodon.Client) (data *renderer.NavbarTemplateData, err error) {
415         notifications, err := c.GetNotifications(ctx, nil)
416         if err != nil {
417                 return
418         }
419
420         var notificationCount int
421         for i := range notifications {
422                 if notifications[i].Pleroma != nil && !notifications[i].Pleroma.IsSeen {
423                         notificationCount++
424                 }
425         }
426
427         data = renderer.NewNavbarTemplateData(notificationCount)
428
429         return
430 }
431
432 func (svc *service) Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
433         _, err = c.Favourite(ctx, id)
434         return
435 }
436
437 func (svc *service) UnLike(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
438         _, err = c.Unfavourite(ctx, id)
439         return
440 }
441
442 func (svc *service) Retweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
443         _, err = c.Reblog(ctx, id)
444         return
445 }
446
447 func (svc *service) UnRetweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
448         _, err = c.Unreblog(ctx, id)
449         return
450 }
451
452 func (svc *service) PostTweet(ctx context.Context, client io.Writer, c *mastodon.Client, content string, replyToID string, files []*multipart.FileHeader) (id string, err error) {
453         var mediaIds []string
454         for _, f := range files {
455                 a, err := c.UploadMediaFromMultipartFileHeader(ctx, f)
456                 if err != nil {
457                         return "", err
458                 }
459                 mediaIds = append(mediaIds, a.ID)
460         }
461
462         tweet := &mastodon.Toot{
463                 Status:      content,
464                 InReplyToID: replyToID,
465                 MediaIDs:    mediaIds,
466         }
467
468         s, err := c.PostStatus(ctx, tweet)
469         if err != nil {
470                 return
471         }
472
473         return s.ID, nil
474 }
475
476 func (svc *service) Follow(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
477         _, err = c.AccountFollow(ctx, id)
478         return
479 }
480
481 func (svc *service) UnFollow(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
482         _, err = c.AccountUnfollow(ctx, id)
483         return
484 }
485
486 func addToReplyMap(m map[string][]mastodon.ReplyInfo, key interface{}, val string, number int) {
487         if key == nil {
488                 return
489         }
490         keyStr, ok := key.(string)
491         if !ok {
492                 return
493         }
494         _, ok = m[keyStr]
495         if !ok {
496                 m[keyStr] = []mastodon.ReplyInfo{}
497         }
498
499         m[keyStr] = append(m[keyStr], mastodon.ReplyInfo{val, number})
500 }