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