22 ErrInvalidArgument = errors.New("invalid argument")
23 ErrInvalidToken = errors.New("invalid token")
24 ErrInvalidClient = errors.New("invalid client")
25 ErrInvalidTimeline = errors.New("invalid timeline")
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 ServeLikedByPage(ctx context.Context, client io.Writer, c *model.Client, id string) (err error)
41 ServeRetweetedByPage(ctx context.Context, client io.Writer, c *model.Client, id string) (err error)
42 ServeFollowingPage(ctx context.Context, client io.Writer, c *model.Client, id string, maxID string, minID string) (err error)
43 ServeFollowersPage(ctx context.Context, client io.Writer, c *model.Client, id string, maxID string, minID string) (err error)
44 ServeSearchPage(ctx context.Context, client io.Writer, c *model.Client, q string, qType string, offset int) (err error)
45 ServeSettingsPage(ctx context.Context, client io.Writer, c *model.Client) (err error)
46 SaveSettings(ctx context.Context, client io.Writer, c *model.Client, settings *model.Settings) (err error)
47 Like(ctx context.Context, client io.Writer, c *model.Client, id string) (count int64, err error)
48 UnLike(ctx context.Context, client io.Writer, c *model.Client, id string) (count int64, err error)
49 Retweet(ctx context.Context, client io.Writer, c *model.Client, id string) (count int64, err error)
50 UnRetweet(ctx context.Context, client io.Writer, c *model.Client, id string) (count int64, err error)
51 PostTweet(ctx context.Context, client io.Writer, c *model.Client, content string, replyToID string, format string, visibility string, isNSFW bool, files []*multipart.FileHeader) (id string, err error)
52 Follow(ctx context.Context, client io.Writer, c *model.Client, id string) (err error)
53 UnFollow(ctx context.Context, client io.Writer, c *model.Client, id string) (err error)
61 postFormats []model.PostFormat
62 renderer renderer.Renderer
63 sessionRepo model.SessionRepository
64 appRepo model.AppRepository
67 func NewService(clientName string, clientScope string, clientWebsite string,
68 customCSS string, postFormats []model.PostFormat, renderer renderer.Renderer,
69 sessionRepo model.SessionRepository, appRepo model.AppRepository) Service {
71 clientName: clientName,
72 clientScope: clientScope,
73 clientWebsite: clientWebsite,
75 postFormats: postFormats,
77 sessionRepo: sessionRepo,
82 func (svc *service) GetAuthUrl(ctx context.Context, instance string) (
83 redirectUrl string, sessionID string, err error) {
84 var instanceURL string
85 if strings.HasPrefix(instance, "https://") {
86 instanceURL = instance
87 instance = strings.TrimPrefix(instance, "https://")
89 instanceURL = "https://" + instance
92 sessionID = util.NewSessionId()
93 session := model.Session{
95 InstanceDomain: instance,
96 Settings: *model.NewSettings(),
98 err = svc.sessionRepo.Add(session)
103 app, err := svc.appRepo.Get(instance)
105 if err != model.ErrAppNotFound {
109 var mastoApp *mastodon.Application
110 mastoApp, err = mastodon.RegisterApp(ctx, &mastodon.AppConfig{
112 ClientName: svc.clientName,
113 Scopes: svc.clientScope,
114 Website: svc.clientWebsite,
115 RedirectURIs: svc.clientWebsite + "/oauth_callback",
122 InstanceDomain: instance,
123 InstanceURL: instanceURL,
124 ClientID: mastoApp.ClientID,
125 ClientSecret: mastoApp.ClientSecret,
128 err = svc.appRepo.Add(app)
134 u, err := url.Parse("/oauth/authorize")
139 q := make(url.Values)
140 q.Set("scope", "read write follow")
141 q.Set("client_id", app.ClientID)
142 q.Set("response_type", "code")
143 q.Set("redirect_uri", svc.clientWebsite+"/oauth_callback")
144 u.RawQuery = q.Encode()
146 redirectUrl = instanceURL + u.String()
151 func (svc *service) GetUserToken(ctx context.Context, sessionID string, c *model.Client,
152 code string) (token string, err error) {
154 err = ErrInvalidArgument
158 session, err := svc.sessionRepo.Get(sessionID)
163 app, err := svc.appRepo.Get(session.InstanceDomain)
168 data := &bytes.Buffer{}
169 err = json.NewEncoder(data).Encode(map[string]string{
170 "client_id": app.ClientID,
171 "client_secret": app.ClientSecret,
172 "grant_type": "authorization_code",
174 "redirect_uri": svc.clientWebsite + "/oauth_callback",
180 resp, err := http.Post(app.InstanceURL+"/oauth/token", "application/json", data)
184 defer resp.Body.Close()
187 AccessToken string `json:"access_token"`
190 err = json.NewDecoder(resp.Body).Decode(&res)
195 err = c.AuthenticateToken(ctx, code, svc.clientWebsite+"/oauth_callback")
199 err = svc.sessionRepo.Update(sessionID, c.GetAccessToken(ctx))
202 return res.AccessToken, nil
205 func (svc *service) ServeHomePage(ctx context.Context, client io.Writer) (err error) {
206 commonData, err := svc.getCommonData(ctx, client, nil, "home")
211 data := &renderer.HomePageData{
212 CommonData: commonData,
215 return svc.renderer.RenderHomePage(ctx, client, data)
218 func (svc *service) ServeErrorPage(ctx context.Context, client io.Writer, err error) {
224 commonData, err := svc.getCommonData(ctx, client, nil, "error")
229 data := &renderer.ErrorData{
230 CommonData: commonData,
234 svc.renderer.RenderErrorPage(ctx, client, data)
237 func (svc *service) ServeSigninPage(ctx context.Context, client io.Writer) (err error) {
238 commonData, err := svc.getCommonData(ctx, client, nil, "signin")
243 data := &renderer.SigninData{
244 CommonData: commonData,
247 return svc.renderer.RenderSigninPage(ctx, client, data)
250 func (svc *service) ServeTimelinePage(ctx context.Context, client io.Writer,
251 c *model.Client, timelineType string, maxID string, sinceID string, minID string) (err error) {
253 var hasNext, hasPrev bool
254 var nextLink, prevLink string
256 var pg = mastodon.Pagination{
262 var statuses []*mastodon.Status
264 switch timelineType {
266 return ErrInvalidTimeline
268 statuses, err = c.GetTimelineHome(ctx, &pg)
271 statuses, err = c.GetTimelinePublic(ctx, true, &pg)
272 title = "Local Timeline"
274 statuses, err = c.GetTimelinePublic(ctx, false, &pg)
275 title = "The Whole Known Network"
281 for i := range statuses {
282 statuses[i].ThreadInNewTab = c.Session.Settings.ThreadInNewTab
283 statuses[i].MaskNSFW = c.Session.Settings.MaskNSFW
284 if statuses[i].Reblog != nil {
285 statuses[i].Reblog.RetweetedByID = statuses[i].ID
286 statuses[i].Reblog.ThreadInNewTab = c.Session.Settings.ThreadInNewTab
287 statuses[i].Reblog.MaskNSFW = c.Session.Settings.MaskNSFW
291 if len(maxID) > 0 && len(statuses) > 0 {
293 prevLink = fmt.Sprintf("/timeline/$s?min_id=%s", timelineType, statuses[0].ID)
295 if len(minID) > 0 && len(pg.MinID) > 0 {
296 newStatuses, err := c.GetTimelineHome(ctx, &mastodon.Pagination{MinID: pg.MinID, Limit: 20})
300 newStatusesLen := len(newStatuses)
301 if newStatusesLen == 20 {
303 prevLink = fmt.Sprintf("/timeline/%s?min_id=%s", timelineType, pg.MinID)
305 i := 20 - newStatusesLen - 1
306 if len(statuses) > i {
308 prevLink = fmt.Sprintf("/timeline/%s?min_id=%s", timelineType, statuses[i].ID)
312 if len(pg.MaxID) > 0 {
314 nextLink = fmt.Sprintf("/timeline/%s?max_id=%s", timelineType, pg.MaxID)
317 postContext := model.PostContext{
318 DefaultVisibility: c.Session.Settings.DefaultVisibility,
319 Formats: svc.postFormats,
322 commonData, err := svc.getCommonData(ctx, client, c, timelineType+" timeline ")
327 data := &renderer.TimelineData{
334 PostContext: postContext,
335 CommonData: commonData,
338 err = svc.renderer.RenderTimelinePage(ctx, client, data)
346 func (svc *service) ServeThreadPage(ctx context.Context, client io.Writer, c *model.Client, id string, reply bool) (err error) {
347 status, err := c.GetStatus(ctx, id)
352 u, err := c.GetAccountCurrentUser(ctx)
357 var postContext model.PostContext
360 if u.ID != status.Account.ID {
361 content += "@" + status.Account.Acct + " "
363 for i := range status.Mentions {
364 if status.Mentions[i].ID != u.ID && status.Mentions[i].ID != status.Account.ID {
365 content += "@" + status.Mentions[i].Acct + " "
369 var visibility string
370 if c.Session.Settings.CopyScope {
371 s, err := c.GetStatus(ctx, id)
375 visibility = s.Visibility
377 visibility = c.Session.Settings.DefaultVisibility
380 postContext = model.PostContext{
381 DefaultVisibility: visibility,
382 Formats: svc.postFormats,
383 ReplyContext: &model.ReplyContext{
385 InReplyToName: status.Account.Acct,
386 ReplyContent: content,
391 context, err := c.GetStatusContext(ctx, id)
396 statuses := append(append(context.Ancestors, status), context.Descendants...)
398 replyMap := make(map[string][]mastodon.ReplyInfo)
400 for i := range statuses {
401 statuses[i].ShowReplies = true
402 statuses[i].ReplyMap = replyMap
403 statuses[i].MaskNSFW = c.Session.Settings.MaskNSFW
404 addToReplyMap(replyMap, statuses[i].InReplyToID, statuses[i].ID, i+1)
407 commonData, err := svc.getCommonData(ctx, client, c, "post by "+status.Account.DisplayName)
412 data := &renderer.ThreadData{
414 PostContext: postContext,
416 CommonData: commonData,
419 err = svc.renderer.RenderThreadPage(ctx, client, data)
427 func (svc *service) ServeNotificationPage(ctx context.Context, client io.Writer, c *model.Client, maxID string, minID string) (err error) {
431 var pg = mastodon.Pagination{
437 notifications, err := c.GetNotifications(ctx, &pg)
443 for i := range notifications {
444 if notifications[i].Status != nil {
445 notifications[i].Status.CreatedAt = notifications[i].CreatedAt
446 notifications[i].Status.MaskNSFW = c.Session.Settings.MaskNSFW
447 switch notifications[i].Type {
448 case "reblog", "favourite":
449 notifications[i].Status.HideAccountInfo = true
452 if notifications[i].Pleroma != nil && notifications[i].Pleroma.IsSeen {
458 err := c.ReadNotifications(ctx, notifications[0].ID)
464 if len(pg.MaxID) > 0 {
466 nextLink = "/notifications?max_id=" + pg.MaxID
469 commonData, err := svc.getCommonData(ctx, client, c, "notifications")
474 data := &renderer.NotificationData{
475 Notifications: notifications,
478 CommonData: commonData,
480 err = svc.renderer.RenderNotificationPage(ctx, client, data)
488 func (svc *service) ServeUserPage(ctx context.Context, client io.Writer, c *model.Client, id string, maxID string, minID string) (err error) {
489 user, err := c.GetAccount(ctx, id)
497 var pg = mastodon.Pagination{
503 statuses, err := c.GetAccountStatuses(ctx, id, &pg)
508 for i := range statuses {
509 statuses[i].MaskNSFW = c.Session.Settings.MaskNSFW
510 if statuses[i].Reblog != nil {
511 statuses[i].Reblog.MaskNSFW = c.Session.Settings.MaskNSFW
515 if len(pg.MaxID) > 0 {
517 nextLink = "/user/" + id + "?max_id=" + pg.MaxID
520 commonData, err := svc.getCommonData(ctx, client, c, user.DisplayName)
525 data := &renderer.UserData{
530 CommonData: commonData,
533 err = svc.renderer.RenderUserPage(ctx, client, data)
541 func (svc *service) ServeAboutPage(ctx context.Context, client io.Writer, c *model.Client) (err error) {
542 commonData, err := svc.getCommonData(ctx, client, c, "about")
547 data := &renderer.AboutData{
548 CommonData: commonData,
550 err = svc.renderer.RenderAboutPage(ctx, client, data)
558 func (svc *service) ServeEmojiPage(ctx context.Context, client io.Writer, c *model.Client) (err error) {
559 commonData, err := svc.getCommonData(ctx, client, c, "emojis")
564 emojis, err := c.GetInstanceEmojis(ctx)
569 data := &renderer.EmojiData{
571 CommonData: commonData,
574 err = svc.renderer.RenderEmojiPage(ctx, client, data)
582 func (svc *service) ServeLikedByPage(ctx context.Context, client io.Writer, c *model.Client, id string) (err error) {
583 likers, err := c.GetFavouritedBy(ctx, id, nil)
588 commonData, err := svc.getCommonData(ctx, client, c, "likes")
593 data := &renderer.LikedByData{
594 CommonData: commonData,
598 err = svc.renderer.RenderLikedByPage(ctx, client, data)
606 func (svc *service) ServeRetweetedByPage(ctx context.Context, client io.Writer, c *model.Client, id string) (err error) {
607 retweeters, err := c.GetRebloggedBy(ctx, id, nil)
612 commonData, err := svc.getCommonData(ctx, client, c, "retweets")
617 data := &renderer.RetweetedByData{
618 CommonData: commonData,
622 err = svc.renderer.RenderRetweetedByPage(ctx, client, data)
630 func (svc *service) ServeFollowingPage(ctx context.Context, client io.Writer, c *model.Client, id string, maxID string, minID string) (err error) {
634 var pg = mastodon.Pagination{
640 followings, err := c.GetAccountFollowing(ctx, id, &pg)
645 if len(followings) == 20 && len(pg.MaxID) > 0 {
647 nextLink = "/following/" + id + "?max_id=" + pg.MaxID
650 commonData, err := svc.getCommonData(ctx, client, c, "following")
655 data := &renderer.FollowingData{
656 CommonData: commonData,
662 err = svc.renderer.RenderFollowingPage(ctx, client, data)
670 func (svc *service) ServeFollowersPage(ctx context.Context, client io.Writer, c *model.Client, id string, maxID string, minID string) (err error) {
674 var pg = mastodon.Pagination{
680 followers, err := c.GetAccountFollowers(ctx, id, &pg)
685 if len(followers) == 20 && len(pg.MaxID) > 0 {
687 nextLink = "/followers/" + id + "?max_id=" + pg.MaxID
690 commonData, err := svc.getCommonData(ctx, client, c, "followers")
695 data := &renderer.FollowersData{
696 CommonData: commonData,
702 err = svc.renderer.RenderFollowersPage(ctx, client, data)
710 func (svc *service) ServeSearchPage(ctx context.Context, client io.Writer, c *model.Client, q string, qType string, offset int) (err error) {
714 results, err := c.Search(ctx, q, qType, 20, true, offset)
721 hasNext = len(results.Accounts) == 20
723 hasNext = len(results.Statuses) == 20
724 for i := range results.Statuses {
725 results.Statuses[i].MaskNSFW = c.Session.Settings.MaskNSFW
732 nextLink = fmt.Sprintf("/search?q=%s&type=%s&offset=%d", q, qType, offset)
737 title += " \"" + q + "\""
739 commonData, err := svc.getCommonData(ctx, client, c, title)
744 data := &renderer.SearchData{
745 CommonData: commonData,
748 Users: results.Accounts,
749 Statuses: results.Statuses,
754 err = svc.renderer.RenderSearchPage(ctx, client, data)
762 func (svc *service) ServeSettingsPage(ctx context.Context, client io.Writer, c *model.Client) (err error) {
763 commonData, err := svc.getCommonData(ctx, client, c, "settings")
768 data := &renderer.SettingsData{
769 CommonData: commonData,
770 Settings: &c.Session.Settings,
773 err = svc.renderer.RenderSettingsPage(ctx, client, data)
781 func (svc *service) SaveSettings(ctx context.Context, client io.Writer, c *model.Client, settings *model.Settings) (err error) {
782 session, err := svc.sessionRepo.Get(c.Session.ID)
787 session.Settings = *settings
788 err = svc.sessionRepo.Add(session)
796 func (svc *service) getCommonData(ctx context.Context, client io.Writer, c *model.Client, title string) (data *renderer.CommonData, err error) {
797 data = new(renderer.CommonData)
799 data.HeaderData = &renderer.HeaderData{
800 Title: title + " - " + svc.clientName,
801 NotificationCount: 0,
802 CustomCSS: svc.customCSS,
805 if c != nil && c.Session.IsLoggedIn() {
806 notifications, err := c.GetNotifications(ctx, nil)
811 var notificationCount int
812 for i := range notifications {
813 if notifications[i].Pleroma != nil && !notifications[i].Pleroma.IsSeen {
818 u, err := c.GetAccountCurrentUser(ctx)
823 data.NavbarData = &renderer.NavbarData{
825 NotificationCount: notificationCount,
828 data.HeaderData.NotificationCount = notificationCount
829 data.HeaderData.FluorideMode = c.Session.Settings.FluorideMode
835 func (svc *service) Like(ctx context.Context, client io.Writer, c *model.Client, id string) (count int64, err error) {
836 s, err := c.Favourite(ctx, id)
840 count = s.FavouritesCount
844 func (svc *service) UnLike(ctx context.Context, client io.Writer, c *model.Client, id string) (count int64, err error) {
845 s, err := c.Unfavourite(ctx, id)
849 count = s.FavouritesCount
853 func (svc *service) Retweet(ctx context.Context, client io.Writer, c *model.Client, id string) (count int64, err error) {
854 s, err := c.Reblog(ctx, id)
859 count = s.Reblog.ReblogsCount
864 func (svc *service) UnRetweet(ctx context.Context, client io.Writer, c *model.Client, id string) (count int64, err error) {
865 s, err := c.Unreblog(ctx, id)
869 count = s.ReblogsCount
873 func (svc *service) PostTweet(ctx context.Context, client io.Writer, c *model.Client, content string, replyToID string, format string, visibility string, isNSFW bool, files []*multipart.FileHeader) (id string, err error) {
874 var mediaIds []string
875 for _, f := range files {
876 a, err := c.UploadMediaFromMultipartFileHeader(ctx, f)
880 mediaIds = append(mediaIds, a.ID)
883 tweet := &mastodon.Toot{
885 InReplyToID: replyToID,
888 Visibility: visibility,
892 s, err := c.PostStatus(ctx, tweet)
900 func (svc *service) Follow(ctx context.Context, client io.Writer, c *model.Client, id string) (err error) {
901 _, err = c.AccountFollow(ctx, id)
905 func (svc *service) UnFollow(ctx context.Context, client io.Writer, c *model.Client, id string) (err error) {
906 _, err = c.AccountUnfollow(ctx, id)
910 func addToReplyMap(m map[string][]mastodon.ReplyInfo, key interface{}, val string, number int) {
915 keyStr, ok := key.(string)
921 m[keyStr] = []mastodon.ReplyInfo{}
924 m[keyStr] = append(m[keyStr], mastodon.ReplyInfo{val, number})