Initial commit

master
r 3 years ago
commit 5e4da01c3a

2
.gitignore vendored

@ -0,0 +1,2 @@
web
database.db

@ -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

@ -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)
}

@ -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)
}
}

@ -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.

@ -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)

@ -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
}

@ -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
}

@ -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
)

@ -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=

@ -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)
}

@ -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
}

@ -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)
}

@ -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
}

@ -0,0 +1,42 @@
package mastodon
import (
"context"
"fmt"
"net/http"
"time"
)
// Notification hold information for mastodon notification.
type Notification struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt time.Time `json:"created_at"`
Account Account `json:"account"`
Status *Status `json:"status"`
}
// GetNotifications return notifications.
func (c *Client) GetNotifications(ctx context.Context, pg *Pagination) ([]*Notification, error) {
var notifications []*Notification
err := c.doAPI(ctx, http.MethodGet, "/api/v1/notifications", nil, &notifications, pg)
if err != nil {
return nil, err
}
return notifications, nil
}
// GetNotification return notification.
func (c *Client) GetNotification(ctx context.Context, id string) (*Notification, error) {
var notification Notification
err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/notifications/%v", id), nil, &notification, nil)
if err != nil {
return nil, err
}
return &notification, nil
}
// ClearNotifications clear notifications.
func (c *Client) ClearNotifications(ctx context.Context) error {
return c.doAPI(ctx, http.MethodPost, "/api/v1/notifications/clear", nil, nil, nil)
}

@ -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