diff options
43 files changed, 3429 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb908cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +web +database.db diff --git a/Makefile b/Makefile new file mode 100644 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 index 0000000..844672a --- /dev/null +++ b/config/config.go @@ -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 index 0000000..00d02a9 --- /dev/null +++ b/default.conf @@ -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 @@ -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 @@ -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= @@ -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 index 0000000..42066c7 --- /dev/null +++ b/mastodon/LICENSE @@ -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 index 0000000..9be937f --- /dev/null +++ b/mastodon/README.md @@ -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 index 0000000..e6f5a6d --- /dev/null +++ b/mastodon/accounts.go @@ -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 index 0000000..5d925c3 --- /dev/null +++ b/mastodon/apps.go @@ -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 index 0000000..ea24109 --- /dev/null +++ b/mastodon/go.mod @@ -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 index 0000000..3ec24b2 --- /dev/null +++ b/mastodon/go.sum @@ -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 index 0000000..05af20f --- /dev/null +++ b/mastodon/helper.go @@ -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 index 0000000..3217450 --- /dev/null +++ b/mastodon/instance.go @@ -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 index 0000000..d323b79 --- /dev/null +++ b/mastodon/lists.go @@ -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 index 0000000..ff86d2b --- /dev/null +++ b/mastodon/mastodon.go @@ -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 index 0000000..236fcbf --- /dev/null +++ b/mastodon/notification.go @@ -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, ¬ifications, 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, ¬ification, nil) + if err != nil { + return nil, err + } + return ¬ification, 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 index 0000000..920614a --- /dev/null +++ b/mastodon/report.go @@ -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 index 0000000..fd69914 --- /dev/null +++ b/mastodon/status.go @@ -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 index 0000000..77ae284 --- /dev/null +++ b/mastodon/streaming.go @@ -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]), ¬ification) + if err == nil { + q <- &NotificationEvent{¬ification} + } + 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 index 0000000..838f65b --- /dev/null +++ b/mastodon/streaming_ws.go @@ -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)), ¬ification) + if err == nil { + q <- &NotificationEvent{Notification: ¬ification} + } + 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 index 0000000..a935a9e --- /dev/null +++ b/mastodon/unixtime.go @@ -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 index 0000000..52ebdf5 --- /dev/null +++ b/model/app.go @@ -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 index 0000000..43628ee --- /dev/null +++ b/model/session.go @@ -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 index 0000000..ddc9e2d --- /dev/null +++ b/renderer/model.go @@ -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 index 0000000..c3d3526 --- /dev/null +++ b/renderer/renderer.go @@ -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 index 0000000..1a8f204 --- /dev/null +++ b/repository/appRepository.go @@ -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 index 0000000..2a88b40 --- /dev/null +++ b/repository/sessionRepository.go @@ -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 index 0000000..cb442a7 --- /dev/null +++ b/service/auth.go @@ -0,0 +1,151 @@ +package service + +import ( + "context" + "errors" + "io" + "mastodon" + "web/model" +) + +var ( + ErrInvalidSession = errors.New("invalid session") +) + +type authService struct { + sessionRepo model.SessionRepository + appRepo model.AppRepository + Service +} + +func NewAuthService(sessionRepo model.SessionRepository, appRepo model.AppRepository, s Service) Service { + return &authService{sessionRepo, appRepo, s} +} + +func getSessionID(ctx context.Context) (sessionID string, err error) { + sessionID, ok := ctx.Value("session_id").(string) + if !ok || len(sessionID) < 1 { + return "", ErrInvalidSession + } + return sessionID, nil +} + +func (s *authService) getClient(ctx context.Context) (c *mastodon.Client, err error) { + sessionID, err := getSessionID(ctx) + if err != nil { + return nil, ErrInvalidSession + } + session, err := s.sessionRepo.Get(sessionID) + if err != nil { + return nil, ErrInvalidSession + } + client, err := s.appRepo.Get(session.InstanceURL) + if err != nil { + return + } + c = mastodon.NewClient(&mastodon.Config{ + Server: session.InstanceURL, + ClientID: client.ClientID, + ClientSecret: client.ClientSecret, + AccessToken: session.AccessToken, + }) + return c, nil +} + +func (s *authService) GetAuthUrl(ctx context.Context, instance string) ( + redirectUrl string, sessionID string, err error) { + return s.Service.GetAuthUrl(ctx, instance) +} + +func (s *authService) GetUserToken(ctx context.Context, sessionID string, c *mastodon.Client, + code string) (token string, err error) { + sessionID, err = getSessionID(ctx) + if err != nil { + return + } + c, err = s.getClient(ctx) + if err != nil { + return + } + + token, err = s.Service.GetUserToken(ctx, sessionID, c, code) + if err != nil { + return + } + + err = s.sessionRepo.Update(sessionID, token) + if err != nil { + return + } + + return +} + +func (s *authService) ServeHomePage(ctx context.Context, client io.Writer) (err error) { + return s.Service.ServeHomePage(ctx, client) +} + +func (s *authService) ServeErrorPage(ctx context.Context, client io.Writer, err error) { + s.Service.ServeErrorPage(ctx, client, err) +} + +func (s *authService) ServeSigninPage(ctx context.Context, client io.Writer) (err error) { + return s.Service.ServeSigninPage(ctx, client) +} + +func (s *authService) ServeTimelinePage(ctx context.Context, client io.Writer, + c *mastodon.Client, maxID string, sinceID string, minID string) (err error) { + c, err = s.getClient(ctx) + if err != nil { + return + } + return s.Service.ServeTimelinePage(ctx, client, c, maxID, sinceID, minID) +} + +func (s *authService) ServeThreadPage(ctx context.Context, client io.Writer, c *mastodon.Client, id string, reply bool) (err error) { + c, err = s.getClient(ctx) + if err != nil { + return + } + return s.Service.ServeThreadPage(ctx, client, c, id, reply) +} + +func (s *authService) Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + c, err = s.getClient(ctx) + if err != nil { + return + } + return s.Service.Like(ctx, client, c, id) +} + +func (s *authService) UnLike(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + c, err = s.getClient(ctx) + if err != nil { + return + } + return s.Service.UnLike(ctx, client, c, id) +} + +func (s *authService) Retweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + c, err = s.getClient(ctx) + if err != nil { + return + } + return s.Service.Retweet(ctx, client, c, id) +} + +func (s *authService) UnRetweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + c, err = s.getClient(ctx) + if err != nil { + return + } + return s.Service.UnRetweet(ctx, client, c, id) +} + +func (s *authService) PostTweet(ctx context.Context, client io.Writer, c *mastodon.Client, content string, replyToID string) (err error) { + c, err = s.getClient(ctx) + if err != nil { + return + } + return s.Service.PostTweet(ctx, client, c, content, replyToID) +} diff --git a/service/logging.go b/service/logging.go new file mode 100644 index 0000000..b11599e --- /dev/null +++ b/service/logging.go @@ -0,0 +1,117 @@ +package service + +import ( + "context" + "io" + "log" + "mastodon" + "time" +) + +type loggingService struct { + logger *log.Logger + Service +} + +func NewLoggingService(logger *log.Logger, s Service) Service { + return &loggingService{logger, s} +} + +func (s *loggingService) GetAuthUrl(ctx context.Context, instance string) ( + redirectUrl string, sessionID string, err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, instance=%v, took=%v, err=%v\n", + "GetAuthUrl", instance, time.Since(begin), err) + }(time.Now()) + return s.Service.GetAuthUrl(ctx, instance) +} + +func (s *loggingService) GetUserToken(ctx context.Context, sessionID string, c *mastodon.Client, + code string) (token string, err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, session_id=%v, code=%v, took=%v, err=%v\n", + "GetUserToken", sessionID, code, time.Since(begin), err) + }(time.Now()) + return s.Service.GetUserToken(ctx, sessionID, c, code) +} + +func (s *loggingService) ServeHomePage(ctx context.Context, client io.Writer) (err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, took=%v, err=%v\n", + "ServeHomePage", time.Since(begin), err) + }(time.Now()) + return s.Service.ServeHomePage(ctx, client) +} + +func (s *loggingService) ServeErrorPage(ctx context.Context, client io.Writer, err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, err=%v, took=%v\n", + "ServeErrorPage", err, time.Since(begin)) + }(time.Now()) + s.Service.ServeErrorPage(ctx, client, err) +} + +func (s *loggingService) ServeSigninPage(ctx context.Context, client io.Writer) (err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, took=%v, err=%v\n", + "ServeSigninPage", time.Since(begin), err) + }(time.Now()) + return s.Service.ServeSigninPage(ctx, client) +} + +func (s *loggingService) ServeTimelinePage(ctx context.Context, client io.Writer, + c *mastodon.Client, maxID string, sinceID string, minID string) (err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, max_id=%v, since_id=%v, min_id=%v, took=%v, err=%v\n", + "ServeTimelinePage", maxID, sinceID, minID, time.Since(begin), err) + }(time.Now()) + return s.Service.ServeTimelinePage(ctx, client, c, maxID, sinceID, minID) +} + +func (s *loggingService) ServeThreadPage(ctx context.Context, client io.Writer, c *mastodon.Client, id string, reply bool) (err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, id=%v, reply=%v, took=%v, err=%v\n", + "ServeThreadPage", id, reply, time.Since(begin), err) + }(time.Now()) + return s.Service.ServeThreadPage(ctx, client, c, id, reply) +} + +func (s *loggingService) Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, id=%v, took=%v, err=%v\n", + "Like", id, time.Since(begin), err) + }(time.Now()) + return s.Service.Like(ctx, client, c, id) +} + +func (s *loggingService) UnLike(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, id=%v, took=%v, err=%v\n", + "UnLike", id, time.Since(begin), err) + }(time.Now()) + return s.Service.UnLike(ctx, client, c, id) +} + +func (s *loggingService) Retweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, id=%v, took=%v, err=%v\n", + "Retweet", id, time.Since(begin), err) + }(time.Now()) + return s.Service.Retweet(ctx, client, c, id) +} + +func (s *loggingService) UnRetweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, id=%v, took=%v, err=%v\n", + "UnRetweet", id, time.Since(begin), err) + }(time.Now()) + return s.Service.UnRetweet(ctx, client, c, id) +} + +func (s *loggingService) PostTweet(ctx context.Context, client io.Writer, c *mastodon.Client, content string, replyToID string) (err error) { + defer func(begin time.Time) { + s.logger.Printf("method=%v, content=%v, reply_to_id=%v, took=%v, err=%v\n", + "PostTweet", content, replyToID, time.Since(begin), err) + }(time.Now()) + return s.Service.PostTweet(ctx, client, c, content, replyToID) +} diff --git a/service/service.go b/service/service.go new file mode 100644 index 0000000..7088a9b --- /dev/null +++ b/service/service.go @@ -0,0 +1,285 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + + "mastodon" + "web/model" + "web/renderer" + "web/util" +) + +var ( + ErrInvalidArgument = errors.New("invalid argument") + ErrInvalidToken = errors.New("invalid token") + ErrInvalidClient = errors.New("invalid client") +) + +type Service interface { + ServeHomePage(ctx context.Context, client io.Writer) (err error) + GetAuthUrl(ctx context.Context, instance string) (url string, sessionID string, err error) + GetUserToken(ctx context.Context, sessionID string, c *mastodon.Client, token string) (accessToken string, err error) + ServeErrorPage(ctx context.Context, client io.Writer, err error) + ServeSigninPage(ctx context.Context, client io.Writer) (err error) + ServeTimelinePage(ctx context.Context, client io.Writer, c *mastodon.Client, maxID string, sinceID string, minID string) (err error) + ServeThreadPage(ctx context.Context, client io.Writer, c *mastodon.Client, id string, reply bool) (err error) + Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) + UnLike(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) + Retweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) + UnRetweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) + PostTweet(ctx context.Context, client io.Writer, c *mastodon.Client, content string, replyToID string) (err error) +} + +type service struct { + clientName string + clientScope string + clientWebsite string + renderer renderer.Renderer + sessionRepo model.SessionRepository + appRepo model.AppRepository +} + +func NewService(clientName string, clientScope string, clientWebsite string, + renderer renderer.Renderer, sessionRepo model.SessionRepository, + appRepo model.AppRepository) Service { + return &service{ + clientName: clientName, + clientScope: clientScope, + clientWebsite: clientWebsite, + renderer: renderer, + sessionRepo: sessionRepo, + appRepo: appRepo, + } +} + +func (svc *service) GetAuthUrl(ctx context.Context, instance string) ( + redirectUrl string, sessionID string, err error) { + if !strings.HasPrefix(instance, "https://") { + instance = "https://" + instance + } + + sessionID = util.NewSessionId() + err = svc.sessionRepo.Add(model.Session{ + ID: sessionID, + InstanceURL: instance, + }) + if err != nil { + return + } + + app, err := svc.appRepo.Get(instance) + if err != nil { + if err != model.ErrAppNotFound { + return + } + + var mastoApp *mastodon.Application + mastoApp, err = mastodon.RegisterApp(ctx, &mastodon.AppConfig{ + Server: instance, + ClientName: svc.clientName, + Scopes: svc.clientScope, + Website: svc.clientWebsite, + RedirectURIs: svc.clientWebsite + "/oauth_callback", + }) + if err != nil { + return + } + + app = model.App{ + InstanceURL: instance, + ClientID: mastoApp.ClientID, + ClientSecret: mastoApp.ClientSecret, + } + + err = svc.appRepo.Add(app) + if err != nil { + return + } + } + + u, err := url.Parse(path.Join(instance, "/oauth/authorize")) + if err != nil { + return + } + + q := make(url.Values) + q.Set("scope", "read write follow") + q.Set("client_id", app.ClientID) + q.Set("response_type", "code") + q.Set("redirect_uri", svc.clientWebsite+"/oauth_callback") + u.RawQuery = q.Encode() + + redirectUrl = u.String() + + return +} + +func (svc *service) GetUserToken(ctx context.Context, sessionID string, c *mastodon.Client, + code string) (token string, err error) { + if len(code) < 1 { + err = ErrInvalidArgument + return + } + + session, err := svc.sessionRepo.Get(sessionID) + if err != nil { + return + } + + app, err := svc.appRepo.Get(session.InstanceURL) + if err != nil { + return + } + + data := &bytes.Buffer{} + err = json.NewEncoder(data).Encode(map[string]string{ + "client_id": app.ClientID, + "client_secret": app.ClientSecret, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": svc.clientWebsite + "/oauth_callback", + }) + if err != nil { + return + } + + resp, err := http.Post(app.InstanceURL+"/oauth/token", "application/json", data) + if err != nil { + return + } + defer resp.Body.Close() + + var res struct { + AccessToken string `json:"access_token"` + } + + err = json.NewDecoder(resp.Body).Decode(&res) + if err != nil { + return + } + /* + err = c.AuthenticateToken(ctx, code, svc.clientWebsite+"/oauth_callback") + if err != nil { + return + } + err = svc.sessionRepo.Update(sessionID, c.GetAccessToken(ctx)) + */ + + return res.AccessToken, nil +} + +func (svc *service) ServeHomePage(ctx context.Context, client io.Writer) (err error) { + err = svc.renderer.RenderHomePage(ctx, client) + if err != nil { + return + } + + return +} + +func (svc *service) ServeErrorPage(ctx context.Context, client io.Writer, err error) { + svc.renderer.RenderErrorPage(ctx, client, err) +} + +func (svc *service) ServeSigninPage(ctx context.Context, client io.Writer) (err error) { + err = svc.renderer.RenderSigninPage(ctx, client) + if err != nil { + return + } + + return +} + +func (svc *service) ServeTimelinePage(ctx context.Context, client io.Writer, + c *mastodon.Client, maxID string, sinceID string, minID string) (err error) { + + var hasNext, hasPrev bool + var nextLink, prevLink string + + var pg = mastodon.Pagination{ + MaxID: maxID, + SinceID: sinceID, + MinID: minID, + Limit: 20, + } + + statuses, err := c.GetTimelineHome(ctx, &pg) + if err != nil { + return err + } + + if len(pg.MaxID) > 0 { + hasNext = true + nextLink = fmt.Sprintf("/timeline?max_id=%s", pg.MaxID) + } + if len(pg.SinceID) > 0 { + hasPrev = true + prevLink = fmt.Sprintf("/timeline?since_id=%s", pg.SinceID) + } + + data := renderer.NewTimelinePageTemplateData(statuses, hasNext, nextLink, hasPrev, prevLink) + err = svc.renderer.RenderTimelinePage(ctx, client, data) + if err != nil { + return + } + + return +} + +func (svc *service) ServeThreadPage(ctx context.Context, client io.Writer, c *mastodon.Client, id string, reply bool) (err error) { + status, err := c.GetStatus(ctx, id) + if err != nil { + return + } + + context, err := c.GetStatusContext(ctx, id) + if err != nil { + return + } + + data := renderer.NewThreadPageTemplateData(status, context, reply, id) + err = svc.renderer.RenderThreadPage(ctx, client, data) + if err != nil { + return + } + + return +} + +func (svc *service) Like(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + _, err = c.Favourite(ctx, id) + return +} + +func (svc *service) UnLike(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + _, err = c.Unfavourite(ctx, id) + return +} + +func (svc *service) Retweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + _, err = c.Reblog(ctx, id) + return +} + +func (svc *service) UnRetweet(ctx context.Context, client io.Writer, c *mastodon.Client, id string) (err error) { + _, err = c.Unreblog(ctx, id) + return +} + +func (svc *service) PostTweet(ctx context.Context, client io.Writer, c *mastodon.Client, content string, replyToID string) (err error) { + tweet := &mastodon.Toot{ + Status: content, + InReplyToID: replyToID, + } + _, err = c.PostStatus(ctx, tweet) + return +} diff --git a/service/transport.go b/service/transport.go new file mode 100644 index 0000000..f4f5ed7 --- /dev/null +++ b/service/transport.go @@ -0,0 +1,165 @@ +package service + +import ( + "context" + "fmt" + "net/http" + "path" + + "github.com/gorilla/mux" +) + +var ( + ctx = context.Background() + cookieAge = "31536000" +) + +func getContextWithSession(ctx context.Context, req *http.Request) context.Context { + sessionID, err := req.Cookie("session_id") + if err != nil { + return ctx + } + return context.WithValue(ctx, "session_id", sessionID.Value) +} + +func NewHandler(s Service, staticDir string) http.Handler { + r := mux.NewRouter() + + r.PathPrefix("/static").Handler(http.StripPrefix("/static", + http.FileServer(http.Dir(path.Join(".", staticDir))))) + + r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + err := s.ServeHomePage(ctx, w) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + }).Methods(http.MethodGet) + + r.HandleFunc("/signin", func(w http.ResponseWriter, req *http.Request) { + err := s.ServeSigninPage(ctx, w) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + }).Methods(http.MethodGet) + + r.HandleFunc("/signin", func(w http.ResponseWriter, req *http.Request) { + instance := req.FormValue("instance") + url, sessionId, err := s.GetAuthUrl(ctx, instance) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + + w.Header().Add("Set-Cookie", fmt.Sprintf("session_id=%s;max-age=%s", sessionId, cookieAge)) + w.Header().Add("Location", url) + w.WriteHeader(http.StatusSeeOther) + }).Methods(http.MethodPost) + + r.HandleFunc("/oauth_callback", func(w http.ResponseWriter, req *http.Request) { + ctx := getContextWithSession(context.Background(), req) + token := req.URL.Query().Get("code") + _, err := s.GetUserToken(ctx, "", nil, token) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + + w.Header().Add("Location", "/timeline") + w.WriteHeader(http.StatusSeeOther) + }).Methods(http.MethodGet) + + r.HandleFunc("/timeline", func(w http.ResponseWriter, req *http.Request) { + ctx := getContextWithSession(context.Background(), req) + + maxID := req.URL.Query().Get("max_id") + sinceID := req.URL.Query().Get("since_id") + minID := req.URL.Query().Get("min_id") + + err := s.ServeTimelinePage(ctx, w, nil, maxID, sinceID, minID) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + }).Methods(http.MethodGet) + + r.HandleFunc("/thread/{id}", func(w http.ResponseWriter, req *http.Request) { + ctx := getContextWithSession(context.Background(), req) + id, _ := mux.Vars(req)["id"] + reply := req.URL.Query().Get("reply") + err := s.ServeThreadPage(ctx, w, nil, id, len(reply) > 1) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + }).Methods(http.MethodGet) + + r.HandleFunc("/like/{id}", func(w http.ResponseWriter, req *http.Request) { + ctx := getContextWithSession(context.Background(), req) + id, _ := mux.Vars(req)["id"] + err := s.Like(ctx, w, nil, id) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + + w.Header().Add("Location", req.Header.Get("Referer")) + w.WriteHeader(http.StatusSeeOther) + }).Methods(http.MethodGet) + + r.HandleFunc("/unlike/{id}", func(w http.ResponseWriter, req *http.Request) { + ctx := getContextWithSession(context.Background(), req) + id, _ := mux.Vars(req)["id"] + err := s.UnLike(ctx, w, nil, id) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + + w.Header().Add("Location", req.Header.Get("Referer")) + w.WriteHeader(http.StatusSeeOther) + }).Methods(http.MethodGet) + + r.HandleFunc("/retweet/{id}", func(w http.ResponseWriter, req *http.Request) { + ctx := getContextWithSession(context.Background(), req) + id, _ := mux.Vars(req)["id"] + err := s.Retweet(ctx, w, nil, id) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + + w.Header().Add("Location", req.Header.Get("Referer")) + w.WriteHeader(http.StatusSeeOther) + }).Methods(http.MethodGet) + + r.HandleFunc("/unretweet/{id}", func(w http.ResponseWriter, req *http.Request) { + ctx := getContextWithSession(context.Background(), req) + id, _ := mux.Vars(req)["id"] + err := s.UnRetweet(ctx, w, nil, id) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + + w.Header().Add("Location", req.Header.Get("Referer")) + w.WriteHeader(http.StatusSeeOther) + }).Methods(http.MethodGet) + + r.HandleFunc("/post", func(w http.ResponseWriter, req *http.Request) { + ctx := getContextWithSession(context.Background(), req) + content := req.FormValue("content") + replyToID := req.FormValue("reply_to_id") + err := s.PostTweet(ctx, w, nil, content, replyToID) + if err != nil { + s.ServeErrorPage(ctx, w, err) + return + } + + w.Header().Add("Location", req.Header.Get("Referer")) + w.WriteHeader(http.StatusSeeOther) + }).Methods(http.MethodPost) + + return r +} diff --git a/static/main.css b/static/main.css new file mode 100644 index 0000000..b415ae5 --- /dev/null +++ b/static/main.css @@ -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 index 0000000..b6943be --- /dev/null +++ b/templates/error.tmpl @@ -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 index 0000000..308b1d0 --- /dev/null +++ b/templates/footer.tmpl @@ -0,0 +1,2 @@ +</body> +</html> diff --git a/templates/header.tmpl b/templates/header.tmpl new file mode 100644 index 0000000..970aca4 --- /dev/null +++ b/templates/header.tmpl @@ -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 index 0000000..256bb29 --- /dev/null +++ b/templates/homepage.tmpl @@ -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 index 0000000..07bc132 --- /dev/null +++ b/templates/signin.tmpl @@ -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 index 0000000..47ff6e4 --- /dev/null +++ b/templates/status.tmpl @@ -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 index 0000000..4d6aad0 --- /dev/null +++ b/templates/thread.tmpl @@ -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 index 0000000..b9ee3a5 --- /dev/null +++ b/templates/timeline.tmpl @@ -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 index 0000000..8502521 --- /dev/null +++ b/util/rand.go @@ -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) +} |