Initial commit
This commit is contained in:
commit
5e4da01c3a
|
@ -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, ¬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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
{{template "header.tmpl"}}
|
||||||
|
<h1> Error </h1>
|
||||||
|
<div> {{.}} </div>
|
||||||
|
<a href="/timeline"> Home </a>
|
||||||
|
{{template "footer.tmpl"}}
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -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>
|
|
@ -0,0 +1,4 @@
|
||||||
|
{{template "header.tmpl"}}
|
||||||
|
<h1> HOME </h1>
|
||||||
|
<a href="/signin"> Signin </a>
|
||||||
|
{{template "footer.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"}}
|
|
@ -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>
|
|
@ -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"}}
|
|
@ -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"}}
|
|
@ -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)
|
||||||
|
}
|
Loading…
Reference in New Issue