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