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