Initial commit
authorr <r@freesoftwareextremist.com>
Fri, 13 Dec 2019 18:08:26 +0000 (18:08 +0000)
committerr <r@freesoftwareextremist.com>
Fri, 13 Dec 2019 18:26:24 +0000 (18:26 +0000)
43 files changed:
.gitignore [new file with mode: 0644]
Makefile [new file with mode: 0644]
config/config.go [new file with mode: 0644]
default.conf [new file with mode: 0644]
go.mod [new file with mode: 0644]
go.sum [new file with mode: 0644]
main.go [new file with mode: 0644]
mastodon/LICENSE [new file with mode: 0644]
mastodon/README.md [new file with mode: 0644]
mastodon/accounts.go [new file with mode: 0644]
mastodon/apps.go [new file with mode: 0644]
mastodon/go.mod [new file with mode: 0644]
mastodon/go.sum [new file with mode: 0644]
mastodon/helper.go [new file with mode: 0644]
mastodon/instance.go [new file with mode: 0644]
mastodon/lists.go [new file with mode: 0644]
mastodon/mastodon.go [new file with mode: 0644]
mastodon/notification.go [new file with mode: 0644]
mastodon/report.go [new file with mode: 0644]
mastodon/status.go [new file with mode: 0644]
mastodon/streaming.go [new file with mode: 0644]
mastodon/streaming_ws.go [new file with mode: 0644]
mastodon/unixtime.go [new file with mode: 0644]
model/app.go [new file with mode: 0644]
model/session.go [new file with mode: 0644]
renderer/model.go [new file with mode: 0644]
renderer/renderer.go [new file with mode: 0644]
repository/appRepository.go [new file with mode: 0644]
repository/sessionRepository.go [new file with mode: 0644]
service/auth.go [new file with mode: 0644]
service/logging.go [new file with mode: 0644]
service/service.go [new file with mode: 0644]
service/transport.go [new file with mode: 0644]
static/main.css [new file with mode: 0644]
templates/error.tmpl [new file with mode: 0644]
templates/footer.tmpl [new file with mode: 0644]
templates/header.tmpl [new file with mode: 0644]
templates/homepage.tmpl [new file with mode: 0644]
templates/signin.tmpl [new file with mode: 0644]
templates/status.tmpl [new file with mode: 0644]
templates/thread.tmpl [new file with mode: 0644]
templates/timeline.tmpl [new file with mode: 0644]
util/rand.go [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..bb908cd
--- /dev/null
@@ -0,0 +1,2 @@
+web
+database.db
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..ba2ef80
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,14 @@
+.POSIX:
+
+GO=go
+#GOFLAGS=-mod=vendor
+
+all: web
+
+PHONY:
+
+web: main.go PHONY
+       $(GO) build $(GOFLAGS) -o web main.go
+
+run: web
+       ./web
diff --git a/config/config.go b/config/config.go
new file mode 100644 (file)
index 0000000..844672a
--- /dev/null
@@ -0,0 +1,113 @@
+package config
+
+import (
+       "bufio"
+       "errors"
+       "io"
+       "os"
+       "strings"
+)
+
+type config struct {
+       ListenAddress        string
+       ClientName           string
+       ClientScope          string
+       ClientWebsite        string
+       StaticDirectory      string
+       TemplatesGlobPattern string
+       DatabasePath         string
+       Logfile              string
+}
+
+func (c *config) IsValid() bool {
+       if len(c.ListenAddress) < 1 ||
+               len(c.ClientName) < 1 ||
+               len(c.ClientScope) < 1 ||
+               len(c.ClientWebsite) < 1 ||
+               len(c.StaticDirectory) < 1 ||
+               len(c.TemplatesGlobPattern) < 1 ||
+               len(c.DatabasePath) < 1 {
+               return false
+       }
+       return true
+}
+
+func getDefaultConfig() *config {
+       return &config{
+               ListenAddress:        ":8080",
+               ClientName:           "web",
+               ClientScope:          "read write follow",
+               ClientWebsite:        "http://localhost:8080",
+               StaticDirectory:      "static",
+               TemplatesGlobPattern: "templates/*",
+               DatabasePath:         "database.db",
+               Logfile:              "",
+       }
+}
+
+func Parse(r io.Reader) (c *config, err error) {
+       c = getDefaultConfig()
+       scanner := bufio.NewScanner(r)
+       for scanner.Scan() {
+               line := strings.TrimSpace(scanner.Text())
+
+               if len(line) < 1 {
+                       continue
+               }
+
+               index := strings.IndexRune(line, '#')
+               if index == 0 {
+                       continue
+               }
+
+               index = strings.IndexRune(line, '=')
+               if index < 1 {
+                       return nil, errors.New("invalid config key")
+               }
+
+               key := strings.TrimSpace(line[:index])
+               val := strings.TrimSpace(line[index+1 : len(line)])
+
+               switch key {
+               case "listen_address":
+                       c.ListenAddress = val
+               case "client_name":
+                       c.ClientName = val
+               case "client_scope":
+                       c.ClientScope = val
+               case "client_website":
+                       c.ClientWebsite = val
+               case "static_directory":
+                       c.StaticDirectory = val
+               case "templates_glob_pattern":
+                       c.TemplatesGlobPattern = val
+               case "database_path":
+                       c.DatabasePath = val
+               case "logfile":
+                       c.Logfile = val
+               default:
+                       return nil, errors.New("invliad config key " + key)
+               }
+       }
+
+       return
+}
+
+func ParseFile(file string) (c *config, err error) {
+       f, err := os.Open(file)
+       if err != nil {
+               return
+       }
+       defer f.Close()
+
+       info, err := f.Stat()
+       if err != nil {
+               return
+       }
+
+       if info.IsDir() {
+               return nil, errors.New("invalid config file")
+       }
+
+       return Parse(f)
+}
diff --git a/default.conf b/default.conf
new file mode 100644 (file)
index 0000000..00d02a9
--- /dev/null
@@ -0,0 +1,7 @@
+listen_address=:8080
+client_name=web
+client_scope=read write follow
+client_website=http://localhost:8080
+static_directory=static
+templates_glob_pattern=templates/*
+database_path=database.db
diff --git a/go.mod b/go.mod
new file mode 100644 (file)
index 0000000..de1ba89
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,11 @@
+module web
+
+go 1.13
+
+require (
+       github.com/gorilla/mux v1.7.3
+       github.com/mattn/go-sqlite3 v2.0.1+incompatible
+       mastodon v0.0.0-00010101000000-000000000000
+)
+
+replace mastodon => ./mastodon
diff --git a/go.sum b/go.sum
new file mode 100644 (file)
index 0000000..236732d
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,8 @@
+github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
+github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
+github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
+github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/mattn/go-sqlite3 v2.0.1+incompatible h1:xQ15muvnzGBHpIpdrNi1DA5x0+TcBZzsIDwmw9uTHzw=
+github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
+github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y=
+github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE=
diff --git a/main.go b/main.go
new file mode 100644 (file)
index 0000000..d726fed
--- /dev/null
+++ b/main.go
@@ -0,0 +1,76 @@
+package main
+
+import (
+       "database/sql"
+       "log"
+       "math/rand"
+       "net/http"
+       "os"
+       "time"
+
+       "web/config"
+       "web/renderer"
+       "web/repository"
+       "web/service"
+
+       _ "github.com/mattn/go-sqlite3"
+)
+
+func init() {
+       rand.Seed(time.Now().Unix())
+}
+
+func main() {
+       config, err := config.ParseFile("default.conf")
+       if err != nil {
+               log.Fatal(err)
+       }
+
+       if !config.IsValid() {
+               log.Fatal("invalid config")
+       }
+
+       renderer, err := renderer.NewRenderer(config.TemplatesGlobPattern)
+       if err != nil {
+               log.Fatal(err)
+       }
+
+       db, err := sql.Open("sqlite3", config.DatabasePath)
+       if err != nil {
+               log.Fatal(err)
+       }
+       defer db.Close()
+
+       sessionRepo, err := repository.NewSessionRepository(db)
+       if err != nil {
+               log.Fatal(err)
+       }
+
+       appRepo, err := repository.NewAppRepository(db)
+       if err != nil {
+               log.Fatal(err)
+       }
+
+       var logger *log.Logger
+       if len(config.Logfile) < 1 {
+               logger = log.New(os.Stdout, "", log.LstdFlags)
+       } else {
+               lf, err := os.Open(config.Logfile)
+               if err != nil {
+                       log.Fatal(err)
+               }
+               defer lf.Close()
+               logger = log.New(lf, "", log.LstdFlags)
+       }
+
+       s := service.NewService(config.ClientName, config.ClientScope, config.ClientWebsite, renderer, sessionRepo, appRepo)
+       s = service.NewAuthService(sessionRepo, appRepo, s)
+       s = service.NewLoggingService(logger, s)
+       handler := service.NewHandler(s, config.StaticDirectory)
+
+       log.Println("listening on", config.ListenAddress)
+       err = http.ListenAndServe(config.ListenAddress, handler)
+       if err != nil {
+               log.Fatal(err)
+       }
+}
diff --git a/mastodon/LICENSE b/mastodon/LICENSE
new file mode 100644 (file)
index 0000000..42066c7
--- /dev/null
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 Yasuhiro Matsumoto
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/mastodon/README.md b/mastodon/README.md
new file mode 100644 (file)
index 0000000..9be937f
--- /dev/null
@@ -0,0 +1,142 @@
+# go-mastodon
+
+[![Build Status](https://travis-ci.org/mattn/go-mastodon.svg?branch=master)](https://travis-ci.org/mattn/go-mastodon)
+[![Coverage Status](https://coveralls.io/repos/github/mattn/go-mastodon/badge.svg?branch=master)](https://coveralls.io/github/mattn/go-mastodon?branch=master)
+[![GoDoc](https://godoc.org/github.com/mattn/go-mastodon?status.svg)](http://godoc.org/github.com/mattn/go-mastodon)
+[![Go Report Card](https://goreportcard.com/badge/github.com/mattn/go-mastodon)](https://goreportcard.com/report/github.com/mattn/go-mastodon)
+
+## Usage
+
+### Application
+
+```go
+package main
+
+import (
+       "context"
+       "fmt"
+       "log"
+
+       "github.com/mattn/go-mastodon"
+)
+
+func main() {
+       app, err := mastodon.RegisterApp(context.Background(), &mastodon.AppConfig{
+               Server:     "https://mstdn.jp",
+               ClientName: "client-name",
+               Scopes:     "read write follow",
+               Website:    "https://github.com/mattn/go-mastodon",
+       })
+       if err != nil {
+               log.Fatal(err)
+       }
+       fmt.Printf("client-id    : %s\n", app.ClientID)
+       fmt.Printf("client-secret: %s\n", app.ClientSecret)
+}
+```
+
+### Client
+
+```go
+package main
+
+import (
+       "context"
+       "fmt"
+       "log"
+
+       "github.com/mattn/go-mastodon"
+)
+
+func main() {
+       c := mastodon.NewClient(&mastodon.Config{
+               Server:       "https://mstdn.jp",
+               ClientID:     "client-id",
+               ClientSecret: "client-secret",
+       })
+       err := c.Authenticate(context.Background(), "your-email", "your-password")
+       if err != nil {
+               log.Fatal(err)
+       }
+       timeline, err := c.GetTimelineHome(context.Background(), nil)
+       if err != nil {
+               log.Fatal(err)
+       }
+       for i := len(timeline) - 1; i >= 0; i-- {
+               fmt.Println(timeline[i])
+       }
+}
+```
+
+## Status of implementations
+
+* [x] GET /api/v1/accounts/:id
+* [x] GET /api/v1/accounts/verify_credentials
+* [x] PATCH /api/v1/accounts/update_credentials
+* [x] GET /api/v1/accounts/:id/followers
+* [x] GET /api/v1/accounts/:id/following
+* [x] GET /api/v1/accounts/:id/statuses
+* [x] POST /api/v1/accounts/:id/follow
+* [x] POST /api/v1/accounts/:id/unfollow
+* [x] GET /api/v1/accounts/:id/block
+* [x] GET /api/v1/accounts/:id/unblock
+* [x] GET /api/v1/accounts/:id/mute
+* [x] GET /api/v1/accounts/:id/unmute
+* [x] GET /api/v1/accounts/:id/lists
+* [x] GET /api/v1/accounts/relationships
+* [x] GET /api/v1/accounts/search
+* [x] POST /api/v1/apps
+* [x] GET /api/v1/blocks
+* [x] GET /api/v1/favourites
+* [x] GET /api/v1/follow_requests
+* [x] POST /api/v1/follow_requests/:id/authorize
+* [x] POST /api/v1/follow_requests/:id/reject
+* [x] POST /api/v1/follows
+* [x] GET /api/v1/instance
+* [x] GET /api/v1/instance/activity
+* [x] GET /api/v1/instance/peers
+* [x] GET /api/v1/lists
+* [x] GET /api/v1/lists/:id/accounts
+* [x] GET /api/v1/lists/:id
+* [x] POST /api/v1/lists
+* [x] PUT /api/v1/lists/:id
+* [x] DELETE /api/v1/lists/:id
+* [x] POST /api/v1/lists/:id/accounts
+* [x] DELETE /api/v1/lists/:id/accounts
+* [x] POST /api/v1/media
+* [x] GET /api/v1/mutes
+* [x] GET /api/v1/notifications
+* [x] GET /api/v1/notifications/:id
+* [x] POST /api/v1/notifications/clear
+* [x] GET /api/v1/reports
+* [x] POST /api/v1/reports
+* [x] GET /api/v1/search
+* [x] GET /api/v1/statuses/:id
+* [x] GET /api/v1/statuses/:id/context
+* [x] GET /api/v1/statuses/:id/card
+* [x] GET /api/v1/statuses/:id/reblogged_by
+* [x] GET /api/v1/statuses/:id/favourited_by
+* [x] POST /api/v1/statuses
+* [x] DELETE /api/v1/statuses/:id
+* [x] POST /api/v1/statuses/:id/reblog
+* [x] POST /api/v1/statuses/:id/unreblog
+* [x] POST /api/v1/statuses/:id/favourite
+* [x] POST /api/v1/statuses/:id/unfavourite
+* [x] GET /api/v1/timelines/home
+* [x] GET /api/v1/timelines/public
+* [x] GET /api/v1/timelines/tag/:hashtag
+* [x] GET /api/v1/timelines/list/:id
+
+## Installation
+
+```
+$ go get github.com/mattn/go-mastodon
+```
+
+## License
+
+MIT
+
+## Author
+
+Yasuhiro Matsumoto (a.k.a. mattn)
diff --git a/mastodon/accounts.go b/mastodon/accounts.go
new file mode 100644 (file)
index 0000000..e6f5a6d
--- /dev/null
@@ -0,0 +1,314 @@
+package mastodon
+
+import (
+       "context"
+       "fmt"
+       "net/http"
+       "net/url"
+       "strconv"
+       "time"
+)
+
+// Account hold information for mastodon account.
+type Account struct {
+       ID             string    `json:"id"`
+       Username       string    `json:"username"`
+       Acct           string    `json:"acct"`
+       DisplayName    string    `json:"display_name"`
+       Locked         bool      `json:"locked"`
+       CreatedAt      time.Time `json:"created_at"`
+       FollowersCount int64     `json:"followers_count"`
+       FollowingCount int64     `json:"following_count"`
+       StatusesCount  int64     `json:"statuses_count"`
+       Note           string    `json:"note"`
+       URL            string    `json:"url"`
+       Avatar         string    `json:"avatar"`
+       AvatarStatic   string    `json:"avatar_static"`
+       Header         string    `json:"header"`
+       HeaderStatic   string    `json:"header_static"`
+       Emojis         []Emoji   `json:"emojis"`
+       Moved          *Account  `json:"moved"`
+       Fields         []Field   `json:"fields"`
+       Bot            bool      `json:"bot"`
+}
+
+// Field is a Mastodon account profile field.
+type Field struct {
+       Name       string    `json:"name"`
+       Value      string    `json:"value"`
+       VerifiedAt time.Time `json:"verified_at"`
+}
+
+// AccountSource is a Mastodon account profile field.
+type AccountSource struct {
+       Privacy   *string  `json:"privacy"`
+       Sensitive *bool    `json:"sensitive"`
+       Language  *string  `json:"language"`
+       Note      *string  `json:"note"`
+       Fields    *[]Field `json:"fields"`
+}
+
+// GetAccount return Account.
+func (c *Client) GetAccount(ctx context.Context, id string) (*Account, error) {
+       var account Account
+       err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%s", url.PathEscape(string(id))), nil, &account, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &account, nil
+}
+
+// GetAccountCurrentUser return Account of current user.
+func (c *Client) GetAccountCurrentUser(ctx context.Context) (*Account, error) {
+       var account Account
+       err := c.doAPI(ctx, http.MethodGet, "/api/v1/accounts/verify_credentials", nil, &account, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &account, nil
+}
+
+// Profile is a struct for updating profiles.
+type Profile struct {
+       // If it is nil it will not be updated.
+       // If it is empty, update it with empty.
+       DisplayName *string
+       Note        *string
+       Locked      *bool
+       Fields      *[]Field
+       Source      *AccountSource
+
+       // Set the base64 encoded character string of the image.
+       Avatar string
+       Header string
+}
+
+// AccountUpdate updates the information of the current user.
+func (c *Client) AccountUpdate(ctx context.Context, profile *Profile) (*Account, error) {
+       params := url.Values{}
+       if profile.DisplayName != nil {
+               params.Set("display_name", *profile.DisplayName)
+       }
+       if profile.Note != nil {
+               params.Set("note", *profile.Note)
+       }
+       if profile.Locked != nil {
+               params.Set("locked", strconv.FormatBool(*profile.Locked))
+       }
+       if profile.Fields != nil {
+               for idx, field := range *profile.Fields {
+                       params.Set(fmt.Sprintf("fields_attributes[%d][name]", idx), field.Name)
+                       params.Set(fmt.Sprintf("fields_attributes[%d][value]", idx), field.Value)
+               }
+       }
+       if profile.Source != nil {
+               if profile.Source.Privacy != nil {
+                       params.Set("source[privacy]", *profile.Source.Privacy)
+               }
+               if profile.Source.Sensitive != nil {
+                       params.Set("source[sensitive]", strconv.FormatBool(*profile.Source.Sensitive))
+               }
+               if profile.Source.Language != nil {
+                       params.Set("source[language]", *profile.Source.Language)
+               }
+       }
+       if profile.Avatar != "" {
+               params.Set("avatar", profile.Avatar)
+       }
+       if profile.Header != "" {
+               params.Set("header", profile.Header)
+       }
+
+       var account Account
+       err := c.doAPI(ctx, http.MethodPatch, "/api/v1/accounts/update_credentials", params, &account, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &account, nil
+}
+
+// GetAccountStatuses return statuses by specified accuont.
+func (c *Client) GetAccountStatuses(ctx context.Context, id string, pg *Pagination) ([]*Status, error) {
+       var statuses []*Status
+       err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%s/statuses", url.PathEscape(string(id))), nil, &statuses, pg)
+       if err != nil {
+               return nil, err
+       }
+       return statuses, nil
+}
+
+// GetAccountFollowers return followers list.
+func (c *Client) GetAccountFollowers(ctx context.Context, id string, pg *Pagination) ([]*Account, error) {
+       var accounts []*Account
+       err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%s/followers", url.PathEscape(string(id))), nil, &accounts, pg)
+       if err != nil {
+               return nil, err
+       }
+       return accounts, nil
+}
+
+// GetAccountFollowing return following list.
+func (c *Client) GetAccountFollowing(ctx context.Context, id string, pg *Pagination) ([]*Account, error) {
+       var accounts []*Account
+       err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%s/following", url.PathEscape(string(id))), nil, &accounts, pg)
+       if err != nil {
+               return nil, err
+       }
+       return accounts, nil
+}
+
+// GetBlocks return block list.
+func (c *Client) GetBlocks(ctx context.Context, pg *Pagination) ([]*Account, error) {
+       var accounts []*Account
+       err := c.doAPI(ctx, http.MethodGet, "/api/v1/blocks", nil, &accounts, pg)
+       if err != nil {
+               return nil, err
+       }
+       return accounts, nil
+}
+
+// Relationship hold information for relation-ship to the account.
+type Relationship struct {
+       ID                  string `json:"id"`
+       Following           bool   `json:"following"`
+       FollowedBy          bool   `json:"followed_by"`
+       Blocking            bool   `json:"blocking"`
+       Muting              bool   `json:"muting"`
+       MutingNotifications bool   `json:"muting_notifications"`
+       Requested           bool   `json:"requested"`
+       DomainBlocking      bool   `json:"domain_blocking"`
+       ShowingReblogs      bool   `json:"showing_reblogs"`
+       Endorsed            bool   `json:"endorsed"`
+}
+
+// AccountFollow follow the account.
+func (c *Client) AccountFollow(ctx context.Context, id string) (*Relationship, error) {
+       var relationship Relationship
+       err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/follow", url.PathEscape(string(id))), nil, &relationship, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &relationship, nil
+}
+
+// AccountUnfollow unfollow the account.
+func (c *Client) AccountUnfollow(ctx context.Context, id string) (*Relationship, error) {
+       var relationship Relationship
+       err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/unfollow", url.PathEscape(string(id))), nil, &relationship, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &relationship, nil
+}
+
+// AccountBlock block the account.
+func (c *Client) AccountBlock(ctx context.Context, id string) (*Relationship, error) {
+       var relationship Relationship
+       err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/block", url.PathEscape(string(id))), nil, &relationship, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &relationship, nil
+}
+
+// AccountUnblock unblock the account.
+func (c *Client) AccountUnblock(ctx context.Context, id string) (*Relationship, error) {
+       var relationship Relationship
+       err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/unblock", url.PathEscape(string(id))), nil, &relationship, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &relationship, nil
+}
+
+// AccountMute mute the account.
+func (c *Client) AccountMute(ctx context.Context, id string) (*Relationship, error) {
+       var relationship Relationship
+       err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/mute", url.PathEscape(string(id))), nil, &relationship, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &relationship, nil
+}
+
+// AccountUnmute unmute the account.
+func (c *Client) AccountUnmute(ctx context.Context, id string) (*Relationship, error) {
+       var relationship Relationship
+       err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/unmute", url.PathEscape(string(id))), nil, &relationship, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &relationship, nil
+}
+
+// GetAccountRelationships return relationship for the account.
+func (c *Client) GetAccountRelationships(ctx context.Context, ids []string) ([]*Relationship, error) {
+       params := url.Values{}
+       for _, id := range ids {
+               params.Add("id[]", id)
+       }
+
+       var relationships []*Relationship
+       err := c.doAPI(ctx, http.MethodGet, "/api/v1/accounts/relationships", params, &relationships, nil)
+       if err != nil {
+               return nil, err
+       }
+       return relationships, nil
+}
+
+// AccountsSearch search accounts by query.
+func (c *Client) AccountsSearch(ctx context.Context, q string, limit int64) ([]*Account, error) {
+       params := url.Values{}
+       params.Set("q", q)
+       params.Set("limit", fmt.Sprint(limit))
+
+       var accounts []*Account
+       err := c.doAPI(ctx, http.MethodGet, "/api/v1/accounts/search", params, &accounts, nil)
+       if err != nil {
+               return nil, err
+       }
+       return accounts, nil
+}
+
+// FollowRemoteUser send follow-request.
+func (c *Client) FollowRemoteUser(ctx context.Context, uri string) (*Account, error) {
+       params := url.Values{}
+       params.Set("uri", uri)
+
+       var account Account
+       err := c.doAPI(ctx, http.MethodPost, "/api/v1/follows", params, &account, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &account, nil
+}
+
+// GetFollowRequests return follow-requests.
+func (c *Client) GetFollowRequests(ctx context.Context, pg *Pagination) ([]*Account, error) {
+       var accounts []*Account
+       err := c.doAPI(ctx, http.MethodGet, "/api/v1/follow_requests", nil, &accounts, pg)
+       if err != nil {
+               return nil, err
+       }
+       return accounts, nil
+}
+
+// FollowRequestAuthorize is authorize the follow request of user with id.
+func (c *Client) FollowRequestAuthorize(ctx context.Context, id string) error {
+       return c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/follow_requests/%s/authorize", url.PathEscape(string(id))), nil, nil, nil)
+}
+
+// FollowRequestReject is rejects the follow request of user with id.
+func (c *Client) FollowRequestReject(ctx context.Context, id string) error {
+       return c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/follow_requests/%s/reject", url.PathEscape(string(id))), nil, nil, nil)
+}
+
+// GetMutes returns the list of users muted by the current user.
+func (c *Client) GetMutes(ctx context.Context, pg *Pagination) ([]*Account, error) {
+       var accounts []*Account
+       err := c.doAPI(ctx, http.MethodGet, "/api/v1/mutes", nil, &accounts, pg)
+       if err != nil {
+               return nil, err
+       }
+       return accounts, nil
+}
diff --git a/mastodon/apps.go b/mastodon/apps.go
new file mode 100644 (file)
index 0000000..5d925c3
--- /dev/null
@@ -0,0 +1,96 @@
+package mastodon
+
+import (
+       "context"
+       "encoding/json"
+       "net/http"
+       "net/url"
+       "path"
+       "strings"
+)
+
+// AppConfig is a setting for registering applications.
+type AppConfig struct {
+       http.Client
+       Server     string
+       ClientName string
+
+       // Where the user should be redirected after authorization (for no redirect, use urn:ietf:wg:oauth:2.0:oob)
+       RedirectURIs string
+
+       // This can be a space-separated list of items listed on the /settings/applications/new page of any Mastodon
+       // instance. "read", "write", and "follow" are top-level scopes that include all the permissions of the more
+       // specific scopes like "read:favourites", "write:statuses", and "write:follows".
+       Scopes string
+
+       // Optional.
+       Website string
+}
+
+// Application is mastodon application.
+type Application struct {
+       ID           string `json:"id"`
+       RedirectURI  string `json:"redirect_uri"`
+       ClientID     string `json:"client_id"`
+       ClientSecret string `json:"client_secret"`
+
+       // AuthURI is not part of the Mastodon API; it is generated by go-mastodon.
+       AuthURI string `json:"auth_uri,omitempty"`
+}
+
+// RegisterApp returns the mastodon application.
+func RegisterApp(ctx context.Context, appConfig *AppConfig) (*Application, error) {
+       params := url.Values{}
+       params.Set("client_name", appConfig.ClientName)
+       if appConfig.RedirectURIs == "" {
+               params.Set("redirect_uris", "urn:ietf:wg:oauth:2.0:oob")
+       } else {
+               params.Set("redirect_uris", appConfig.RedirectURIs)
+       }
+       params.Set("scopes", appConfig.Scopes)
+       params.Set("website", appConfig.Website)
+
+       u, err := url.Parse(appConfig.Server)
+       if err != nil {
+               return nil, err
+       }
+       u.Path = path.Join(u.Path, "/api/v1/apps")
+
+       req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(params.Encode()))
+       if err != nil {
+               return nil, err
+       }
+       req = req.WithContext(ctx)
+       req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+       resp, err := appConfig.Do(req)
+       if err != nil {
+               return nil, err
+       }
+       defer resp.Body.Close()
+
+       if resp.StatusCode != http.StatusOK {
+               return nil, parseAPIError("bad request", resp)
+       }
+
+       var app Application
+       err = json.NewDecoder(resp.Body).Decode(&app)
+       if err != nil {
+               return nil, err
+       }
+
+       u, err = url.Parse(appConfig.Server)
+       if err != nil {
+               return nil, err
+       }
+       u.Path = path.Join(u.Path, "/oauth/authorize")
+       u.RawQuery = url.Values{
+               "scope":         {appConfig.Scopes},
+               "response_type": {"code"},
+               "redirect_uri":  {app.RedirectURI},
+               "client_id":     {app.ClientID},
+       }.Encode()
+
+       app.AuthURI = u.String()
+
+       return &app, nil
+}
diff --git a/mastodon/go.mod b/mastodon/go.mod
new file mode 100644 (file)
index 0000000..ea24109
--- /dev/null
@@ -0,0 +1,8 @@
+module mastodon
+
+go 1.13
+
+require (
+       github.com/gorilla/websocket v1.4.1
+       github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80
+)
diff --git a/mastodon/go.sum b/mastodon/go.sum
new file mode 100644 (file)
index 0000000..3ec24b2
--- /dev/null
@@ -0,0 +1,4 @@
+github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
+github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y=
+github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE=
diff --git a/mastodon/helper.go b/mastodon/helper.go
new file mode 100644 (file)
index 0000000..05af20f
--- /dev/null
@@ -0,0 +1,55 @@
+package mastodon
+
+import (
+       "encoding/base64"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "net/http"
+       "os"
+)
+
+// Base64EncodeFileName returns the base64 data URI format string of the file with the file name.
+func Base64EncodeFileName(filename string) (string, error) {
+       file, err := os.Open(filename)
+       if err != nil {
+               return "", err
+       }
+       defer file.Close()
+
+       return Base64Encode(file)
+}
+
+// Base64Encode returns the base64 data URI format string of the file.
+func Base64Encode(file *os.File) (string, error) {
+       fi, err := file.Stat()
+       if err != nil {
+               return "", err
+       }
+
+       d := make([]byte, fi.Size())
+       _, err = file.Read(d)
+       if err != nil {
+               return "", err
+       }
+
+       return "data:" + http.DetectContentType(d) +
+               ";base64," + base64.StdEncoding.EncodeToString(d), nil
+}
+
+// String is a helper function to get the pointer value of a string.
+func String(v string) *string { return &v }
+
+func parseAPIError(prefix string, resp *http.Response) error {
+       errMsg := fmt.Sprintf("%s: %s", prefix, resp.Status)
+       var e struct {
+               Error string `json:"error"`
+       }
+
+       json.NewDecoder(resp.Body).Decode(&e)
+       if e.Error != "" {
+               errMsg = fmt.Sprintf("%s: %s", errMsg, e.Error)
+       }
+
+       return errors.New(errMsg)
+}
diff --git a/mastodon/instance.go b/mastodon/instance.go
new file mode 100644 (file)
index 0000000..3217450
--- /dev/null
@@ -0,0 +1,65 @@
+package mastodon
+
+import (
+       "context"
+       "net/http"
+)
+
+// Instance hold information for mastodon instance.
+type Instance struct {
+       URI            string            `json:"uri"`
+       Title          string            `json:"title"`
+       Description    string            `json:"description"`
+       EMail          string            `json:"email"`
+       Version        string            `json:"version,omitempty"`
+       Thumbnail      string            `json:"thumbnail,omitempty"`
+       URLs           map[string]string `json:"urls,omitempty"`
+       Stats          *InstanceStats    `json:"stats,omitempty"`
+       Languages      []string          `json:"languages"`
+       ContactAccount *Account          `json:"account"`
+}
+
+// InstanceStats hold information for mastodon instance stats.
+type InstanceStats struct {
+       UserCount   int64 `json:"user_count"`
+       StatusCount int64 `json:"status_count"`
+       DomainCount int64 `json:"domain_count"`
+}
+
+// GetInstance return Instance.
+func (c *Client) GetInstance(ctx context.Context) (*Instance, error) {
+       var instance Instance
+       err := c.doAPI(ctx, http.MethodGet, "/api/v1/instance", nil, &instance, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &instance, nil
+}
+
+// WeeklyActivity hold information for mastodon weekly activity.
+type WeeklyActivity struct {
+       Week          Unixtime `json:"week"`
+       Statuses      int64    `json:"statuses,string"`
+       Logins        int64    `json:"logins,string"`
+       Registrations int64    `json:"registrations,string"`
+}
+
+// GetInstanceActivity return instance activity.
+func (c *Client) GetInstanceActivity(ctx context.Context) ([]*WeeklyActivity, error) {
+       var activity []*WeeklyActivity
+       err := c.doAPI(ctx, http.MethodGet, "/api/v1/instance/activity", nil, &activity, nil)
+       if err != nil {
+               return nil, err
+       }
+       return activity, nil
+}
+
+// GetInstancePeers return instance peers.
+func (c *Client) GetInstancePeers(ctx context.Context) ([]string, error) {
+       var peers []string
+       err := c.doAPI(ctx, http.MethodGet, "/api/v1/instance/peers", nil, &peers, nil)
+       if err != nil {
+               return nil, err
+       }
+       return peers, nil
+}
diff --git a/mastodon/lists.go b/mastodon/lists.go
new file mode 100644 (file)
index 0000000..d323b79
--- /dev/null
@@ -0,0 +1,107 @@
+package mastodon
+
+import (
+       "context"
+       "fmt"
+       "net/http"
+       "net/url"
+)
+
+// List is metadata for a list of users.
+type List struct {
+       ID    string `json:"id"`
+       Title string `json:"title"`
+}
+
+// GetLists returns all the lists on the current account.
+func (c *Client) GetLists(ctx context.Context) ([]*List, error) {
+       var lists []*List
+       err := c.doAPI(ctx, http.MethodGet, "/api/v1/lists", nil, &lists, nil)
+       if err != nil {
+               return nil, err
+       }
+       return lists, nil
+}
+
+// GetAccountLists returns the lists containing a given account.
+func (c *Client) GetAccountLists(ctx context.Context, id string) ([]*List, error) {
+       var lists []*List
+       err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%s/lists", url.PathEscape(string(id))), nil, &lists, nil)
+       if err != nil {
+               return nil, err
+       }
+       return lists, nil
+}
+
+// GetListAccounts returns the accounts in a given list.
+func (c *Client) GetListAccounts(ctx context.Context, id string) ([]*Account, error) {
+       var accounts []*Account
+       err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/lists/%s/accounts", url.PathEscape(string(id))), url.Values{"limit": {"0"}}, &accounts, nil)
+       if err != nil {
+               return nil, err
+       }
+       return accounts, nil
+}
+
+// GetList retrieves a list by string.
+func (c *Client) GetList(ctx context.Context, id string) (*List, error) {
+       var list List
+       err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/lists/%s", url.PathEscape(string(id))), nil, &list, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &list, nil
+}
+
+// CreateList creates a new list with a given title.
+func (c *Client) CreateList(ctx context.Context, title string) (*List, error) {
+       params := url.Values{}
+       params.Set("title", title)
+
+       var list List
+       err := c.doAPI(ctx, http.MethodPost, "/api/v1/lists", params, &list, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &list, nil
+}
+
+// RenameList assigns a new title to a list.
+func (c *Client) RenameList(ctx context.Context, id string, title string) (*List, error) {
+       params := url.Values{}
+       params.Set("title", title)
+
+       var list List
+       err := c.doAPI(ctx, http.MethodPut, fmt.Sprintf("/api/v1/lists/%s", url.PathEscape(string(id))), params, &list, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &list, nil
+}
+
+// DeleteList removes a list.
+func (c *Client) DeleteList(ctx context.Context, id string) error {
+       return c.doAPI(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/lists/%s", url.PathEscape(string(id))), nil, nil, nil)
+}
+
+// AddToList adds accounts to a list.
+//
+// Only accounts already followed by the user can be added to a list.
+func (c *Client) AddToList(ctx context.Context, list string, accounts ...string) error {
+       params := url.Values{}
+       for _, acct := range accounts {
+               params.Add("account_ids", string(acct))
+       }
+
+       return c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/lists/%s/accounts", url.PathEscape(string(list))), params, nil, nil)
+}
+
+// RemoveFromList removes accounts from a list.
+func (c *Client) RemoveFromList(ctx context.Context, list string, accounts ...string) error {
+       params := url.Values{}
+       for _, acct := range accounts {
+               params.Add("account_ids", string(acct))
+       }
+
+       return c.doAPI(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/lists/%s/accounts", url.PathEscape(string(list))), params, nil, nil)
+}
diff --git a/mastodon/mastodon.go b/mastodon/mastodon.go
new file mode 100644 (file)
index 0000000..ff86d2b
--- /dev/null
@@ -0,0 +1,388 @@
+// Package mastodon provides functions and structs for accessing the mastodon API.
+package mastodon
+
+import (
+       "bytes"
+       "context"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "io"
+       "mime/multipart"
+       "net/http"
+       "net/url"
+       "os"
+       "path"
+       "path/filepath"
+       "strings"
+       "time"
+
+       "github.com/tomnomnom/linkheader"
+)
+
+// Config is a setting for access mastodon APIs.
+type Config struct {
+       Server       string
+       ClientID     string
+       ClientSecret string
+       AccessToken  string
+}
+
+// Client is a API client for mastodon.
+type Client struct {
+       http.Client
+       config *Config
+}
+
+func (c *Client) doAPI(ctx context.Context, method string, uri string, params interface{}, res interface{}, pg *Pagination) error {
+       u, err := url.Parse(c.config.Server)
+       if err != nil {
+               return err
+       }
+       u.Path = path.Join(u.Path, uri)
+
+       var req *http.Request
+       ct := "application/x-www-form-urlencoded"
+       if values, ok := params.(url.Values); ok {
+               var body io.Reader
+               if method == http.MethodGet {
+                       if pg != nil {
+                               values = pg.setValues(values)
+                       }
+                       u.RawQuery = values.Encode()
+               } else {
+                       body = strings.NewReader(values.Encode())
+               }
+               req, err = http.NewRequest(method, u.String(), body)
+               if err != nil {
+                       return err
+               }
+       } else if file, ok := params.(string); ok {
+               f, err := os.Open(file)
+               if err != nil {
+                       return err
+               }
+               defer f.Close()
+
+               var buf bytes.Buffer
+               mw := multipart.NewWriter(&buf)
+               part, err := mw.CreateFormFile("file", filepath.Base(file))
+               if err != nil {
+                       return err
+               }
+               _, err = io.Copy(part, f)
+               if err != nil {
+                       return err
+               }
+               err = mw.Close()
+               if err != nil {
+                       return err
+               }
+               req, err = http.NewRequest(method, u.String(), &buf)
+               if err != nil {
+                       return err
+               }
+               ct = mw.FormDataContentType()
+       } else if reader, ok := params.(io.Reader); ok {
+               var buf bytes.Buffer
+               mw := multipart.NewWriter(&buf)
+               part, err := mw.CreateFormFile("file", "upload")
+               if err != nil {
+                       return err
+               }
+               _, err = io.Copy(part, reader)
+               if err != nil {
+                       return err
+               }
+               err = mw.Close()
+               if err != nil {
+                       return err
+               }
+               req, err = http.NewRequest(method, u.String(), &buf)
+               if err != nil {
+                       return err
+               }
+               ct = mw.FormDataContentType()
+       } else {
+               if method == http.MethodGet && pg != nil {
+                       u.RawQuery = pg.toValues().Encode()
+               }
+               req, err = http.NewRequest(method, u.String(), nil)
+               if err != nil {
+                       return err
+               }
+       }
+       req = req.WithContext(ctx)
+       req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
+       if params != nil {
+               req.Header.Set("Content-Type", ct)
+       }
+
+       var resp *http.Response
+       backoff := 1000 * time.Millisecond
+       for {
+               resp, err = c.Do(req)
+               if err != nil {
+                       return err
+               }
+               defer resp.Body.Close()
+
+               // handle status code 429, which indicates the server is throttling
+               // our requests. Do an exponential backoff and retry the request.
+               if resp.StatusCode == 429 {
+                       if backoff > time.Hour {
+                               break
+                       }
+                       backoff *= 2
+
+                       select {
+                       case <-time.After(backoff):
+                       case <-ctx.Done():
+                               return ctx.Err()
+                       }
+                       continue
+               }
+               break
+       }
+
+       if resp.StatusCode != http.StatusOK {
+               return parseAPIError("bad request", resp)
+       } else if res == nil {
+               return nil
+       } else if pg != nil {
+               if lh := resp.Header.Get("Link"); lh != "" {
+                       pg2, err := newPagination(lh)
+                       if err != nil {
+                               return err
+                       }
+                       *pg = *pg2
+               }
+       }
+       return json.NewDecoder(resp.Body).Decode(&res)
+}
+
+// NewClient return new mastodon API client.
+func NewClient(config *Config) *Client {
+       return &Client{
+               Client: *http.DefaultClient,
+               config: config,
+       }
+}
+
+// Authenticate get access-token to the API.
+func (c *Client) Authenticate(ctx context.Context, username, password string) error {
+       params := url.Values{
+               "client_id":     {c.config.ClientID},
+               "client_secret": {c.config.ClientSecret},
+               "grant_type":    {"password"},
+               "username":      {username},
+               "password":      {password},
+               "scope":         {"read write follow"},
+       }
+
+       return c.authenticate(ctx, params)
+}
+
+// AuthenticateToken logs in using a grant token returned by Application.AuthURI.
+//
+// redirectURI should be the same as Application.RedirectURI.
+func (c *Client) AuthenticateToken(ctx context.Context, authCode, redirectURI string) error {
+       params := url.Values{
+               "client_id":     {c.config.ClientID},
+               "client_secret": {c.config.ClientSecret},
+               "grant_type":    {"authorization_code"},
+               "code":          {authCode},
+               "redirect_uri":  {redirectURI},
+       }
+
+       return c.authenticate(ctx, params)
+}
+
+func (c *Client) authenticate(ctx context.Context, params url.Values) error {
+       u, err := url.Parse(c.config.Server)
+       if err != nil {
+               return err
+       }
+       u.Path = path.Join(u.Path, "/oauth/token")
+
+       req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(params.Encode()))
+       if err != nil {
+               return err
+       }
+       req = req.WithContext(ctx)
+       req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+       resp, err := c.Do(req)
+       if err != nil {
+               return err
+       }
+       defer resp.Body.Close()
+
+       if resp.StatusCode != http.StatusOK {
+               return parseAPIError("bad authorization", resp)
+       }
+
+       var res struct {
+               AccessToken string `json:"access_token"`
+       }
+       err = json.NewDecoder(resp.Body).Decode(&res)
+       if err != nil {
+               return err
+       }
+       c.config.AccessToken = res.AccessToken
+       return nil
+}
+
+func (c *Client) GetAccessToken(ctx context.Context) string {
+       if c == nil || c.config == nil {
+               return ""
+       }
+       return c.config.AccessToken
+}
+
+// Toot is struct to post status.
+type Toot struct {
+       Status      string   `json:"status"`
+       InReplyToID string   `json:"in_reply_to_id"`
+       MediaIDs    []string `json:"media_ids"`
+       Sensitive   bool     `json:"sensitive"`
+       SpoilerText string   `json:"spoiler_text"`
+       Visibility  string   `json:"visibility"`
+}
+
+// Mention hold information for mention.
+type Mention struct {
+       URL      string `json:"url"`
+       Username string `json:"username"`
+       Acct     string `json:"acct"`
+       ID       string `json:"id"`
+}
+
+// Tag hold information for tag.
+type Tag struct {
+       Name    string    `json:"name"`
+       URL     string    `json:"url"`
+       History []History `json:"history"`
+}
+
+// History hold information for history.
+type History struct {
+       Day      string `json:"day"`
+       Uses     int64  `json:"uses"`
+       Accounts int64  `json:"accounts"`
+}
+
+// Attachment hold information for attachment.
+type Attachment struct {
+       ID          string         `json:"id"`
+       Type        string         `json:"type"`
+       URL         string         `json:"url"`
+       RemoteURL   string         `json:"remote_url"`
+       PreviewURL  string         `json:"preview_url"`
+       TextURL     string         `json:"text_url"`
+       Description string         `json:"description"`
+       Meta        AttachmentMeta `json:"meta"`
+}
+
+// AttachmentMeta holds information for attachment metadata.
+type AttachmentMeta struct {
+       Original AttachmentSize `json:"original"`
+       Small    AttachmentSize `json:"small"`
+}
+
+// AttachmentSize holds information for attatchment size.
+type AttachmentSize struct {
+       Width  int64   `json:"width"`
+       Height int64   `json:"height"`
+       Size   string  `json:"size"`
+       Aspect float64 `json:"aspect"`
+}
+
+// Emoji hold information for CustomEmoji.
+type Emoji struct {
+       ShortCode       string `json:"shortcode"`
+       StaticURL       string `json:"static_url"`
+       URL             string `json:"url"`
+       VisibleInPicker bool   `json:"visible_in_picker"`
+}
+
+// Results hold information for search result.
+type Results struct {
+       Accounts []*Account `json:"accounts"`
+       Statuses []*Status  `json:"statuses"`
+       Hashtags []string   `json:"hashtags"`
+}
+
+// Pagination is a struct for specifying the get range.
+type Pagination struct {
+       MaxID   string
+       SinceID string
+       MinID   string
+       Limit   int64
+}
+
+func newPagination(rawlink string) (*Pagination, error) {
+       if rawlink == "" {
+               return nil, errors.New("empty link header")
+       }
+
+       p := &Pagination{}
+       for _, link := range linkheader.Parse(rawlink) {
+               switch link.Rel {
+               case "next":
+                       maxID, err := getPaginationID(link.URL, "max_id")
+                       if err != nil {
+                               return nil, err
+                       }
+                       p.MaxID = maxID
+               case "prev":
+                       sinceID, err := getPaginationID(link.URL, "since_id")
+                       if err != nil {
+                               return nil, err
+                       }
+                       p.SinceID = sinceID
+
+                       minID, err := getPaginationID(link.URL, "min_id")
+                       if err != nil {
+                               return nil, err
+                       }
+                       p.MinID = minID
+               }
+       }
+
+       return p, nil
+}
+
+func getPaginationID(rawurl, key string) (string, error) {
+       u, err := url.Parse(rawurl)
+       if err != nil {
+               return "", err
+       }
+
+       val := u.Query().Get(key)
+       if val == "" {
+               return "", nil
+       }
+
+       return string(val), nil
+}
+
+func (p *Pagination) toValues() url.Values {
+       return p.setValues(url.Values{})
+}
+
+func (p *Pagination) setValues(params url.Values) url.Values {
+       if p.MaxID != "" {
+               params.Set("max_id", string(p.MaxID))
+       }
+       if p.SinceID != "" {
+               params.Set("since_id", string(p.SinceID))
+       }
+       if p.MinID != "" {
+               params.Set("min_id", string(p.MinID))
+       }
+       if p.Limit > 0 {
+               params.Set("limit", fmt.Sprint(p.Limit))
+       }
+
+       return params
+}
diff --git a/mastodon/notification.go b/mastodon/notification.go
new file mode 100644 (file)
index 0000000..236fcbf
--- /dev/null
@@ -0,0 +1,42 @@
+package mastodon
+
+import (
+       "context"
+       "fmt"
+       "net/http"
+       "time"
+)
+
+// Notification hold information for mastodon notification.
+type Notification struct {
+       ID        string    `json:"id"`
+       Type      string    `json:"type"`
+       CreatedAt time.Time `json:"created_at"`
+       Account   Account   `json:"account"`
+       Status    *Status   `json:"status"`
+}
+
+// GetNotifications return notifications.
+func (c *Client) GetNotifications(ctx context.Context, pg *Pagination) ([]*Notification, error) {
+       var notifications []*Notification
+       err := c.doAPI(ctx, http.MethodGet, "/api/v1/notifications", nil, &notifications, pg)
+       if err != nil {
+               return nil, err
+       }
+       return notifications, nil
+}
+
+// GetNotification return notification.
+func (c *Client) GetNotification(ctx context.Context, id string) (*Notification, error) {
+       var notification Notification
+       err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/notifications/%v", id), nil, &notification, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &notification, nil
+}
+
+// ClearNotifications clear notifications.
+func (c *Client) ClearNotifications(ctx context.Context) error {
+       return c.doAPI(ctx, http.MethodPost, "/api/v1/notifications/clear", nil, nil, nil)
+}
diff --git a/mastodon/report.go b/mastodon/report.go
new file mode 100644 (file)
index 0000000..920614a
--- /dev/null
@@ -0,0 +1,39 @@
+package mastodon
+
+import (
+       "context"
+       "net/http"
+       "net/url"
+)
+
+// Report hold information for mastodon report.
+type Report struct {
+       ID          int64 `json:"id"`
+       ActionTaken bool  `json:"action_taken"`
+}
+
+// GetReports return report of the current user.
+func (c *Client) GetReports(ctx context.Context) ([]*Report, error) {
+       var reports []*Report
+       err := c.doAPI(ctx, http.MethodGet, "/api/v1/reports", nil, &reports, nil)
+       if err != nil {
+               return nil, err
+       }
+       return reports, nil
+}
+
+// Report reports the report
+func (c *Client) Report(ctx context.Context, accountID string, ids []string, comment string) (*Report, error) {
+       params := url.Values{}
+       params.Set("account_id", string(accountID))
+       for _, id := range ids {
+               params.Add("status_ids[]", string(id))
+       }
+       params.Set("comment", comment)
+       var report Report
+       err := c.doAPI(ctx, http.MethodPost, "/api/v1/reports", params, &report, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &report, nil
+}
diff --git a/mastodon/status.go b/mastodon/status.go
new file mode 100644 (file)
index 0000000..fd69914
--- /dev/null
@@ -0,0 +1,297 @@
+package mastodon
+
+import (
+       "context"
+       "fmt"
+       "io"
+       "net/http"
+       "net/url"
+       "time"
+)
+
+// Status is struct to hold status.
+type Status struct {
+       ID                 string       `json:"id"`
+       URI                string       `json:"uri"`
+       URL                string       `json:"url"`
+       Account            Account      `json:"account"`
+       InReplyToID        interface{}  `json:"in_reply_to_id"`
+       InReplyToAccountID interface{}  `json:"in_reply_to_account_id"`
+       Reblog             *Status      `json:"reblog"`
+       Content            string       `json:"content"`
+       CreatedAt          time.Time    `json:"created_at"`
+       Emojis             []Emoji      `json:"emojis"`
+       RepliesCount       int64        `json:"replies_count"`
+       ReblogsCount       int64        `json:"reblogs_count"`
+       FavouritesCount    int64        `json:"favourites_count"`
+       Reblogged          interface{}  `json:"reblogged"`
+       Favourited         interface{}  `json:"favourited"`
+       Muted              interface{}  `json:"muted"`
+       Sensitive          bool         `json:"sensitive"`
+       SpoilerText        string       `json:"spoiler_text"`
+       Visibility         string       `json:"visibility"`
+       MediaAttachments   []Attachment `json:"media_attachments"`
+       Mentions           []Mention    `json:"mentions"`
+       Tags               []Tag        `json:"tags"`
+       Card               *Card        `json:"card"`
+       Application        Application  `json:"application"`
+       Language           string       `json:"language"`
+       Pinned             interface{}  `json:"pinned"`
+}
+
+// Context hold information for mastodon context.
+type Context struct {
+       Ancestors   []*Status `json:"ancestors"`
+       Descendants []*Status `json:"descendants"`
+}
+
+// Card hold information for mastodon card.
+type Card struct {
+       URL          string `json:"url"`
+       Title        string `json:"title"`
+       Description  string `json:"description"`
+       Image        string `json:"image"`
+       Type         string `json:"type"`
+       AuthorName   string `json:"author_name"`
+       AuthorURL    string `json:"author_url"`
+       ProviderName string `json:"provider_name"`
+       ProviderURL  string `json:"provider_url"`
+       HTML         string `json:"html"`
+       Width        int64  `json:"width"`
+       Height       int64  `json:"height"`
+}
+
+// GetFavourites return the favorite list of the current user.
+func (c *Client) GetFavourites(ctx context.Context, pg *Pagination) ([]*Status, error) {
+       var statuses []*Status
+       err := c.doAPI(ctx, http.MethodGet, "/api/v1/favourites", nil, &statuses, pg)
+       if err != nil {
+               return nil, err
+       }
+       return statuses, nil
+}
+
+// GetStatus return status specified by id.
+func (c *Client) GetStatus(ctx context.Context, id string) (*Status, error) {
+       var status Status
+       err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%s", id), nil, &status, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &status, nil
+}
+
+// GetStatusContext return status specified by id.
+func (c *Client) GetStatusContext(ctx context.Context, id string) (*Context, error) {
+       var context Context
+       err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%s/context", id), nil, &context, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &context, nil
+}
+
+// GetStatusCard return status specified by id.
+func (c *Client) GetStatusCard(ctx context.Context, id string) (*Card, error) {
+       var card Card
+       err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%s/card", id), nil, &card, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &card, nil
+}
+
+// GetRebloggedBy returns the account list of the user who reblogged the toot of id.
+func (c *Client) GetRebloggedBy(ctx context.Context, id string, pg *Pagination) ([]*Account, error) {
+       var accounts []*Account
+       err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%s/reblogged_by", id), nil, &accounts, pg)
+       if err != nil {
+               return nil, err
+       }
+       return accounts, nil
+}
+
+// GetFavouritedBy returns the account list of the user who liked the toot of id.
+func (c *Client) GetFavouritedBy(ctx context.Context, id string, pg *Pagination) ([]*Account, error) {
+       var accounts []*Account
+       err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%s/favourited_by", id), nil, &accounts, pg)
+       if err != nil {
+               return nil, err
+       }
+       return accounts, nil
+}
+
+// Reblog is reblog the toot of id and return status of reblog.
+func (c *Client) Reblog(ctx context.Context, id string) (*Status, error) {
+       var status Status
+       err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/statuses/%s/reblog", id), nil, &status, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &status, nil
+}
+
+// Unreblog is unreblog the toot of id and return status of the original toot.
+func (c *Client) Unreblog(ctx context.Context, id string) (*Status, error) {
+       var status Status
+       err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/statuses/%s/unreblog", id), nil, &status, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &status, nil
+}
+
+// Favourite is favourite the toot of id and return status of the favourite toot.
+func (c *Client) Favourite(ctx context.Context, id string) (*Status, error) {
+       var status Status
+       err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/statuses/%s/favourite", id), nil, &status, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &status, nil
+}
+
+// Unfavourite is unfavourite the toot of id and return status of the unfavourite toot.
+func (c *Client) Unfavourite(ctx context.Context, id string) (*Status, error) {
+       var status Status
+       err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/statuses/%s/unfavourite", id), nil, &status, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &status, nil
+}
+
+// GetTimelineHome return statuses from home timeline.
+func (c *Client) GetTimelineHome(ctx context.Context, pg *Pagination) ([]*Status, error) {
+       var statuses []*Status
+       err := c.doAPI(ctx, http.MethodGet, "/api/v1/timelines/home", nil, &statuses, pg)
+       if err != nil {
+               return nil, err
+       }
+       return statuses, nil
+}
+
+// GetTimelinePublic return statuses from public timeline.
+func (c *Client) GetTimelinePublic(ctx context.Context, isLocal bool, pg *Pagination) ([]*Status, error) {
+       params := url.Values{}
+       if isLocal {
+               params.Set("local", "t")
+       }
+
+       var statuses []*Status
+       err := c.doAPI(ctx, http.MethodGet, "/api/v1/timelines/public", params, &statuses, pg)
+       if err != nil {
+               return nil, err
+       }
+       return statuses, nil
+}
+
+// GetTimelineHashtag return statuses from tagged timeline.
+func (c *Client) GetTimelineHashtag(ctx context.Context, tag string, isLocal bool, pg *Pagination) ([]*Status, error) {
+       params := url.Values{}
+       if isLocal {
+               params.Set("local", "t")
+       }
+
+       var statuses []*Status
+       err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/timelines/tag/%s", url.PathEscape(tag)), params, &statuses, pg)
+       if err != nil {
+               return nil, err
+       }
+       return statuses, nil
+}
+
+// GetTimelineList return statuses from a list timeline.
+func (c *Client) GetTimelineList(ctx context.Context, id string, pg *Pagination) ([]*Status, error) {
+       var statuses []*Status
+       err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/timelines/list/%s", url.PathEscape(string(id))), nil, &statuses, pg)
+       if err != nil {
+               return nil, err
+       }
+       return statuses, nil
+}
+
+// GetTimelineMedia return statuses from media timeline.
+// NOTE: This is an experimental feature of pawoo.net.
+func (c *Client) GetTimelineMedia(ctx context.Context, isLocal bool, pg *Pagination) ([]*Status, error) {
+       params := url.Values{}
+       params.Set("media", "t")
+       if isLocal {
+               params.Set("local", "t")
+       }
+
+       var statuses []*Status
+       err := c.doAPI(ctx, http.MethodGet, "/api/v1/timelines/public", params, &statuses, pg)
+       if err != nil {
+               return nil, err
+       }
+       return statuses, nil
+}
+
+// PostStatus post the toot.
+func (c *Client) PostStatus(ctx context.Context, toot *Toot) (*Status, error) {
+       params := url.Values{}
+       params.Set("status", toot.Status)
+       if toot.InReplyToID != "" {
+               params.Set("in_reply_to_id", string(toot.InReplyToID))
+       }
+       if toot.MediaIDs != nil {
+               for _, media := range toot.MediaIDs {
+                       params.Add("media_ids[]", string(media))
+               }
+       }
+       if toot.Visibility != "" {
+               params.Set("visibility", fmt.Sprint(toot.Visibility))
+       }
+       if toot.Sensitive {
+               params.Set("sensitive", "true")
+       }
+       if toot.SpoilerText != "" {
+               params.Set("spoiler_text", toot.SpoilerText)
+       }
+
+       var status Status
+       err := c.doAPI(ctx, http.MethodPost, "/api/v1/statuses", params, &status, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &status, nil
+}
+
+// DeleteStatus delete the toot.
+func (c *Client) DeleteStatus(ctx context.Context, id string) error {
+       return c.doAPI(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/statuses/%s", id), nil, nil, nil)
+}
+
+// Search search content with query.
+func (c *Client) Search(ctx context.Context, q string, resolve bool) (*Results, error) {
+       params := url.Values{}
+       params.Set("q", q)
+       params.Set("resolve", fmt.Sprint(resolve))
+       var results Results
+       err := c.doAPI(ctx, http.MethodGet, "/api/v1/search", params, &results, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &results, nil
+}
+
+// UploadMedia upload a media attachment from a file.
+func (c *Client) UploadMedia(ctx context.Context, file string) (*Attachment, error) {
+       var attachment Attachment
+       err := c.doAPI(ctx, http.MethodPost, "/api/v1/media", file, &attachment, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &attachment, nil
+}
+
+// UploadMediaFromReader uploads a media attachment from a io.Reader.
+func (c *Client) UploadMediaFromReader(ctx context.Context, reader io.Reader) (*Attachment, error) {
+       var attachment Attachment
+       err := c.doAPI(ctx, http.MethodPost, "/api/v1/media", reader, &attachment, nil)
+       if err != nil {
+               return nil, err
+       }
+       return &attachment, nil
+}
diff --git a/mastodon/streaming.go b/mastodon/streaming.go
new file mode 100644 (file)
index 0000000..77ae284
--- /dev/null
@@ -0,0 +1,166 @@
+package mastodon
+
+import (
+       "bufio"
+       "context"
+       "encoding/json"
+       "io"
+       "net/http"
+       "net/url"
+       "path"
+       "strings"
+)
+
+// UpdateEvent is struct for passing status event to app.
+type UpdateEvent struct {
+       Status *Status `json:"status"`
+}
+
+func (e *UpdateEvent) event() {}
+
+// NotificationEvent is struct for passing notification event to app.
+type NotificationEvent struct {
+       Notification *Notification `json:"notification"`
+}
+
+func (e *NotificationEvent) event() {}
+
+// DeleteEvent is struct for passing deletion event to app.
+type DeleteEvent struct{ ID string }
+
+func (e *DeleteEvent) event() {}
+
+// ErrorEvent is struct for passing errors to app.
+type ErrorEvent struct{ err error }
+
+func (e *ErrorEvent) event()        {}
+func (e *ErrorEvent) Error() string { return e.err.Error() }
+
+// Event is interface passing events to app.
+type Event interface {
+       event()
+}
+
+func handleReader(q chan Event, r io.Reader) error {
+       var name string
+       s := bufio.NewScanner(r)
+       for s.Scan() {
+               line := s.Text()
+               token := strings.SplitN(line, ":", 2)
+               if len(token) != 2 {
+                       continue
+               }
+               switch strings.TrimSpace(token[0]) {
+               case "event":
+                       name = strings.TrimSpace(token[1])
+               case "data":
+                       var err error
+                       switch name {
+                       case "update":
+                               var status Status
+                               err = json.Unmarshal([]byte(token[1]), &status)
+                               if err == nil {
+                                       q <- &UpdateEvent{&status}
+                               }
+                       case "notification":
+                               var notification Notification
+                               err = json.Unmarshal([]byte(token[1]), &notification)
+                               if err == nil {
+                                       q <- &NotificationEvent{&notification}
+                               }
+                       case "delete":
+                               q <- &DeleteEvent{ID: string(strings.TrimSpace(token[1]))}
+                       }
+                       if err != nil {
+                               q <- &ErrorEvent{err}
+                       }
+               }
+       }
+       return s.Err()
+}
+
+func (c *Client) streaming(ctx context.Context, p string, params url.Values) (chan Event, error) {
+       u, err := url.Parse(c.config.Server)
+       if err != nil {
+               return nil, err
+       }
+       u.Path = path.Join(u.Path, "/api/v1/streaming", p)
+       u.RawQuery = params.Encode()
+
+       req, err := http.NewRequest(http.MethodGet, u.String(), nil)
+       if err != nil {
+               return nil, err
+       }
+       req = req.WithContext(ctx)
+       req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
+
+       q := make(chan Event)
+       go func() {
+               defer close(q)
+               for {
+                       select {
+                       case <-ctx.Done():
+                               return
+                       default:
+                       }
+
+                       c.doStreaming(req, q)
+               }
+       }()
+       return q, nil
+}
+
+func (c *Client) doStreaming(req *http.Request, q chan Event) {
+       resp, err := c.Do(req)
+       if err != nil {
+               q <- &ErrorEvent{err}
+               return
+       }
+       defer resp.Body.Close()
+
+       if resp.StatusCode != http.StatusOK {
+               q <- &ErrorEvent{parseAPIError("bad request", resp)}
+               return
+       }
+
+       err = handleReader(q, resp.Body)
+       if err != nil {
+               q <- &ErrorEvent{err}
+       }
+}
+
+// StreamingUser return channel to read events on home.
+func (c *Client) StreamingUser(ctx context.Context) (chan Event, error) {
+       return c.streaming(ctx, "user", nil)
+}
+
+// StreamingPublic return channel to read events on public.
+func (c *Client) StreamingPublic(ctx context.Context, isLocal bool) (chan Event, error) {
+       p := "public"
+       if isLocal {
+               p = path.Join(p, "local")
+       }
+
+       return c.streaming(ctx, p, nil)
+}
+
+// StreamingHashtag return channel to read events on tagged timeline.
+func (c *Client) StreamingHashtag(ctx context.Context, tag string, isLocal bool) (chan Event, error) {
+       params := url.Values{}
+       params.Set("tag", tag)
+
+       p := "hashtag"
+       if isLocal {
+               p = path.Join(p, "local")
+       }
+
+       return c.streaming(ctx, p, params)
+}
+
+// StreamingList return channel to read events on a list.
+func (c *Client) StreamingList(ctx context.Context, id string) (chan Event, error) {
+       params := url.Values{}
+       params.Set("list", string(id))
+
+       return c.streaming(ctx, "list", params)
+}
diff --git a/mastodon/streaming_ws.go b/mastodon/streaming_ws.go
new file mode 100644 (file)
index 0000000..838f65b
--- /dev/null
@@ -0,0 +1,195 @@
+package mastodon
+
+import (
+       "context"
+       "encoding/json"
+       "fmt"
+       "net/url"
+       "path"
+       "strings"
+
+       "github.com/gorilla/websocket"
+)
+
+// WSClient is a WebSocket client.
+type WSClient struct {
+       websocket.Dialer
+       client *Client
+}
+
+// NewWSClient return WebSocket client.
+func (c *Client) NewWSClient() *WSClient { return &WSClient{client: c} }
+
+// Stream is a struct of data that flows in streaming.
+type Stream struct {
+       Event   string      `json:"event"`
+       Payload interface{} `json:"payload"`
+}
+
+// StreamingWSUser return channel to read events on home using WebSocket.
+func (c *WSClient) StreamingWSUser(ctx context.Context) (chan Event, error) {
+       return c.streamingWS(ctx, "user", "")
+}
+
+// StreamingWSPublic return channel to read events on public using WebSocket.
+func (c *WSClient) StreamingWSPublic(ctx context.Context, isLocal bool) (chan Event, error) {
+       s := "public"
+       if isLocal {
+               s += ":local"
+       }
+
+       return c.streamingWS(ctx, s, "")
+}
+
+// StreamingWSHashtag return channel to read events on tagged timeline using WebSocket.
+func (c *WSClient) StreamingWSHashtag(ctx context.Context, tag string, isLocal bool) (chan Event, error) {
+       s := "hashtag"
+       if isLocal {
+               s += ":local"
+       }
+
+       return c.streamingWS(ctx, s, tag)
+}
+
+// StreamingWSList return channel to read events on a list using WebSocket.
+func (c *WSClient) StreamingWSList(ctx context.Context, id string) (chan Event, error) {
+       return c.streamingWS(ctx, "list", string(id))
+}
+
+func (c *WSClient) streamingWS(ctx context.Context, stream, tag string) (chan Event, error) {
+       params := url.Values{}
+       params.Set("access_token", c.client.config.AccessToken)
+       params.Set("stream", stream)
+       if tag != "" {
+               params.Set("tag", tag)
+       }
+
+       u, err := changeWebSocketScheme(c.client.config.Server)
+       if err != nil {
+               return nil, err
+       }
+       u.Path = path.Join(u.Path, "/api/v1/streaming")
+       u.RawQuery = params.Encode()
+
+       q := make(chan Event)
+       go func() {
+               defer close(q)
+               for {
+                       err := c.handleWS(ctx, u.String(), q)
+                       if err != nil {
+                               return
+                       }
+               }
+       }()
+
+       return q, nil
+}
+
+func (c *WSClient) handleWS(ctx context.Context, rawurl string, q chan Event) error {
+       conn, err := c.dialRedirect(rawurl)
+       if err != nil {
+               q <- &ErrorEvent{err: err}
+
+               // End.
+               return err
+       }
+
+       // Close the WebSocket when the context is canceled.
+       go func() {
+               <-ctx.Done()
+               conn.Close()
+       }()
+
+       for {
+               select {
+               case <-ctx.Done():
+                       q <- &ErrorEvent{err: ctx.Err()}
+
+                       // End.
+                       return ctx.Err()
+               default:
+               }
+
+               var s Stream
+               err := conn.ReadJSON(&s)
+               if err != nil {
+                       q <- &ErrorEvent{err: err}
+
+                       // Reconnect.
+                       break
+               }
+
+               err = nil
+               switch s.Event {
+               case "update":
+                       var status Status
+                       err = json.Unmarshal([]byte(s.Payload.(string)), &status)
+                       if err == nil {
+                               q <- &UpdateEvent{Status: &status}
+                       }
+               case "notification":
+                       var notification Notification
+                       err = json.Unmarshal([]byte(s.Payload.(string)), &notification)
+                       if err == nil {
+                               q <- &NotificationEvent{Notification: &notification}
+                       }
+               case "delete":
+                       if f, ok := s.Payload.(float64); ok {
+                               q <- &DeleteEvent{ID: fmt.Sprint(int64(f))}
+                       } else {
+                               q <- &DeleteEvent{ID: strings.TrimSpace(s.Payload.(string))}
+                       }
+               }
+               if err != nil {
+                       q <- &ErrorEvent{err}
+               }
+       }
+
+       return nil
+}
+
+func (c *WSClient) dialRedirect(rawurl string) (conn *websocket.Conn, err error) {
+       for {
+               conn, rawurl, err = c.dial(rawurl)
+               if err != nil {
+                       return nil, err
+               } else if conn != nil {
+                       return conn, nil
+               }
+       }
+}
+
+func (c *WSClient) dial(rawurl string) (*websocket.Conn, string, error) {
+       conn, resp, err := c.Dial(rawurl, nil)
+       if err != nil && err != websocket.ErrBadHandshake {
+               return nil, "", err
+       }
+       defer resp.Body.Close()
+
+       if loc := resp.Header.Get("Location"); loc != "" {
+               u, err := changeWebSocketScheme(loc)
+               if err != nil {
+                       return nil, "", err
+               }
+
+               return nil, u.String(), nil
+       }
+
+       return conn, "", err
+}
+
+func changeWebSocketScheme(rawurl string) (*url.URL, error) {
+       u, err := url.Parse(rawurl)
+       if err != nil {
+               return nil, err
+       }
+
+       switch u.Scheme {
+       case "http":
+               u.Scheme = "ws"
+       case "https":
+               u.Scheme = "wss"
+       }
+
+       return u, nil
+}
diff --git a/mastodon/unixtime.go b/mastodon/unixtime.go
new file mode 100644 (file)
index 0000000..a935a9e
--- /dev/null
@@ -0,0 +1,20 @@
+package mastodon
+
+import (
+       "strconv"
+       "time"
+)
+
+type Unixtime time.Time
+
+func (t *Unixtime) UnmarshalJSON(data []byte) error {
+       if len(data) > 0 && data[0] == '"' && data[len(data)-1] == '"' {
+               data = data[1 : len(data)-1]
+       }
+       ts, err := strconv.ParseInt(string(data), 10, 64)
+       if err != nil {
+               return err
+       }
+       *t = Unixtime(time.Unix(ts, 0))
+       return nil
+}
diff --git a/model/app.go b/model/app.go
new file mode 100644 (file)
index 0000000..52ebdf5
--- /dev/null
@@ -0,0 +1,19 @@
+package model
+
+import "errors"
+
+var (
+       ErrAppNotFound = errors.New("app not found")
+)
+
+type App struct {
+       InstanceURL  string
+       ClientID     string
+       ClientSecret string
+}
+
+type AppRepository interface {
+       Add(app App) (err error)
+       Update(instanceURL string, clientID string, clientSecret string) (err error)
+       Get(instanceURL string) (app App, err error)
+}
diff --git a/model/session.go b/model/session.go
new file mode 100644 (file)
index 0000000..43628ee
--- /dev/null
@@ -0,0 +1,23 @@
+package model
+
+import "errors"
+
+var (
+       ErrSessionNotFound = errors.New("session not found")
+)
+
+type Session struct {
+       ID          string
+       InstanceURL string
+       AccessToken string
+}
+
+type SessionRepository interface {
+       Add(session Session) (err error)
+       Update(sessionID string, accessToken string) (err error)
+       Get(sessionID string) (session Session, err error)
+}
+
+func (s Session) IsLoggedIn() bool {
+       return len(s.AccessToken) > 0
+}
diff --git a/renderer/model.go b/renderer/model.go
new file mode 100644 (file)
index 0000000..ddc9e2d
--- /dev/null
@@ -0,0 +1,40 @@
+package renderer
+
+import (
+       "mastodon"
+)
+
+type TimelinePageTemplateData struct {
+       Statuses []*mastodon.Status
+       HasNext  bool
+       NextLink string
+       HasPrev  bool
+       PrevLink string
+}
+
+func NewTimelinePageTemplateData(statuses []*mastodon.Status, hasNext bool, nextLink string, hasPrev bool,
+       prevLink string) *TimelinePageTemplateData {
+       return &TimelinePageTemplateData{
+               Statuses: statuses,
+               HasNext:  hasNext,
+               NextLink: nextLink,
+               HasPrev:  hasPrev,
+               PrevLink: prevLink,
+       }
+}
+
+type ThreadPageTemplateData struct {
+       Status    *mastodon.Status
+       Context   *mastodon.Context
+       PostReply bool
+       ReplyToID string
+}
+
+func NewThreadPageTemplateData(status *mastodon.Status, context *mastodon.Context, postReply bool, replyToID string) *ThreadPageTemplateData {
+       return &ThreadPageTemplateData{
+               Status:    status,
+               Context:   context,
+               PostReply: postReply,
+               ReplyToID: replyToID,
+       }
+}
diff --git a/renderer/renderer.go b/renderer/renderer.go
new file mode 100644 (file)
index 0000000..c3d3526
--- /dev/null
@@ -0,0 +1,112 @@
+package renderer
+
+import (
+       "context"
+       "io"
+       "strconv"
+       "strings"
+       "text/template"
+       "time"
+
+       "mastodon"
+)
+
+type Renderer interface {
+       RenderErrorPage(ctx context.Context, writer io.Writer, err error)
+       RenderHomePage(ctx context.Context, writer io.Writer) (err error)
+       RenderSigninPage(ctx context.Context, writer io.Writer) (err error)
+       RenderTimelinePage(ctx context.Context, writer io.Writer, data *TimelinePageTemplateData) (err error)
+       RenderThreadPage(ctx context.Context, writer io.Writer, data *ThreadPageTemplateData) (err error)
+}
+
+type renderer struct {
+       template *template.Template
+}
+
+func NewRenderer(templateGlobPattern string) (r *renderer, err error) {
+       t := template.New("default")
+       t, err = t.Funcs(template.FuncMap{
+               "WithEmojis":              WithEmojis,
+               "DisplayInteractionCount": DisplayInteractionCount,
+               "TimeSince":               TimeSince,
+               "FormatTimeRFC3339":       FormatTimeRFC3339,
+       }).ParseGlob(templateGlobPattern)
+       if err != nil {
+               return
+       }
+       return &renderer{
+               template: t,
+       }, nil
+}
+
+func (r *renderer) RenderErrorPage(ctx context.Context, writer io.Writer, err error) {
+       r.template.ExecuteTemplate(writer, "error.tmpl", err)
+       return
+}
+
+func (r *renderer) RenderHomePage(ctx context.Context, writer io.Writer) (err error) {
+       return r.template.ExecuteTemplate(writer, "homepage.tmpl", nil)
+}
+
+func (r *renderer) RenderSigninPage(ctx context.Context, writer io.Writer) (err error) {
+       return r.template.ExecuteTemplate(writer, "signin.tmpl", nil)
+}
+
+func (r *renderer) RenderTimelinePage(ctx context.Context, writer io.Writer, data *TimelinePageTemplateData) (err error) {
+       return r.template.ExecuteTemplate(writer, "timeline.tmpl", data)
+}
+
+func (r *renderer) RenderThreadPage(ctx context.Context, writer io.Writer, data *ThreadPageTemplateData) (err error) {
+       return r.template.ExecuteTemplate(writer, "thread.tmpl", data)
+}
+
+func WithEmojis(content string, emojis []mastodon.Emoji) string {
+       var emojiNameContentPair []string
+       for _, e := range emojis {
+               emojiNameContentPair = append(emojiNameContentPair, ":"+e.ShortCode+":", "<img class=\"status-emoji\" src=\""+e.URL+"\" alt=\""+e.ShortCode+"\" />")
+       }
+       return strings.NewReplacer(emojiNameContentPair...).Replace(content)
+}
+
+func DisplayInteractionCount(c int64) string {
+       if c > 0 {
+               return strconv.Itoa(int(c))
+       }
+       return ""
+}
+
+func TimeSince(t time.Time) string {
+       dur := time.Since(t)
+
+       s := dur.Seconds()
+       if s < 60 {
+               return strconv.Itoa(int(s)) + "s"
+       }
+
+       m := dur.Minutes()
+       if m < 60 {
+               return strconv.Itoa(int(m)) + "m"
+       }
+
+       h := dur.Hours()
+       if h < 24 {
+               return strconv.Itoa(int(h)) + "h"
+       }
+
+       d := h / 24
+       if d < 30 {
+               return strconv.Itoa(int(d)) + "d"
+       }
+
+       mo := d / 30
+       if mo < 12 {
+               return strconv.Itoa(int(mo)) + "mo"
+       }
+
+       y := m / 12
+       return strconv.Itoa(int(y)) + "y"
+}
+
+func FormatTimeRFC3339(t time.Time) string {
+       return t.Format(time.RFC3339)
+}
diff --git a/repository/appRepository.go b/repository/appRepository.go
new file mode 100644 (file)
index 0000000..1a8f204
--- /dev/null
@@ -0,0 +1,54 @@
+package repository
+
+import (
+       "database/sql"
+
+       "web/model"
+)
+
+type appRepository struct {
+       db *sql.DB
+}
+
+func NewAppRepository(db *sql.DB) (*appRepository, error) {
+       _, err := db.Exec(`CREATE TABLE IF NOT EXISTS app 
+               (instance_url varchar, client_id varchar, client_secret varchar)`,
+       )
+       if err != nil {
+               return nil, err
+       }
+
+       return &appRepository{
+               db: db,
+       }, nil
+}
+
+func (repo *appRepository) Add(a model.App) (err error) {
+       _, err = repo.db.Exec("INSERT INTO app VALUES (?, ?, ?)", a.InstanceURL, a.ClientID, a.ClientSecret)
+       return
+}
+
+func (repo *appRepository) Update(instanceURL string, clientID string, clientSecret string) (err error) {
+       _, err = repo.db.Exec("UPDATE app SET client_id = ?, client_secret = ? where instance_url = ?", clientID, clientSecret, instanceURL)
+       return
+}
+
+func (repo *appRepository) Get(instanceURL string) (a model.App, err error) {
+       rows, err := repo.db.Query("SELECT * FROM app WHERE instance_url = ?", instanceURL)
+       if err != nil {
+               return
+       }
+       defer rows.Close()
+
+       if !rows.Next() {
+               err = model.ErrAppNotFound
+               return
+       }
+
+       err = rows.Scan(&a.InstanceURL, &a.ClientID, &a.ClientSecret)
+       if err != nil {
+               return
+       }
+
+       return
+}
diff --git a/repository/sessionRepository.go b/repository/sessionRepository.go
new file mode 100644 (file)
index 0000000..2a88b40
--- /dev/null
@@ -0,0 +1,54 @@
+package repository
+
+import (
+       "database/sql"
+
+       "web/model"
+)
+
+type sessionRepository struct {
+       db *sql.DB
+}
+
+func NewSessionRepository(db *sql.DB) (*sessionRepository, error) {
+       _, err := db.Exec(`CREATE TABLE IF NOT EXISTS session 
+               (id varchar, instance_url varchar, access_token varchar)`,
+       )
+       if err != nil {
+               return nil, err
+       }
+
+       return &sessionRepository{
+               db: db,
+       }, nil
+}
+
+func (repo *sessionRepository) Add(s model.Session) (err error) {
+       _, err = repo.db.Exec("INSERT INTO session VALUES (?, ?, ?)", s.ID, s.InstanceURL, s.AccessToken)
+       return
+}
+
+func (repo *sessionRepository) Update(sessionID string, accessToken string) (err error) {
+       _, err = repo.db.Exec("UPDATE session SET access_token = ? where id = ?", accessToken, sessionID)
+       return
+}
+
+func (repo *sessionRepository) Get(id string) (s model.Session, err error) {
+       rows, err := repo.db.Query("SELECT * FROM session WHERE id = ?", id)
+       if err != nil {
+               return
+       }
+       defer rows.Close()
+
+       if !rows.Next() {
+               err = model.ErrSessionNotFound
+               return
+       }
+
+       err = rows.Scan(&s.ID, &s.InstanceURL, &s.AccessToken)
+       if err != nil {
+               return
+       }
+
+       return
+}
diff --git a/service/auth.go b/service/auth.go
new file mode 100644 (file)
index 0000000..cb442a7
--- /dev/null
@@ -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 (file)
index 0000000..b11599e
--- /dev/null
@@ -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 (file)
index 0000000..7088a9b
--- /dev/null
@@ -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 (file)
index 0000000..f4f5ed7
--- /dev/null
@@ -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
+}
diff --git a/static/main.css b/static/main.css
new file mode 100644 (file)
index 0000000..b415ae5
--- /dev/null
@@ -0,0 +1,77 @@
+.status-container {
+       display: flex;
+       margin: 16px 0;
+}
+
+.status-content {
+       margin: 8px 0;
+}
+
+.status-content p {
+       margin: 0px;
+}
+
+.status-profile-img {
+       height: 48px;
+       width: 48px;
+       object-fit: contain;
+}
+
+.status {
+       margin: 0 8px;
+}
+
+.status a {
+       text-decoration: none;
+}
+
+.status-dname {
+       font-weight: 800;
+}
+
+.status-uname {
+       font-style: italic;
+       font-size: 10pt;
+}
+
+.status-emoji {
+       height: 20px;
+       witdth: auto;
+}
+
+.name-emoji {
+       height: 20px;
+       witdth: auto;
+}
+
+.status-action {
+       display: flex;
+}
+
+.status-action a {
+       display: flex;
+       margin: 0 4px;
+       width: 64px;
+       text-decoration: none;
+       color: #333333;
+}
+
+.status-action a:hover {
+       color: #777777;
+}
+
+.status-action .icon {
+       margin: 0 4px 0 0;
+}
+
+.status-action a.status-time {
+       width: auto;
+}
+
+.icon.dripicons-star.liked {
+       color: yellow;
+}
+
+.icon.dripicons-retweet.retweeted {
+       color: green;
+}
diff --git a/templates/error.tmpl b/templates/error.tmpl
new file mode 100644 (file)
index 0000000..b6943be
--- /dev/null
@@ -0,0 +1,6 @@
+{{template "header.tmpl"}}
+<h1> Error </h1>
+<div> {{.}} </div>
+<a href="/timeline"> Home </a>
+{{template "footer.tmpl"}}
+
diff --git a/templates/footer.tmpl b/templates/footer.tmpl
new file mode 100644 (file)
index 0000000..308b1d0
--- /dev/null
@@ -0,0 +1,2 @@
+</body>
+</html>
diff --git a/templates/header.tmpl b/templates/header.tmpl
new file mode 100644 (file)
index 0000000..970aca4
--- /dev/null
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+       <meta charset='utf-8'>
+       <meta content='width=device-width, initial-scale=1' name='viewport'>
+       <title> Web </title>
+       <link rel="stylesheet" href="/static/main.css" />
+       <link rel="stylesheet" href="/static/fonts/fonts.css">
+</head>
+<body>
diff --git a/templates/homepage.tmpl b/templates/homepage.tmpl
new file mode 100644 (file)
index 0000000..256bb29
--- /dev/null
@@ -0,0 +1,4 @@
+{{template "header.tmpl"}}
+<h1> HOME </h1>
+<a href="/signin"> Signin </a>
+{{template "footer.tmpl"}}
diff --git a/templates/signin.tmpl b/templates/signin.tmpl
new file mode 100644 (file)
index 0000000..07bc132
--- /dev/null
@@ -0,0 +1,9 @@
+{{template "header.tmpl"}}
+<h3> Signin </h3>
+<a href="/"> Home </a>
+<form action="/signin" method="post">
+       <input type="text" name="instance" placeholder="instance">
+       <br>
+       <button type="submit"> Submit </button>
+</form>
+{{template "footer.tmpl"}}
diff --git a/templates/status.tmpl b/templates/status.tmpl
new file mode 100644 (file)
index 0000000..47ff6e4
--- /dev/null
@@ -0,0 +1,43 @@
+<div class="status-container">
+       <div>
+               <img class="status-profile-img" src="{{.Account.AvatarStatic}}" alt="profile-avatar" />
+       </div>
+       <div class="status"> 
+               <div class="status-name">
+                       <span class="status-dname"> {{WithEmojis .Account.DisplayName .Account.Emojis}} </span> 
+                       <span class="status-uname"> {{.Account.Acct}} </span>
+               </div>
+               <div class="status-content"> {{WithEmojis .Content .Emojis}} </div>
+               <div class="status-action"> 
+                       <a class="status-you" href="/thread/{{.ID}}?reply=true" title="reply"> 
+                               <span class="icon dripicons-reply"></span> 
+                               <span> {{DisplayInteractionCount .RepliesCount}} </span>
+                       </a>
+                       {{if .Reblogged}}
+                       <a class="status-retweet" href="/unretweet/{{.ID}}" title="undo repost"> 
+                               <span class="icon dripicons-retweet retweeted"></span> 
+                               <span> {{DisplayInteractionCount .ReblogsCount}} </span>
+                       </a>
+                       {{else}}
+                       <a class="status-retweet" href="/retweet/{{.ID}}" title="repost"> 
+                               <span class="icon dripicons-retweet"></span> 
+                               <span> {{DisplayInteractionCount .ReblogsCount}} </span>
+                       </a>
+                       {{end}}
+                       {{if .Favourited}}
+                       <a class="status-like" href="/unlike/{{.ID}}" title="unlike"> 
+                               <span class="icon dripicons-star liked"></span> 
+                               <span> {{DisplayInteractionCount .FavouritesCount}} </span>
+                       </a>
+                       {{else}}
+                       <a class="status-like" href="/like/{{.ID}}" title="like"> 
+                               <span class="icon dripicons-star"></span> 
+                               <span> {{DisplayInteractionCount .FavouritesCount}} </span>
+                       </a>
+                       {{end}}
+                       <a class="status-time" href="/thread/{{.ID}}"> 
+                               <time datetime="{{FormatTimeRFC3339 .CreatedAt}}" title="{{.CreatedAt}}"> {{TimeSince .CreatedAt}} </time> 
+                       </a>
+               </div>
+       </div>
+</div>
diff --git a/templates/thread.tmpl b/templates/thread.tmpl
new file mode 100644 (file)
index 0000000..4d6aad0
--- /dev/null
@@ -0,0 +1,24 @@
+{{template "header.tmpl"}}
+<h1> THREAD </h1>
+
+{{range .Context.Ancestors}}
+{{template "status.tmpl" .}}
+{{end}}
+
+{{template "status.tmpl" .Status}}
+{{if .PostReply}}
+<form class="timeline-post-form" action="/post" method="POST">
+       <input type="hidden" name="reply_to_id" value="{{.ReplyToID}}" />
+       <label for="post-content"> Reply to {{.Status.Account.DisplayName}} </label>
+       <br/>
+       <textarea id="post-content" name="content" class="post-content" cols="50" rows="5"></textarea>
+       <br/>
+       <button type="submit"> Post </button>
+</form>
+{{end}}
+
+{{range .Context.Descendants}}
+{{template "status.tmpl" .}}
+{{end}}
+
+{{template "footer.tmpl"}}
diff --git a/templates/timeline.tmpl b/templates/timeline.tmpl
new file mode 100644 (file)
index 0000000..b9ee3a5
--- /dev/null
@@ -0,0 +1,22 @@
+{{template "header.tmpl"}}
+<h1> TIMELINE </h1>
+
+<form class="timeline-post-form" action="/post" method="POST">
+       <label for="post-content"> New Post </label>
+       <br/>
+       <textarea id="post-content" name="content" class="post-content" cols="50" rows="5"></textarea>
+       <br/>
+       <button type="submit"> Post </button>
+</form>
+
+{{range .Statuses}}
+{{template "status.tmpl" .}}
+{{end}}
+
+{{if .HasNext}}
+       <a href="{{.NextLink}}"> next </a>
+{{end}}
+{{if .HasPrev}}
+       <a href="{{.PrevLink}}"> next </a>
+{{end}}
+{{template "footer.tmpl"}}
diff --git a/util/rand.go b/util/rand.go
new file mode 100644 (file)
index 0000000..8502521
--- /dev/null
@@ -0,0 +1,22 @@
+package util
+
+import (
+       "math/rand"
+)
+
+var (
+       runes        = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890")
+       runes_length = len(runes)
+)
+
+func NewRandId(n int) string {
+       data := make([]rune, n)
+       for i := range data {
+               data[i] = runes[rand.Intn(runes_length)]
+       }
+       return string(data)
+}
+
+func NewSessionId() string {
+       return NewRandId(24)
+}