Add CSRF protection
[bloat] / service / service.go
1 package service
2
3 import (
4         "bytes"
5         "context"
6         "encoding/json"
7         "errors"
8         "fmt"
9         "io"
10         "mime/multipart"
11         "net/http"
12         "net/url"
13         "strings"
14
15         "bloat/model"
16         "bloat/renderer"
17         "bloat/util"
18         "mastodon"
19 )
20
21 var (
22         ErrInvalidArgument = errors.New("invalid argument")
23         ErrInvalidToken    = errors.New("invalid token")
24         ErrInvalidClient   = errors.New("invalid client")
25         ErrInvalidTimeline = errors.New("invalid timeline")
26 )
27
28 type Service interface {
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, c *model.Client, 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         ServeLikedByPage(ctx context.Context, client io.Writer, c *model.Client, id string) (err error)
40         ServeRetweetedByPage(ctx context.Context, client io.Writer, c *model.Client, id string) (err error)
41         ServeFollowingPage(ctx context.Context, client io.Writer, c *model.Client, id string, maxID string, minID string) (err error)
42         ServeFollowersPage(ctx context.Context, client io.Writer, c *model.Client, id string, maxID string, minID string) (err error)
43         ServeSearchPage(ctx context.Context, client io.Writer, c *model.Client, q string, qType string, offset int) (err error)
44         ServeSettingsPage(ctx context.Context, client io.Writer, c *model.Client) (err error)
45         SaveSettings(ctx context.Context, client io.Writer, c *model.Client, settings *model.Settings) (err error)
46         Like(ctx context.Context, client io.Writer, c *model.Client, id string) (count int64, err error)
47         UnLike(ctx context.Context, client io.Writer, c *model.Client, id string) (count int64, err error)
48         Retweet(ctx context.Context, client io.Writer, c *model.Client, id string) (count int64, err error)
49         UnRetweet(ctx context.Context, client io.Writer, c *model.Client, id string) (count int64, err error)
50         PostTweet(ctx context.Context, client io.Writer, c *model.Client, content string, replyToID string, format string, visibility string, isNSFW bool, files []*multipart.FileHeader) (id string, err error)
51         Follow(ctx context.Context, client io.Writer, c *model.Client, id string) (err error)
52         UnFollow(ctx context.Context, client io.Writer, c *model.Client, id string) (err error)
53 }
54
55 type service struct {
56         clientName    string
57         clientScope   string
58         clientWebsite string
59         customCSS     string
60         postFormats   []model.PostFormat
61         renderer      renderer.Renderer
62         sessionRepo   model.SessionRepository
63         appRepo       model.AppRepository
64 }
65
66 func NewService(clientName string, clientScope string, clientWebsite string,
67         customCSS string, postFormats []model.PostFormat, renderer renderer.Renderer,
68         sessionRepo model.SessionRepository, appRepo model.AppRepository) Service {
69         return &service{
70                 clientName:    clientName,
71                 clientScope:   clientScope,
72                 clientWebsite: clientWebsite,
73                 customCSS:     customCSS,
74                 postFormats:   postFormats,
75                 renderer:      renderer,
76                 sessionRepo:   sessionRepo,
77                 appRepo:       appRepo,
78         }
79 }
80
81 func getRendererContext(c *model.Client) *renderer.Context {
82         var settings model.Settings
83         var session model.Session
84         if c != nil {
85                 settings = c.Session.Settings
86                 session = c.Session
87         } else {
88                 settings = *model.NewSettings()
89         }
90         return &renderer.Context{
91                 MaskNSFW:       settings.MaskNSFW,
92                 ThreadInNewTab: settings.ThreadInNewTab,
93                 FluorideMode:   settings.FluorideMode,
94                 DarkMode:       settings.DarkMode,
95                 CSRFToken:      session.CSRFToken,
96         }
97 }
98
99 func (svc *service) GetAuthUrl(ctx context.Context, instance string) (
100         redirectUrl string, sessionID string, err error) {
101         var instanceURL string
102         if strings.HasPrefix(instance, "https://") {
103                 instanceURL = instance
104                 instance = strings.TrimPrefix(instance, "https://")
105         } else {
106                 instanceURL = "https://" + instance
107         }
108
109         sessionID = util.NewSessionId()
110         csrfToken := util.NewCSRFToken()
111         session := model.Session{
112                 ID:             sessionID,
113                 InstanceDomain: instance,
114                 CSRFToken:      csrfToken,
115                 Settings:       *model.NewSettings(),
116         }
117         err = svc.sessionRepo.Add(session)
118         if err != nil {
119                 return
120         }
121
122         app, err := svc.appRepo.Get(instance)
123         if err != nil {
124                 if err != model.ErrAppNotFound {
125                         return
126                 }
127
128                 var mastoApp *mastodon.Application
129                 mastoApp, err = mastodon.RegisterApp(ctx, &mastodon.AppConfig{
130                         Server:       instanceURL,
131                         ClientName:   svc.clientName,
132                         Scopes:       svc.clientScope,
133                         Website:      svc.clientWebsite,
134                         RedirectURIs: svc.clientWebsite + "/oauth_callback",
135                 })
136                 if err != nil {
137                         return
138                 }
139
140                 app = model.App{
141                         InstanceDomain: instance,
142                         InstanceURL:    instanceURL,
143                         ClientID:       mastoApp.ClientID,
144                         ClientSecret:   mastoApp.ClientSecret,
145                 }
146
147                 err = svc.appRepo.Add(app)
148                 if err != nil {
149                         return
150                 }
151         }
152
153         u, err := url.Parse("/oauth/authorize")
154         if err != nil {
155                 return
156         }
157
158         q := make(url.Values)
159         q.Set("scope", "read write follow")
160         q.Set("client_id", app.ClientID)
161         q.Set("response_type", "code")
162         q.Set("redirect_uri", svc.clientWebsite+"/oauth_callback")
163         u.RawQuery = q.Encode()
164
165         redirectUrl = instanceURL + u.String()
166
167         return
168 }
169
170 func (svc *service) GetUserToken(ctx context.Context, sessionID string, c *model.Client,
171         code string) (token string, err error) {
172         if len(code) < 1 {
173                 err = ErrInvalidArgument
174                 return
175         }
176
177         session, err := svc.sessionRepo.Get(sessionID)
178         if err != nil {
179                 return
180         }
181
182         app, err := svc.appRepo.Get(session.InstanceDomain)
183         if err != nil {
184                 return
185         }
186
187         data := &bytes.Buffer{}
188         err = json.NewEncoder(data).Encode(map[string]string{
189                 "client_id":     app.ClientID,
190                 "client_secret": app.ClientSecret,
191                 "grant_type":    "authorization_code",
192                 "code":          code,
193                 "redirect_uri":  svc.clientWebsite + "/oauth_callback",
194         })
195         if err != nil {
196                 return
197         }
198
199         resp, err := http.Post(app.InstanceURL+"/oauth/token", "application/json", data)
200         if err != nil {
201                 return
202         }
203         defer resp.Body.Close()
204
205         var res struct {
206                 AccessToken string `json:"access_token"`
207         }
208
209         err = json.NewDecoder(resp.Body).Decode(&res)
210         if err != nil {
211                 return
212         }
213
214         return res.AccessToken, nil
215 }
216
217 func (svc *service) ServeErrorPage(ctx context.Context, client io.Writer, c *model.Client, err error) {
218         var errStr string
219         if err != nil {
220                 errStr = err.Error()
221         }
222
223         commonData, err := svc.getCommonData(ctx, client, nil, "error")
224         if err != nil {
225                 return
226         }
227
228         data := &renderer.ErrorData{
229                 CommonData: commonData,
230                 Error:      errStr,
231         }
232
233         rCtx := getRendererContext(c)
234
235         svc.renderer.RenderErrorPage(rCtx, client, data)
236 }
237
238 func (svc *service) ServeSigninPage(ctx context.Context, client io.Writer) (err error) {
239         commonData, err := svc.getCommonData(ctx, client, nil, "signin")
240         if err != nil {
241                 return
242         }
243
244         data := &renderer.SigninData{
245                 CommonData: commonData,
246         }
247
248         rCtx := getRendererContext(nil)
249         return svc.renderer.RenderSigninPage(rCtx, client, data)
250 }
251
252 func (svc *service) ServeTimelinePage(ctx context.Context, client io.Writer,
253         c *model.Client, timelineType string, maxID string, sinceID string, minID string) (err error) {
254
255         var hasNext, hasPrev bool
256         var nextLink, prevLink string
257
258         var pg = mastodon.Pagination{
259                 MaxID: maxID,
260                 MinID: minID,
261                 Limit: 20,
262         }
263
264         var statuses []*mastodon.Status
265         var title string
266         switch timelineType {
267         default:
268                 return ErrInvalidTimeline
269         case "home":
270                 statuses, err = c.GetTimelineHome(ctx, &pg)
271                 title = "Timeline"
272         case "local":
273                 statuses, err = c.GetTimelinePublic(ctx, true, &pg)
274                 title = "Local Timeline"
275         case "twkn":
276                 statuses, err = c.GetTimelinePublic(ctx, false, &pg)
277                 title = "The Whole Known Network"
278         }
279         if err != nil {
280                 return err
281         }
282
283         for i := range statuses {
284                 if statuses[i].Reblog != nil {
285                         statuses[i].Reblog.RetweetedByID = statuses[i].ID
286                 }
287         }
288
289         if len(maxID) > 0 && len(statuses) > 0 {
290                 hasPrev = true
291                 prevLink = fmt.Sprintf("/timeline/$s?min_id=%s", timelineType, statuses[0].ID)
292         }
293         if len(minID) > 0 && len(pg.MinID) > 0 {
294                 newStatuses, err := c.GetTimelineHome(ctx, &mastodon.Pagination{MinID: pg.MinID, Limit: 20})
295                 if err != nil {
296                         return err
297                 }
298                 newStatusesLen := len(newStatuses)
299                 if newStatusesLen == 20 {
300                         hasPrev = true
301                         prevLink = fmt.Sprintf("/timeline/%s?min_id=%s", timelineType, pg.MinID)
302                 } else {
303                         i := 20 - newStatusesLen - 1
304                         if len(statuses) > i {
305                                 hasPrev = true
306                                 prevLink = fmt.Sprintf("/timeline/%s?min_id=%s", timelineType, statuses[i].ID)
307                         }
308                 }
309         }
310         if len(pg.MaxID) > 0 {
311                 hasNext = true
312                 nextLink = fmt.Sprintf("/timeline/%s?max_id=%s", timelineType, pg.MaxID)
313         }
314
315         postContext := model.PostContext{
316                 DefaultVisibility: c.Session.Settings.DefaultVisibility,
317                 Formats:           svc.postFormats,
318         }
319
320         commonData, err := svc.getCommonData(ctx, client, c, timelineType+" timeline ")
321         if err != nil {
322                 return
323         }
324
325         data := &renderer.TimelineData{
326                 Title:       title,
327                 Statuses:    statuses,
328                 HasNext:     hasNext,
329                 NextLink:    nextLink,
330                 HasPrev:     hasPrev,
331                 PrevLink:    prevLink,
332                 PostContext: postContext,
333                 CommonData:  commonData,
334         }
335         rCtx := getRendererContext(c)
336
337         err = svc.renderer.RenderTimelinePage(rCtx, client, data)
338         if err != nil {
339                 return
340         }
341
342         return
343 }
344
345 func (svc *service) ServeThreadPage(ctx context.Context, client io.Writer, c *model.Client, id string, reply bool) (err error) {
346         status, err := c.GetStatus(ctx, id)
347         if err != nil {
348                 return
349         }
350
351         u, err := c.GetAccountCurrentUser(ctx)
352         if err != nil {
353                 return
354         }
355
356         var postContext model.PostContext
357         if reply {
358                 var content string
359                 if u.ID != status.Account.ID {
360                         content += "@" + status.Account.Acct + " "
361                 }
362                 for i := range status.Mentions {
363                         if status.Mentions[i].ID != u.ID && status.Mentions[i].ID != status.Account.ID {
364                                 content += "@" + status.Mentions[i].Acct + " "
365                         }
366                 }
367
368                 var visibility string
369                 if c.Session.Settings.CopyScope {
370                         s, err := c.GetStatus(ctx, id)
371                         if err != nil {
372                                 return err
373                         }
374                         visibility = s.Visibility
375                 } else {
376                         visibility = c.Session.Settings.DefaultVisibility
377                 }
378
379                 postContext = model.PostContext{
380                         DefaultVisibility: visibility,
381                         Formats:           svc.postFormats,
382                         ReplyContext: &model.ReplyContext{
383                                 InReplyToID:   id,
384                                 InReplyToName: status.Account.Acct,
385                                 ReplyContent:  content,
386                         },
387                         DarkMode: c.Session.Settings.DarkMode,
388                 }
389         }
390
391         context, err := c.GetStatusContext(ctx, id)
392         if err != nil {
393                 return
394         }
395
396         statuses := append(append(context.Ancestors, status), context.Descendants...)
397
398         replyMap := make(map[string][]mastodon.ReplyInfo)
399
400         for i := range statuses {
401                 statuses[i].ShowReplies = true
402                 statuses[i].ReplyMap = replyMap
403                 addToReplyMap(replyMap, statuses[i].InReplyToID, statuses[i].ID, i+1)
404         }
405
406         commonData, err := svc.getCommonData(ctx, client, c, "post by "+status.Account.DisplayName)
407         if err != nil {
408                 return
409         }
410
411         data := &renderer.ThreadData{
412                 Statuses:    statuses,
413                 PostContext: postContext,
414                 ReplyMap:    replyMap,
415                 CommonData:  commonData,
416         }
417         rCtx := getRendererContext(c)
418
419         err = svc.renderer.RenderThreadPage(rCtx, client, data)
420         if err != nil {
421                 return
422         }
423
424         return
425 }
426
427 func (svc *service) ServeNotificationPage(ctx context.Context, client io.Writer, c *model.Client, maxID string, minID string) (err error) {
428         var hasNext bool
429         var nextLink string
430
431         var pg = mastodon.Pagination{
432                 MaxID: maxID,
433                 MinID: minID,
434                 Limit: 20,
435         }
436
437         notifications, err := c.GetNotifications(ctx, &pg)
438         if err != nil {
439                 return
440         }
441
442         var unreadCount int
443         for i := range notifications {
444                 if notifications[i].Status != nil {
445                         notifications[i].Status.CreatedAt = notifications[i].CreatedAt
446                         switch notifications[i].Type {
447                         case "reblog", "favourite":
448                                 notifications[i].Status.HideAccountInfo = true
449                         }
450                 }
451                 if notifications[i].Pleroma != nil && !notifications[i].Pleroma.IsSeen {
452                         unreadCount++
453                 }
454         }
455
456         if unreadCount > 0 {
457                 err := c.ReadNotifications(ctx, notifications[0].ID)
458                 if err != nil {
459                         return err
460                 }
461         }
462
463         if len(pg.MaxID) > 0 {
464                 hasNext = true
465                 nextLink = "/notifications?max_id=" + pg.MaxID
466         }
467
468         commonData, err := svc.getCommonData(ctx, client, c, "notifications")
469         if err != nil {
470                 return
471         }
472
473         data := &renderer.NotificationData{
474                 Notifications: notifications,
475                 HasNext:       hasNext,
476                 NextLink:      nextLink,
477                 CommonData:    commonData,
478         }
479         rCtx := getRendererContext(c)
480
481         err = svc.renderer.RenderNotificationPage(rCtx, client, data)
482         if err != nil {
483                 return
484         }
485
486         return
487 }
488
489 func (svc *service) ServeUserPage(ctx context.Context, client io.Writer, c *model.Client, id string, maxID string, minID string) (err error) {
490         user, err := c.GetAccount(ctx, id)
491         if err != nil {
492                 return
493         }
494
495         var hasNext bool
496         var nextLink string
497
498         var pg = mastodon.Pagination{
499                 MaxID: maxID,
500                 MinID: minID,
501                 Limit: 20,
502         }
503
504         statuses, err := c.GetAccountStatuses(ctx, id, &pg)
505         if err != nil {
506                 return
507         }
508
509         if len(pg.MaxID) > 0 {
510                 hasNext = true
511                 nextLink = "/user/" + id + "?max_id=" + pg.MaxID
512         }
513
514         commonData, err := svc.getCommonData(ctx, client, c, user.DisplayName)
515         if err != nil {
516                 return
517         }
518
519         data := &renderer.UserData{
520                 User:       user,
521                 Statuses:   statuses,
522                 HasNext:    hasNext,
523                 NextLink:   nextLink,
524                 CommonData: commonData,
525         }
526         rCtx := getRendererContext(c)
527
528         err = svc.renderer.RenderUserPage(rCtx, client, data)
529         if err != nil {
530                 return
531         }
532
533         return
534 }
535
536 func (svc *service) ServeAboutPage(ctx context.Context, client io.Writer, c *model.Client) (err error) {
537         commonData, err := svc.getCommonData(ctx, client, c, "about")
538         if err != nil {
539                 return
540         }
541
542         data := &renderer.AboutData{
543                 CommonData: commonData,
544         }
545         rCtx := getRendererContext(c)
546
547         err = svc.renderer.RenderAboutPage(rCtx, client, data)
548         if err != nil {
549                 return
550         }
551
552         return
553 }
554
555 func (svc *service) ServeEmojiPage(ctx context.Context, client io.Writer, c *model.Client) (err error) {
556         commonData, err := svc.getCommonData(ctx, client, c, "emojis")
557         if err != nil {
558                 return
559         }
560
561         emojis, err := c.GetInstanceEmojis(ctx)
562         if err != nil {
563                 return
564         }
565
566         data := &renderer.EmojiData{
567                 Emojis:     emojis,
568                 CommonData: commonData,
569         }
570         rCtx := getRendererContext(c)
571
572         err = svc.renderer.RenderEmojiPage(rCtx, client, data)
573         if err != nil {
574                 return
575         }
576
577         return
578 }
579
580 func (svc *service) ServeLikedByPage(ctx context.Context, client io.Writer, c *model.Client, id string) (err error) {
581         likers, err := c.GetFavouritedBy(ctx, id, nil)
582         if err != nil {
583                 return
584         }
585
586         commonData, err := svc.getCommonData(ctx, client, c, "likes")
587         if err != nil {
588                 return
589         }
590
591         data := &renderer.LikedByData{
592                 CommonData: commonData,
593                 Users:      likers,
594         }
595         rCtx := getRendererContext(c)
596
597         err = svc.renderer.RenderLikedByPage(rCtx, client, data)
598         if err != nil {
599                 return
600         }
601
602         return
603 }
604
605 func (svc *service) ServeRetweetedByPage(ctx context.Context, client io.Writer, c *model.Client, id string) (err error) {
606         retweeters, err := c.GetRebloggedBy(ctx, id, nil)
607         if err != nil {
608                 return
609         }
610
611         commonData, err := svc.getCommonData(ctx, client, c, "retweets")
612         if err != nil {
613                 return
614         }
615
616         data := &renderer.RetweetedByData{
617                 CommonData: commonData,
618                 Users:      retweeters,
619         }
620         rCtx := getRendererContext(c)
621
622         err = svc.renderer.RenderRetweetedByPage(rCtx, client, data)
623         if err != nil {
624                 return
625         }
626
627         return
628 }
629
630 func (svc *service) ServeFollowingPage(ctx context.Context, client io.Writer, c *model.Client, id string, maxID string, minID string) (err error) {
631         var hasNext bool
632         var nextLink string
633
634         var pg = mastodon.Pagination{
635                 MaxID: maxID,
636                 MinID: minID,
637                 Limit: 20,
638         }
639
640         followings, err := c.GetAccountFollowing(ctx, id, &pg)
641         if err != nil {
642                 return
643         }
644
645         if len(followings) == 20 && len(pg.MaxID) > 0 {
646                 hasNext = true
647                 nextLink = "/following/" + id + "?max_id=" + pg.MaxID
648         }
649
650         commonData, err := svc.getCommonData(ctx, client, c, "following")
651         if err != nil {
652                 return
653         }
654
655         data := &renderer.FollowingData{
656                 CommonData: commonData,
657                 Users:      followings,
658                 HasNext:    hasNext,
659                 NextLink:   nextLink,
660         }
661         rCtx := getRendererContext(c)
662
663         err = svc.renderer.RenderFollowingPage(rCtx, client, data)
664         if err != nil {
665                 return
666         }
667
668         return
669 }
670
671 func (svc *service) ServeFollowersPage(ctx context.Context, client io.Writer, c *model.Client, id string, maxID string, minID string) (err error) {
672         var hasNext bool
673         var nextLink string
674
675         var pg = mastodon.Pagination{
676                 MaxID: maxID,
677                 MinID: minID,
678                 Limit: 20,
679         }
680
681         followers, err := c.GetAccountFollowers(ctx, id, &pg)
682         if err != nil {
683                 return
684         }
685
686         if len(followers) == 20 && len(pg.MaxID) > 0 {
687                 hasNext = true
688                 nextLink = "/followers/" + id + "?max_id=" + pg.MaxID
689         }
690
691         commonData, err := svc.getCommonData(ctx, client, c, "followers")
692         if err != nil {
693                 return
694         }
695
696         data := &renderer.FollowersData{
697                 CommonData: commonData,
698                 Users:      followers,
699                 HasNext:    hasNext,
700                 NextLink:   nextLink,
701         }
702         rCtx := getRendererContext(c)
703
704         err = svc.renderer.RenderFollowersPage(rCtx, client, data)
705         if err != nil {
706                 return
707         }
708
709         return
710 }
711
712 func (svc *service) ServeSearchPage(ctx context.Context, client io.Writer, c *model.Client, q string, qType string, offset int) (err error) {
713         var hasNext bool
714         var nextLink string
715
716         results, err := c.Search(ctx, q, qType, 20, true, offset)
717         if err != nil {
718                 return
719         }
720
721         switch qType {
722         case "accounts":
723                 hasNext = len(results.Accounts) == 20
724         case "statuses":
725                 hasNext = len(results.Statuses) == 20
726         }
727
728         if hasNext {
729                 offset += 20
730                 nextLink = fmt.Sprintf("/search?q=%s&type=%s&offset=%d", q, qType, offset)
731         }
732
733         var title = "search"
734         if len(q) > 0 {
735                 title += " \"" + q + "\""
736         }
737         commonData, err := svc.getCommonData(ctx, client, c, title)
738         if err != nil {
739                 return
740         }
741
742         data := &renderer.SearchData{
743                 CommonData: commonData,
744                 Q:          q,
745                 Type:       qType,
746                 Users:      results.Accounts,
747                 Statuses:   results.Statuses,
748                 HasNext:    hasNext,
749                 NextLink:   nextLink,
750         }
751         rCtx := getRendererContext(c)
752
753         err = svc.renderer.RenderSearchPage(rCtx, client, data)
754         if err != nil {
755                 return
756         }
757
758         return
759 }
760
761 func (svc *service) ServeSettingsPage(ctx context.Context, client io.Writer, c *model.Client) (err error) {
762         commonData, err := svc.getCommonData(ctx, client, c, "settings")
763         if err != nil {
764                 return
765         }
766
767         data := &renderer.SettingsData{
768                 CommonData: commonData,
769                 Settings:   &c.Session.Settings,
770         }
771         rCtx := getRendererContext(c)
772
773         err = svc.renderer.RenderSettingsPage(rCtx, client, data)
774         if err != nil {
775                 return
776         }
777
778         return
779 }
780
781 func (svc *service) SaveSettings(ctx context.Context, client io.Writer, c *model.Client, settings *model.Settings) (err error) {
782         session, err := svc.sessionRepo.Get(c.Session.ID)
783         if err != nil {
784                 return
785         }
786
787         session.Settings = *settings
788         err = svc.sessionRepo.Add(session)
789         if err != nil {
790                 return
791         }
792
793         return
794 }
795
796 func (svc *service) getCommonData(ctx context.Context, client io.Writer, c *model.Client, title string) (data *renderer.CommonData, err error) {
797         data = new(renderer.CommonData)
798
799         data.HeaderData = &renderer.HeaderData{
800                 Title:             title + " - " + svc.clientName,
801                 NotificationCount: 0,
802                 CustomCSS:         svc.customCSS,
803         }
804
805         if c != nil && c.Session.IsLoggedIn() {
806                 notifications, err := c.GetNotifications(ctx, nil)
807                 if err != nil {
808                         return nil, err
809                 }
810
811                 var notificationCount int
812                 for i := range notifications {
813                         if notifications[i].Pleroma != nil && !notifications[i].Pleroma.IsSeen {
814                                 notificationCount++
815                         }
816                 }
817
818                 u, err := c.GetAccountCurrentUser(ctx)
819                 if err != nil {
820                         return nil, err
821                 }
822
823                 data.NavbarData = &renderer.NavbarData{
824                         User:              u,
825                         NotificationCount: notificationCount,
826                 }
827
828                 data.HeaderData.NotificationCount = notificationCount
829                 data.HeaderData.CSRFToken = c.Session.CSRFToken
830         }
831
832         return
833 }
834
835 func (svc *service) Like(ctx context.Context, client io.Writer, c *model.Client, id string) (count int64, err error) {
836         s, err := c.Favourite(ctx, id)
837         if err != nil {
838                 return
839         }
840         count = s.FavouritesCount
841         return
842 }
843
844 func (svc *service) UnLike(ctx context.Context, client io.Writer, c *model.Client, id string) (count int64, err error) {
845         s, err := c.Unfavourite(ctx, id)
846         if err != nil {
847                 return
848         }
849         count = s.FavouritesCount
850         return
851 }
852
853 func (svc *service) Retweet(ctx context.Context, client io.Writer, c *model.Client, id string) (count int64, err error) {
854         s, err := c.Reblog(ctx, id)
855         if err != nil {
856                 return
857         }
858         if s.Reblog != nil {
859                 count = s.Reblog.ReblogsCount
860         }
861         return
862 }
863
864 func (svc *service) UnRetweet(ctx context.Context, client io.Writer, c *model.Client, id string) (count int64, err error) {
865         s, err := c.Unreblog(ctx, id)
866         if err != nil {
867                 return
868         }
869         count = s.ReblogsCount
870         return
871 }
872
873 func (svc *service) PostTweet(ctx context.Context, client io.Writer, c *model.Client, content string, replyToID string, format string, visibility string, isNSFW bool, files []*multipart.FileHeader) (id string, err error) {
874         var mediaIds []string
875         for _, f := range files {
876                 a, err := c.UploadMediaFromMultipartFileHeader(ctx, f)
877                 if err != nil {
878                         return "", err
879                 }
880                 mediaIds = append(mediaIds, a.ID)
881         }
882
883         tweet := &mastodon.Toot{
884                 Status:      content,
885                 InReplyToID: replyToID,
886                 MediaIDs:    mediaIds,
887                 ContentType: format,
888                 Visibility:  visibility,
889                 Sensitive:   isNSFW,
890         }
891
892         s, err := c.PostStatus(ctx, tweet)
893         if err != nil {
894                 return
895         }
896
897         return s.ID, nil
898 }
899
900 func (svc *service) Follow(ctx context.Context, client io.Writer, c *model.Client, id string) (err error) {
901         _, err = c.AccountFollow(ctx, id)
902         return
903 }
904
905 func (svc *service) UnFollow(ctx context.Context, client io.Writer, c *model.Client, id string) (err error) {
906         _, err = c.AccountUnfollow(ctx, id)
907         return
908 }
909
910 func addToReplyMap(m map[string][]mastodon.ReplyInfo, key interface{}, val string, number int) {
911         if key == nil {
912                 return
913         }
914
915         keyStr, ok := key.(string)
916         if !ok {
917                 return
918         }
919         _, ok = m[keyStr]
920         if !ok {
921                 m[keyStr] = []mastodon.ReplyInfo{}
922         }
923
924         m[keyStr] = append(m[keyStr], mastodon.ReplyInfo{val, number})
925 }