From 5e4da01c3ae3ae2e870faba9085d9d9213c01c29 Mon Sep 17 00:00:00 2001 From: r Date: Fri, 13 Dec 2019 18:08:26 +0000 Subject: Initial commit --- service/auth.go | 151 +++++++++++++++++++++++++++ service/logging.go | 117 +++++++++++++++++++++ service/service.go | 285 +++++++++++++++++++++++++++++++++++++++++++++++++++ service/transport.go | 165 +++++++++++++++++++++++++++++ 4 files changed, 718 insertions(+) create mode 100644 service/auth.go create mode 100644 service/logging.go create mode 100644 service/service.go create mode 100644 service/transport.go (limited to 'service') diff --git a/service/auth.go b/service/auth.go new file mode 100644 index 0000000..cb442a7 --- /dev/null +++ b/service/auth.go @@ -0,0 +1,151 @@ +package service + +import ( + "context" + "errors" + "io" + "mastodon" + "web/model" +) + +var ( + ErrInvalidSession = errors.New("invalid session") +) + +type authService struct { + sessionRepo model.SessionRepository + appRepo model.AppRepository + Service +} + +func NewAuthService(sessionRepo model.SessionRepository, appRepo model.AppRepository, s Service) Service { + return &authService{sessionRepo, appRepo, s} +} + +func getSessionID(ctx context.Context) (sessionID string, err error) { + sessionID, ok := ctx.Value("session_id").(string) + if !ok || len(sessionID) < 1 { + return "", ErrInvalidSession + } + return sessionID, nil +} + +func (s *authService) getClient(ctx context.Context) (c *mastodon.Client, err error) { + sessionID, err := getSessionID(ctx) + if err != nil { + return nil, ErrInvalidSession + } + session, err := s.sessionRepo.Get(sessionID) + if err != nil { + return nil, ErrInvalidSession + } + client, err := s.appRepo.Get(session.InstanceURL) + if err != nil { + return + } + c = mastodon.NewClient(&mastodon.Config{ + Server: session.InstanceURL, + ClientID: client.ClientID, + ClientSecret: client.ClientSecret, + AccessToken: session.AccessToken, + }) + return c, nil +} + +func (s *authService) GetAuthUrl(ctx context.Context, instance string) ( + redirectUrl string, sessionID string, err error) { + return s.Service.GetAuthUrl(ctx, instance) +} + +func (s *authService) GetUserToken(ctx context.Context, sessionID string, c *mastodon.Client, + code string) (token string, err error) { + sessionID, err = getSessionID(ctx) + if err != nil { + return + } + c, err = s.getClient(ctx) + if err != nil { + return + } + + token, err = s.Service.GetUserToken(ctx, sessionID, c, code) + if err != nil { + return + } + + err = s.sessionRepo.Update(sessionID, token) + if err != nil { + return + } + + return +} + +func (s *authService) ServeHomePage(ctx context.Context, client io.Writer) (err error) { + return s.Service.ServeHomePage(ctx, client) +} + +func (s *authService) ServeErrorPage(ctx context.Context, client io.Writer, err error) { + s.Service.ServeErrorPage(ctx, client, err) +} + +func (s *authService) ServeSigninPage(ctx context.Context, client io.Writer) (err error) { + return s.Service.ServeSigninPage(ctx, client) +} + +func (s *authService) ServeTimelinePage(ctx context.Context, client io.Writer, + c *mastodon.Client, maxID string, sinceID string, minID string) (err error) { + c, err = s.getClient(ctx) + if err != nil { + return + } + return s.Service.ServeTimelinePage(ctx, client, c, maxID, sinceID, minID) +} + +func (s *authService) ServeThreadPage(ctx context.Context, client io.Writer, c *mastodon.Client, id string, reply bool) (err error) { + c, err = s.getClient(ctx) + if err != nil { + return + } + return s.Service.ServeThreadPage(ctx, client, c, id, reply) +} + +func (s *authService) Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + c, err = s.getClient(ctx) + if err != nil { + return + } + return s.Service.Like(ctx, client, c, id) +} + +func (s *authService) UnLike(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + c, err = s.getClient(ctx) + if err != nil { + return + } + return s.Service.UnLike(ctx, client, c, id) +} + +func (s *authService) Retweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + c, err = s.getClient(ctx) + if err != nil { + return + } + return s.Service.Retweet(ctx, client, c, id) +} + +func (s *authService) UnRetweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + c, err = s.getClient(ctx) + if err != nil { + return + } + return s.Service.UnRetweet(ctx, client, c, id) +} + +func (s *authService) PostTweet(ctx context.Context, client io.Writer, c *mastodon.Client, content string, replyToID string) (err error) { + c, err = s.getClient(ctx) + if err != nil { + return + } + return s.Service.PostTweet(ctx, client, c, content, replyToID) +} diff --git a/service/logging.go b/service/logging.go new file mode 100644 index 0000000..b11599e --- /dev/null +++ b/service/logging.go @@ -0,0 +1,117 @@ +package service + +import ( + "context" + "io" + "log" + "mastodon" + "time" +) + +type loggingService struct { + logger *log.Logger + Service +} + +func NewLoggingService(logger *log.Logger, s Service) Service { + return &loggingService{logger, s} +} + +func (s *loggingService) GetAuthUrl(ctx context.Context, instance string) ( + redirectUrl string, sessionID string, err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, instance=%v, took=%v, err=%v\n", + "GetAuthUrl", instance, time.Since(begin), err) + }(time.Now()) + return s.Service.GetAuthUrl(ctx, instance) +} + +func (s *loggingService) GetUserToken(ctx context.Context, sessionID string, c *mastodon.Client, + code string) (token string, err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, session_id=%v, code=%v, took=%v, err=%v\n", + "GetUserToken", sessionID, code, time.Since(begin), err) + }(time.Now()) + return s.Service.GetUserToken(ctx, sessionID, c, code) +} + +func (s *loggingService) ServeHomePage(ctx context.Context, client io.Writer) (err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, took=%v, err=%v\n", + "ServeHomePage", time.Since(begin), err) + }(time.Now()) + return s.Service.ServeHomePage(ctx, client) +} + +func (s *loggingService) ServeErrorPage(ctx context.Context, client io.Writer, err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, err=%v, took=%v\n", + "ServeErrorPage", err, time.Since(begin)) + }(time.Now()) + s.Service.ServeErrorPage(ctx, client, err) +} + +func (s *loggingService) ServeSigninPage(ctx context.Context, client io.Writer) (err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, took=%v, err=%v\n", + "ServeSigninPage", time.Since(begin), err) + }(time.Now()) + return s.Service.ServeSigninPage(ctx, client) +} + +func (s *loggingService) ServeTimelinePage(ctx context.Context, client io.Writer, + c *mastodon.Client, maxID string, sinceID string, minID string) (err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, max_id=%v, since_id=%v, min_id=%v, took=%v, err=%v\n", + "ServeTimelinePage", maxID, sinceID, minID, time.Since(begin), err) + }(time.Now()) + return s.Service.ServeTimelinePage(ctx, client, c, maxID, sinceID, minID) +} + +func (s *loggingService) ServeThreadPage(ctx context.Context, client io.Writer, c *mastodon.Client, id string, reply bool) (err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, id=%v, reply=%v, took=%v, err=%v\n", + "ServeThreadPage", id, reply, time.Since(begin), err) + }(time.Now()) + return s.Service.ServeThreadPage(ctx, client, c, id, reply) +} + +func (s *loggingService) Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, id=%v, took=%v, err=%v\n", + "Like", id, time.Since(begin), err) + }(time.Now()) + return s.Service.Like(ctx, client, c, id) +} + +func (s *loggingService) UnLike(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, id=%v, took=%v, err=%v\n", + "UnLike", id, time.Since(begin), err) + }(time.Now()) + return s.Service.UnLike(ctx, client, c, id) +} + +func (s *loggingService) Retweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, id=%v, took=%v, err=%v\n", + "Retweet", id, time.Since(begin), err) + }(time.Now()) + return s.Service.Retweet(ctx, client, c, id) +} + +func (s *loggingService) UnRetweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, id=%v, took=%v, err=%v\n", + "UnRetweet", id, time.Since(begin), err) + }(time.Now()) + return s.Service.UnRetweet(ctx, client, c, id) +} + +func (s *loggingService) PostTweet(ctx context.Context, client io.Writer, c *mastodon.Client, content string, replyToID string) (err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, content=%v, reply_to_id=%v, took=%v, err=%v\n", + "PostTweet", content, replyToID, time.Since(begin), err) + }(time.Now()) + return s.Service.PostTweet(ctx, client, c, content, replyToID) +} diff --git a/service/service.go b/service/service.go new file mode 100644 index 0000000..7088a9b --- /dev/null +++ b/service/service.go @@ -0,0 +1,285 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + + "mastodon" + "web/model" + "web/renderer" + "web/util" +) + +var ( + ErrInvalidArgument = errors.New("invalid argument") + ErrInvalidToken = errors.New("invalid token") + ErrInvalidClient = errors.New("invalid client") +) + +type Service interface { + ServeHomePage(ctx context.Context, client io.Writer) (err error) + GetAuthUrl(ctx context.Context, instance string) (url string, sessionID string, err error) + GetUserToken(ctx context.Context, sessionID string, c *mastodon.Client, token string) (accessToken string, err error) + ServeErrorPage(ctx context.Context, client io.Writer, err error) + ServeSigninPage(ctx context.Context, client io.Writer) (err error) + ServeTimelinePage(ctx context.Context, client io.Writer, c *mastodon.Client, maxID string, sinceID string, minID string) (err error) + ServeThreadPage(ctx context.Context, client io.Writer, c *mastodon.Client, id string, reply bool) (err error) + Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) + UnLike(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) + Retweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) + UnRetweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) + PostTweet(ctx context.Context, client io.Writer, c *mastodon.Client, content string, replyToID string) (err error) +} + +type service struct { + clientName string + clientScope string + clientWebsite string + renderer renderer.Renderer + sessionRepo model.SessionRepository + appRepo model.AppRepository +} + +func NewService(clientName string, clientScope string, clientWebsite string, + renderer renderer.Renderer, sessionRepo model.SessionRepository, + appRepo model.AppRepository) Service { + return &service{ + clientName: clientName, + clientScope: clientScope, + clientWebsite: clientWebsite, + renderer: renderer, + sessionRepo: sessionRepo, + appRepo: appRepo, + } +} + +func (svc *service) GetAuthUrl(ctx context.Context, instance string) ( + redirectUrl string, sessionID string, err error) { + if !strings.HasPrefix(instance, "https://") { + instance = "https://" + instance + } + + sessionID = util.NewSessionId() + err = svc.sessionRepo.Add(model.Session{ + ID: sessionID, + InstanceURL: instance, + }) + if err != nil { + return + } + + app, err := svc.appRepo.Get(instance) + if err != nil { + if err != model.ErrAppNotFound { + return + } + + var mastoApp *mastodon.Application + mastoApp, err = mastodon.RegisterApp(ctx, &mastodon.AppConfig{ + Server: instance, + ClientName: svc.clientName, + Scopes: svc.clientScope, + Website: svc.clientWebsite, + RedirectURIs: svc.clientWebsite + "/oauth_callback", + }) + if err != nil { + return + } + + app = model.App{ + InstanceURL: instance, + ClientID: mastoApp.ClientID, + ClientSecret: mastoApp.ClientSecret, + } + + err = svc.appRepo.Add(app) + if err != nil { + return + } + } + + u, err := url.Parse(path.Join(instance, "/oauth/authorize")) + if err != nil { + return + } + + q := make(url.Values) + q.Set("scope", "read write follow") + q.Set("client_id", app.ClientID) + q.Set("response_type", "code") + q.Set("redirect_uri", svc.clientWebsite+"/oauth_callback") + u.RawQuery = q.Encode() + + redirectUrl = u.String() + + return +} + +func (svc *service) GetUserToken(ctx context.Context, sessionID string, c *mastodon.Client, + code string) (token string, err error) { + if len(code) < 1 { + err = ErrInvalidArgument + return + } + + session, err := svc.sessionRepo.Get(sessionID) + if err != nil { + return + } + + app, err := svc.appRepo.Get(session.InstanceURL) + if err != nil { + return + } + + data := &bytes.Buffer{} + err = json.NewEncoder(data).Encode(map[string]string{ + "client_id": app.ClientID, + "client_secret": app.ClientSecret, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": svc.clientWebsite + "/oauth_callback", + }) + if err != nil { + return + } + + resp, err := http.Post(app.InstanceURL+"/oauth/token", "application/json", data) + if err != nil { + return + } + defer resp.Body.Close() + + var res struct { + AccessToken string `json:"access_token"` + } + + err = json.NewDecoder(resp.Body).Decode(&res) + if err != nil { + return + } + /* + err = c.AuthenticateToken(ctx, code, svc.clientWebsite+"/oauth_callback") + if err != nil { + return + } + err = svc.sessionRepo.Update(sessionID, c.GetAccessToken(ctx)) + */ + + return res.AccessToken, nil +} + +func (svc *service) ServeHomePage(ctx context.Context, client io.Writer) (err error) { + err = svc.renderer.RenderHomePage(ctx, client) + if err != nil { + return + } + + return +} + +func (svc *service) ServeErrorPage(ctx context.Context, client io.Writer, err error) { + svc.renderer.RenderErrorPage(ctx, client, err) +} + +func (svc *service) ServeSigninPage(ctx context.Context, client io.Writer) (err error) { + err = svc.renderer.RenderSigninPage(ctx, client) + if err != nil { + return + } + + return +} + +func (svc *service) ServeTimelinePage(ctx context.Context, client io.Writer, + c *mastodon.Client, maxID string, sinceID string, minID string) (err error) { + + var hasNext, hasPrev bool + var nextLink, prevLink string + + var pg = mastodon.Pagination{ + MaxID: maxID, + SinceID: sinceID, + MinID: minID, + Limit: 20, + } + + statuses, err := c.GetTimelineHome(ctx, &pg) + if err != nil { + return err + } + + if len(pg.MaxID) > 0 { + hasNext = true + nextLink = fmt.Sprintf("/timeline?max_id=%s", pg.MaxID) + } + if len(pg.SinceID) > 0 { + hasPrev = true + prevLink = fmt.Sprintf("/timeline?since_id=%s", pg.SinceID) + } + + data := renderer.NewTimelinePageTemplateData(statuses, hasNext, nextLink, hasPrev, prevLink) + err = svc.renderer.RenderTimelinePage(ctx, client, data) + if err != nil { + return + } + + return +} + +func (svc *service) ServeThreadPage(ctx context.Context, client io.Writer, c *mastodon.Client, id string, reply bool) (err error) { + status, err := c.GetStatus(ctx, id) + if err != nil { + return + } + + context, err := c.GetStatusContext(ctx, id) + if err != nil { + return + } + + data := renderer.NewThreadPageTemplateData(status, context, reply, id) + err = svc.renderer.RenderThreadPage(ctx, client, data) + if err != nil { + return + } + + return +} + +func (svc *service) Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + _, err = c.Favourite(ctx, id) + return +} + +func (svc *service) UnLike(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + _, err = c.Unfavourite(ctx, id) + return +} + +func (svc *service) Retweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + _, err = c.Reblog(ctx, id) + return +} + +func (svc *service) UnRetweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + _, err = c.Unreblog(ctx, id) + return +} + +func (svc *service) PostTweet(ctx context.Context, client io.Writer, c *mastodon.Client, content string, replyToID string) (err error) { + tweet := &mastodon.Toot{ + Status: content, + InReplyToID: replyToID, + } + _, err = c.PostStatus(ctx, tweet) + return +} diff --git a/service/transport.go b/service/transport.go new file mode 100644 index 0000000..f4f5ed7 --- /dev/null +++ b/service/transport.go @@ -0,0 +1,165 @@ +package service + +import ( + "context" + "fmt" + "net/http" + "path" + + "github.com/gorilla/mux" +) + +var ( + ctx = context.Background() + cookieAge = "31536000" +) + +func getContextWithSession(ctx context.Context, req *http.Request) context.Context { + sessionID, err := req.Cookie("session_id") + if err != nil { + return ctx + } + return context.WithValue(ctx, "session_id", sessionID.Value) +} + +func NewHandler(s Service, staticDir string) http.Handler { + r := mux.NewRouter() + + r.PathPrefix("/static").Handler(http.StripPrefix("/static", + http.FileServer(http.Dir(path.Join(".", staticDir))))) + + r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + err := s.ServeHomePage(ctx, w) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + }).Methods(http.MethodGet) + + r.HandleFunc("/signin", func(w http.ResponseWriter, req *http.Request) { + err := s.ServeSigninPage(ctx, w) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + }).Methods(http.MethodGet) + + r.HandleFunc("/signin", func(w http.ResponseWriter, req *http.Request) { + instance := req.FormValue("instance") + url, sessionId, err := s.GetAuthUrl(ctx, instance) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + + w.Header().Add("Set-Cookie", fmt.Sprintf("session_id=%s;max-age=%s", sessionId, cookieAge)) + w.Header().Add("Location", url) + w.WriteHeader(http.StatusSeeOther) + }).Methods(http.MethodPost) + + r.HandleFunc("/oauth_callback", func(w http.ResponseWriter, req *http.Request) { + ctx := getContextWithSession(context.Background(), req) + token := req.URL.Query().Get("code") + _, err := s.GetUserToken(ctx, "", nil, token) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + + w.Header().Add("Location", "/timeline") + w.WriteHeader(http.StatusSeeOther) + }).Methods(http.MethodGet) + + r.HandleFunc("/timeline", func(w http.ResponseWriter, req *http.Request) { + ctx := getContextWithSession(context.Background(), req) + + maxID := req.URL.Query().Get("max_id") + sinceID := req.URL.Query().Get("since_id") + minID := req.URL.Query().Get("min_id") + + err := s.ServeTimelinePage(ctx, w, nil, maxID, sinceID, minID) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + }).Methods(http.MethodGet) + + r.HandleFunc("/thread/{id}", func(w http.ResponseWriter, req *http.Request) { + ctx := getContextWithSession(context.Background(), req) + id, _ := mux.Vars(req)["id"] + reply := req.URL.Query().Get("reply") + err := s.ServeThreadPage(ctx, w, nil, id, len(reply) > 1) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + }).Methods(http.MethodGet) + + r.HandleFunc("/like/{id}", func(w http.ResponseWriter, req *http.Request) { + ctx := getContextWithSession(context.Background(), req) + id, _ := mux.Vars(req)["id"] + err := s.Like(ctx, w, nil, id) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + + w.Header().Add("Location", req.Header.Get("Referer")) + w.WriteHeader(http.StatusSeeOther) + }).Methods(http.MethodGet) + + r.HandleFunc("/unlike/{id}", func(w http.ResponseWriter, req *http.Request) { + ctx := getContextWithSession(context.Background(), req) + id, _ := mux.Vars(req)["id"] + err := s.UnLike(ctx, w, nil, id) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + + w.Header().Add("Location", req.Header.Get("Referer")) + w.WriteHeader(http.StatusSeeOther) + }).Methods(http.MethodGet) + + r.HandleFunc("/retweet/{id}", func(w http.ResponseWriter, req *http.Request) { + ctx := getContextWithSession(context.Background(), req) + id, _ := mux.Vars(req)["id"] + err := s.Retweet(ctx, w, nil, id) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + + w.Header().Add("Location", req.Header.Get("Referer")) + w.WriteHeader(http.StatusSeeOther) + }).Methods(http.MethodGet) + + r.HandleFunc("/unretweet/{id}", func(w http.ResponseWriter, req *http.Request) { + ctx := getContextWithSession(context.Background(), req) + id, _ := mux.Vars(req)["id"] + err := s.UnRetweet(ctx, w, nil, id) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + + w.Header().Add("Location", req.Header.Get("Referer")) + w.WriteHeader(http.StatusSeeOther) + }).Methods(http.MethodGet) + + r.HandleFunc("/post", func(w http.ResponseWriter, req *http.Request) { + ctx := getContextWithSession(context.Background(), req) + content := req.FormValue("content") + replyToID := req.FormValue("reply_to_id") + err := s.PostTweet(ctx, w, nil, content, replyToID) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + + w.Header().Add("Location", req.Header.Get("Referer")) + w.WriteHeader(http.StatusSeeOther) + }).Methods(http.MethodPost) + + return r +} -- cgit v1.2.3