Use a single form for new posts and replies
[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         ServeUserPage(ctx context.Context, client io.Writer, c *mastodon.Client, id string, maxID string, minID string) (err error)
36         ServeAboutPage(ctx context.Context, client io.Writer, c *mastodon.Client) (err error)
37         Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error)
38         UnLike(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error)
39         Retweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error)
40         UnRetweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error)
41         PostTweet(ctx context.Context, client io.Writer, c *mastodon.Client, content string, replyToID string, files []*multipart.FileHeader) (id string, err error)
42         Follow(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error)
43         UnFollow(ctx context.Context, client io.Writer, c *mastodon.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 *mastodon.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 *mastodon.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         navbarData, err := svc.getNavbarTemplateData(ctx, client, c)
255         if err != nil {
256                 return
257         }
258
259         data := renderer.NewTimelinePageTemplateData(statuses, hasNext, nextLink, hasPrev, prevLink, navbarData)
260         err = svc.renderer.RenderTimelinePage(ctx, client, data)
261         if err != nil {
262                 return
263         }
264
265         return
266 }
267
268 func (svc *service) ServeThreadPage(ctx context.Context, client io.Writer, c *mastodon.Client, id string, reply bool) (err error) {
269         status, err := c.GetStatus(ctx, id)
270         if err != nil {
271                 return
272         }
273
274         u, err := c.GetAccountCurrentUser(ctx)
275         if err != nil {
276                 return
277         }
278
279         var replyContext *model.ReplyContext
280         if reply {
281                 var content string
282                 if u.ID != status.Account.ID {
283                         content += "@" + status.Account.Acct + " "
284                 }
285                 for i := range status.Mentions {
286                         if status.Mentions[i].ID != u.ID && status.Mentions[i].ID != status.Account.ID {
287                                 content += "@" + status.Mentions[i].Acct + " "
288                         }
289                 }
290                 replyContext = &model.ReplyContext{
291                         InReplyToID:   id,
292                         InReplyToName: status.Account.Acct,
293                         ReplyContent:  content,
294                 }
295         }
296
297         context, err := c.GetStatusContext(ctx, id)
298         if err != nil {
299                 return
300         }
301
302         statuses := append(append(context.Ancestors, status), context.Descendants...)
303
304         replyMap := make(map[string][]mastodon.ReplyInfo)
305
306         for i := range statuses {
307                 statuses[i].ShowReplies = true
308                 statuses[i].ReplyMap = replyMap
309                 addToReplyMap(replyMap, statuses[i].InReplyToID, statuses[i].ID, i+1)
310         }
311
312         navbarData, err := svc.getNavbarTemplateData(ctx, client, c)
313         if err != nil {
314                 return
315         }
316
317         data := renderer.NewThreadPageTemplateData(statuses, replyContext, replyMap, navbarData)
318         err = svc.renderer.RenderThreadPage(ctx, client, data)
319         if err != nil {
320                 return
321         }
322
323         return
324 }
325
326 func (svc *service) ServeNotificationPage(ctx context.Context, client io.Writer, c *mastodon.Client, maxID string, minID string) (err error) {
327         var hasNext bool
328         var nextLink string
329
330         var pg = mastodon.Pagination{
331                 MaxID: maxID,
332                 MinID: minID,
333                 Limit: 20,
334         }
335
336         notifications, err := c.GetNotifications(ctx, &pg)
337         if err != nil {
338                 return
339         }
340
341         var unreadCount int
342         for i := range notifications {
343                 switch notifications[i].Type {
344                 case "reblog", "favourite":
345                         if notifications[i].Status != nil {
346                                 notifications[i].Status.HideAccountInfo = true
347                         }
348                 }
349                 if notifications[i].Pleroma != nil && notifications[i].Pleroma.IsSeen {
350                         unreadCount++
351                 }
352         }
353
354         if unreadCount > 0 {
355                 err := c.ReadNotifications(ctx, notifications[0].ID)
356                 if err != nil {
357                         return err
358                 }
359         }
360
361         if len(pg.MaxID) > 0 {
362                 hasNext = true
363                 nextLink = "/notifications?max_id=" + pg.MaxID
364         }
365
366         navbarData, err := svc.getNavbarTemplateData(ctx, client, c)
367         if err != nil {
368                 return
369         }
370
371         data := renderer.NewNotificationPageTemplateData(notifications, hasNext, nextLink, navbarData)
372         err = svc.renderer.RenderNotificationPage(ctx, client, data)
373         if err != nil {
374                 return
375         }
376
377         return
378 }
379
380 func (svc *service) ServeUserPage(ctx context.Context, client io.Writer, c *mastodon.Client, id string, maxID string, minID string) (err error) {
381         user, err := c.GetAccount(ctx, id)
382         if err != nil {
383                 return
384         }
385
386         var hasNext bool
387         var nextLink string
388
389         var pg = mastodon.Pagination{
390                 MaxID: maxID,
391                 MinID: minID,
392                 Limit: 20,
393         }
394
395         statuses, err := c.GetAccountStatuses(ctx, id, &pg)
396         if err != nil {
397                 return
398         }
399
400         if len(pg.MaxID) > 0 {
401                 hasNext = true
402                 nextLink = "/user/" + id + "?max_id=" + pg.MaxID
403         }
404
405         navbarData, err := svc.getNavbarTemplateData(ctx, client, c)
406         if err != nil {
407                 return
408         }
409
410         data := renderer.NewUserPageTemplateData(user, statuses, hasNext, nextLink, navbarData)
411         err = svc.renderer.RenderUserPage(ctx, client, data)
412         if err != nil {
413                 return
414         }
415
416         return
417 }
418
419 func (svc *service) ServeAboutPage(ctx context.Context, client io.Writer, c *mastodon.Client) (err error) {
420         navbarData, err := svc.getNavbarTemplateData(ctx, client, c)
421         if err != nil {
422                 return
423         }
424
425         data := renderer.NewAboutPageTemplateData(navbarData)
426         err = svc.renderer.RenderAboutPage(ctx, client, data)
427         if err != nil {
428                 return
429         }
430
431         return
432 }
433
434 func (svc *service) getNavbarTemplateData(ctx context.Context, client io.Writer, c *mastodon.Client) (data *renderer.NavbarTemplateData, err error) {
435         notifications, err := c.GetNotifications(ctx, nil)
436         if err != nil {
437                 return
438         }
439
440         var notificationCount int
441         for i := range notifications {
442                 if notifications[i].Pleroma != nil && !notifications[i].Pleroma.IsSeen {
443                         notificationCount++
444                 }
445         }
446
447         data = renderer.NewNavbarTemplateData(notificationCount)
448
449         return
450 }
451
452 func (svc *service) Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
453         _, err = c.Favourite(ctx, id)
454         return
455 }
456
457 func (svc *service) UnLike(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
458         _, err = c.Unfavourite(ctx, id)
459         return
460 }
461
462 func (svc *service) Retweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
463         _, err = c.Reblog(ctx, id)
464         return
465 }
466
467 func (svc *service) UnRetweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
468         _, err = c.Unreblog(ctx, id)
469         return
470 }
471
472 func (svc *service) PostTweet(ctx context.Context, client io.Writer, c *mastodon.Client, content string, replyToID string, files []*multipart.FileHeader) (id string, err error) {
473         var mediaIds []string
474         for _, f := range files {
475                 a, err := c.UploadMediaFromMultipartFileHeader(ctx, f)
476                 if err != nil {
477                         return "", err
478                 }
479                 mediaIds = append(mediaIds, a.ID)
480         }
481
482         tweet := &mastodon.Toot{
483                 Status:      content,
484                 InReplyToID: replyToID,
485                 MediaIDs:    mediaIds,
486         }
487
488         s, err := c.PostStatus(ctx, tweet)
489         if err != nil {
490                 return
491         }
492
493         return s.ID, nil
494 }
495
496 func (svc *service) Follow(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
497         _, err = c.AccountFollow(ctx, id)
498         return
499 }
500
501 func (svc *service) UnFollow(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) {
502         _, err = c.AccountUnfollow(ctx, id)
503         return
504 }
505
506 func addToReplyMap(m map[string][]mastodon.ReplyInfo, key interface{}, val string, number int) {
507         if key == nil {
508                 return
509         }
510         keyStr, ok := key.(string)
511         if !ok {
512                 return
513         }
514         _, ok = m[keyStr]
515         if !ok {
516                 m[keyStr] = []mastodon.ReplyInfo{}
517         }
518
519         m[keyStr] = append(m[keyStr], mastodon.ReplyInfo{val, number})
520 }