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