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