Add notification support
[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         "path"
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 )
26
27 type Service interface {
28         ServeHomePage(ctx context.Context, client io.Writer) (err error)
29         GetAuthUrl(ctx context.Context, instance string) (url string, sessionID string, err error)
30         GetUserToken(ctx context.Context, sessionID string, c *mastodon.Client, token string) (accessToken string, err error)
31         ServeErrorPage(ctx context.Context, client io.Writer, err error)
32         ServeSigninPage(ctx context.Context, client io.Writer) (err error)
33         ServeTimelinePage(ctx context.Context, client io.Writer, c *mastodon.Client, maxID string, sinceID string, minID string) (err error)
34         ServeThreadPage(ctx context.Context, client io.Writer, c *mastodon.Client, id string, reply bool) (err error)
35         ServeNotificationPage(ctx context.Context, client io.Writer, c *mastodon.Client, maxID string, minID string) (err error)
36         Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error)
37         UnLike(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error)
38         Retweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error)
39         UnRetweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error)
40         PostTweet(ctx context.Context, client io.Writer, c *mastodon.Client, content string, replyToID string, files []*multipart.FileHeader) (id string, err error)
41 }
42
43 type service struct {
44         clientName    string
45         clientScope   string
46         clientWebsite string
47         renderer      renderer.Renderer
48         sessionRepo   model.SessionRepository
49         appRepo       model.AppRepository
50 }
51
52 func NewService(clientName string, clientScope string, clientWebsite string,
53         renderer renderer.Renderer, sessionRepo model.SessionRepository,
54         appRepo model.AppRepository) Service {
55         return &service{
56                 clientName:    clientName,
57                 clientScope:   clientScope,
58                 clientWebsite: clientWebsite,
59                 renderer:      renderer,
60                 sessionRepo:   sessionRepo,
61                 appRepo:       appRepo,
62         }
63 }
64
65 func (svc *service) GetAuthUrl(ctx context.Context, instance string) (
66         redirectUrl string, sessionID string, err error) {
67         if !strings.HasPrefix(instance, "https://") {
68                 instance = "https://" + instance
69         }
70
71         sessionID = util.NewSessionId()
72         err = svc.sessionRepo.Add(model.Session{
73                 ID:          sessionID,
74                 InstanceURL: instance,
75         })
76         if err != nil {
77                 return
78         }
79
80         app, err := svc.appRepo.Get(instance)
81         if err != nil {
82                 if err != model.ErrAppNotFound {
83                         return
84                 }
85
86                 var mastoApp *mastodon.Application
87                 mastoApp, err = mastodon.RegisterApp(ctx, &mastodon.AppConfig{
88                         Server:       instance,
89                         ClientName:   svc.clientName,
90                         Scopes:       svc.clientScope,
91                         Website:      svc.clientWebsite,
92                         RedirectURIs: svc.clientWebsite + "/oauth_callback",
93                 })
94                 if err != nil {
95                         return
96                 }
97
98                 app = model.App{
99                         InstanceURL:  instance,
100                         ClientID:     mastoApp.ClientID,
101                         ClientSecret: mastoApp.ClientSecret,
102                 }
103
104                 err = svc.appRepo.Add(app)
105                 if err != nil {
106                         return
107                 }
108         }
109
110         u, err := url.Parse(path.Join(instance, "/oauth/authorize"))
111         if err != nil {
112                 return
113         }
114
115         q := make(url.Values)
116         q.Set("scope", "read write follow")
117         q.Set("client_id", app.ClientID)
118         q.Set("response_type", "code")
119         q.Set("redirect_uri", svc.clientWebsite+"/oauth_callback")
120         u.RawQuery = q.Encode()
121
122         redirectUrl = u.String()
123
124         return
125 }
126
127 func (svc *service) GetUserToken(ctx context.Context, sessionID string, c *mastodon.Client,
128         code string) (token string, err error) {
129         if len(code) < 1 {
130                 err = ErrInvalidArgument
131                 return
132         }
133
134         session, err := svc.sessionRepo.Get(sessionID)
135         if err != nil {
136                 return
137         }
138
139         app, err := svc.appRepo.Get(session.InstanceURL)
140         if err != nil {
141                 return
142         }
143
144         data := &bytes.Buffer{}
145         err = json.NewEncoder(data).Encode(map[string]string{
146                 "client_id":     app.ClientID,
147                 "client_secret": app.ClientSecret,
148                 "grant_type":    "authorization_code",
149                 "code":          code,
150                 "redirect_uri":  svc.clientWebsite + "/oauth_callback",
151         })
152         if err != nil {
153                 return
154         }
155
156         resp, err := http.Post(app.InstanceURL+"/oauth/token", "application/json", data)
157         if err != nil {
158                 return
159         }
160         defer resp.Body.Close()
161
162         var res struct {
163                 AccessToken string `json:"access_token"`
164         }
165
166         err = json.NewDecoder(resp.Body).Decode(&res)
167         if err != nil {
168                 return
169         }
170         /*
171                 err = c.AuthenticateToken(ctx, code, svc.clientWebsite+"/oauth_callback")
172                 if err != nil {
173                         return
174                 }
175                 err = svc.sessionRepo.Update(sessionID, c.GetAccessToken(ctx))
176         */
177
178         return res.AccessToken, nil
179 }
180
181 func (svc *service) ServeHomePage(ctx context.Context, client io.Writer) (err error) {
182         err = svc.renderer.RenderHomePage(ctx, client)
183         if err != nil {
184                 return
185         }
186
187         return
188 }
189
190 func (svc *service) ServeErrorPage(ctx context.Context, client io.Writer, err error) {
191         svc.renderer.RenderErrorPage(ctx, client, err)
192 }
193
194 func (svc *service) ServeSigninPage(ctx context.Context, client io.Writer) (err error) {
195         err = svc.renderer.RenderSigninPage(ctx, client)
196         if err != nil {
197                 return
198         }
199
200         return
201 }
202
203 func (svc *service) ServeTimelinePage(ctx context.Context, client io.Writer,
204         c *mastodon.Client, maxID string, sinceID string, minID string) (err error) {
205
206         var hasNext, hasPrev bool
207         var nextLink, prevLink string
208
209         var pg = mastodon.Pagination{
210                 MaxID: maxID,
211                 MinID: minID,
212                 Limit: 20,
213         }
214
215         statuses, err := c.GetTimelineHome(ctx, &pg)
216         if err != nil {
217                 return err
218         }
219
220         if len(maxID) > 0 && len(statuses) > 0 {
221                 hasPrev = true
222                 prevLink = "/timeline?min_id=" + statuses[0].ID
223         }
224         if len(minID) > 0 && len(pg.MinID) > 0 {
225                 newStatuses, err := c.GetTimelineHome(ctx, &mastodon.Pagination{MinID: pg.MinID, Limit: 20})
226                 if err != nil {
227                         return err
228                 }
229                 newStatusesLen := len(newStatuses)
230                 if newStatusesLen == 20 {
231                         hasPrev = true
232                         prevLink = "/timeline?min_id=" + pg.MinID
233                 } else {
234                         i := 20 - newStatusesLen - 1
235                         if len(statuses) > i {
236                                 hasPrev = true
237                                 prevLink = "/timeline?min_id=" + statuses[i].ID
238                         }
239                 }
240         }
241         if len(pg.MaxID) > 0 {
242                 hasNext = true
243                 nextLink = "/timeline?max_id=" + pg.MaxID
244         }
245
246         navbarData, err := svc.getNavbarTemplateData(ctx, client, c)
247         if err != nil {
248                 return
249         }
250
251         data := renderer.NewTimelinePageTemplateData(statuses, hasNext, nextLink, hasPrev, prevLink, navbarData)
252         err = svc.renderer.RenderTimelinePage(ctx, client, data)
253         if err != nil {
254                 return
255         }
256
257         return
258 }
259
260 func (svc *service) ServeThreadPage(ctx context.Context, client io.Writer, c *mastodon.Client, id string, reply bool) (err error) {
261         status, err := c.GetStatus(ctx, id)
262         if err != nil {
263                 return
264         }
265
266         context, err := c.GetStatusContext(ctx, id)
267         if err != nil {
268                 return
269         }
270
271         u, err := c.GetAccountCurrentUser(ctx)
272         if err != nil {
273                 return
274         }
275
276         var content string
277         if reply {
278                 if u.ID != status.Account.ID {
279                         content += "@" + status.Account.Acct + " "
280                 }
281                 for _, m := range status.Mentions {
282                         if u.ID != m.ID {
283                                 content += "@" + m.Acct + " "
284                         }
285                 }
286         }
287
288         navbarData, err := svc.getNavbarTemplateData(ctx, client, c)
289         if err != nil {
290                 return
291         }
292
293         data := renderer.NewThreadPageTemplateData(status, context, reply, id, content, navbarData)
294         err = svc.renderer.RenderThreadPage(ctx, client, data)
295         if err != nil {
296                 return
297         }
298
299         return
300 }
301
302 func (svc *service) ServeNotificationPage(ctx context.Context, client io.Writer, c *mastodon.Client, maxID string, minID string) (err error) {
303         var hasNext bool
304         var nextLink string
305
306         var pg = mastodon.Pagination{
307                 MaxID: maxID,
308                 MinID: minID,
309                 Limit: 20,
310         }
311
312         notifications, err := c.GetNotifications(ctx, &pg)
313         if err != nil {
314                 return
315         }
316
317         var unreadCount int
318         for i := range notifications {
319                 switch notifications[i].Type {
320                 case "reblog", "favourite":
321                         if notifications[i].Status != nil {
322                                 notifications[i].Status.Account.ID = ""
323                         }
324                 }
325                 if notifications[i].Pleroma != nil && notifications[i].Pleroma.IsSeen {
326                         unreadCount++
327                 }
328         }
329
330         if unreadCount > 0 {
331                 err := c.ReadNotifications(ctx, notifications[0].ID)
332                 if err != nil {
333                         return err
334                 }
335         }
336
337         if len(pg.MaxID) > 0 {
338                 hasNext = true
339                 nextLink = "/notifications?max_id=" + pg.MaxID
340         }
341
342         navbarData, err := svc.getNavbarTemplateData(ctx, client, c)
343         if err != nil {
344                 return
345         }
346
347         data := renderer.NewNotificationPageTemplateData(notifications, hasNext, nextLink, navbarData)
348         err = svc.renderer.RenderNotificationPage(ctx, client, data)
349         if err != nil {
350                 return
351         }
352
353         return
354 }
355
356 func (svc *service) getNavbarTemplateData(ctx context.Context, client io.Writer, c *mastodon.Client) (data *renderer.NavbarTemplateData, err error) {
357         notifications, err := c.GetNotifications(ctx, nil)
358         if err != nil {
359                 return
360         }
361
362         var notificationCount int
363         for i := range notifications {
364                 if notifications[i].Pleroma != nil && !notifications[i].Pleroma.IsSeen {
365                         notificationCount++
366                 }
367         }
368
369         data = renderer.NewNavbarTemplateData(notificationCount)
370
371         return
372 }
373
374 func (svc *service) Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
375         _, err = c.Favourite(ctx, id)
376         return
377 }
378
379 func (svc *service) UnLike(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
380         _, err = c.Unfavourite(ctx, id)
381         return
382 }
383
384 func (svc *service) Retweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
385         _, err = c.Reblog(ctx, id)
386         return
387 }
388
389 func (svc *service) UnRetweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
390         _, err = c.Unreblog(ctx, id)
391         return
392 }
393
394 func (svc *service) PostTweet(ctx context.Context, client io.Writer, c *mastodon.Client, content string, replyToID string, files []*multipart.FileHeader) (id string, err error) {
395         var mediaIds []string
396         for _, f := range files {
397                 a, err := c.UploadMediaFromMultipartFileHeader(ctx, f)
398                 if err != nil {
399                         return "", err
400                 }
401                 mediaIds = append(mediaIds, a.ID)
402         }
403
404         tweet := &mastodon.Toot{
405                 Status:      content,
406                 InReplyToID: replyToID,
407                 MediaIDs:    mediaIds,
408         }
409
410         s, err := c.PostStatus(ctx, tweet)
411         if err != nil {
412                 return
413         }
414
415         return s.ID, nil
416 }