Initial commit

This commit is contained in:
r 2019-12-13 18:08:26 +00:00
commit 5e4da01c3a
43 changed files with 3429 additions and 0 deletions

2
.gitignore vendored Normal file
View File

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

14
Makefile Normal file
View File

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

113
config/config.go Normal file
View File

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

7
default.conf Normal file
View File

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

11
go.mod Normal file
View File

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

8
go.sum Normal file
View File

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

76
main.go Normal file
View File

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

21
mastodon/LICENSE Normal file
View File

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

142
mastodon/README.md Normal file
View File

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

314
mastodon/accounts.go Normal file
View File

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

96
mastodon/apps.go Normal file
View File

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

8
mastodon/go.mod Normal file
View File

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

4
mastodon/go.sum Normal file
View File

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

55
mastodon/helper.go Normal file
View File

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

65
mastodon/instance.go Normal file
View File

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

107
mastodon/lists.go Normal file
View File

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

388
mastodon/mastodon.go Normal file
View File

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

42
mastodon/notification.go Normal file
View File

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

39
mastodon/report.go Normal file
View File

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

297
mastodon/status.go Normal file
View File

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

166
mastodon/streaming.go Normal file
View File

@ -0,0 +1,166 @@
package mastodon
import (
"bufio"
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"path"
"strings"
)
// UpdateEvent is struct for passing status event to app.
type UpdateEvent struct {
Status *Status `json:"status"`
}
func (e *UpdateEvent) event() {}
// NotificationEvent is struct for passing notification event to app.
type NotificationEvent struct {
Notification *Notification `json:"notification"`
}
func (e *NotificationEvent) event() {}
// DeleteEvent is struct for passing deletion event to app.
type DeleteEvent struct{ ID string }
func (e *DeleteEvent) event() {}
// ErrorEvent is struct for passing errors to app.
type ErrorEvent struct{ err error }
func (e *ErrorEvent) event() {}
func (e *ErrorEvent) Error() string { return e.err.Error() }
// Event is interface passing events to app.
type Event interface {
event()
}
func handleReader(q chan Event, r io.Reader) error {
var name string
s := bufio.NewScanner(r)
for s.Scan() {
line := s.Text()
token := strings.SplitN(line, ":", 2)
if len(token) != 2 {
continue
}
switch strings.TrimSpace(token[0]) {
case "event":
name = strings.TrimSpace(token[1])
case "data":
var err error
switch name {
case "update":
var status Status
err = json.Unmarshal([]byte(token[1]), &status)
if err == nil {
q <- &UpdateEvent{&status}
}
case "notification":
var notification Notification
err = json.Unmarshal([]byte(token[1]), &notification)
if err == nil {
q <- &NotificationEvent{&notification}
}
case "delete":
q <- &DeleteEvent{ID: string(strings.TrimSpace(token[1]))}
}
if err != nil {
q <- &ErrorEvent{err}
}
}
}
return s.Err()
}
func (c *Client) streaming(ctx context.Context, p string, params url.Values) (chan Event, error) {
u, err := url.Parse(c.config.Server)
if err != nil {
return nil, err
}
u.Path = path.Join(u.Path, "/api/v1/streaming", p)
u.RawQuery = params.Encode()
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
q := make(chan Event)
go func() {
defer close(q)
for {
select {
case <-ctx.Done():
return
default:
}
c.doStreaming(req, q)
}
}()
return q, nil
}
func (c *Client) doStreaming(req *http.Request, q chan Event) {
resp, err := c.Do(req)
if err != nil {
q <- &ErrorEvent{err}
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
q <- &ErrorEvent{parseAPIError("bad request", resp)}
return
}
err = handleReader(q, resp.Body)
if err != nil {
q <- &ErrorEvent{err}
}
}
// StreamingUser return channel to read events on home.
func (c *Client) StreamingUser(ctx context.Context) (chan Event, error) {
return c.streaming(ctx, "user", nil)
}
// StreamingPublic return channel to read events on public.
func (c *Client) StreamingPublic(ctx context.Context, isLocal bool) (chan Event, error) {
p := "public"
if isLocal {
p = path.Join(p, "local")
}
return c.streaming(ctx, p, nil)
}
// StreamingHashtag return channel to read events on tagged timeline.
func (c *Client) StreamingHashtag(ctx context.Context, tag string, isLocal bool) (chan Event, error) {
params := url.Values{}
params.Set("tag", tag)
p := "hashtag"
if isLocal {
p = path.Join(p, "local")
}
return c.streaming(ctx, p, params)
}
// StreamingList return channel to read events on a list.
func (c *Client) StreamingList(ctx context.Context, id string) (chan Event, error) {
params := url.Values{}
params.Set("list", string(id))
return c.streaming(ctx, "list", params)
}

195
mastodon/streaming_ws.go Normal file
View File

@ -0,0 +1,195 @@
package mastodon
import (
"context"
"encoding/json"
"fmt"
"net/url"
"path"
"strings"
"github.com/gorilla/websocket"
)
// WSClient is a WebSocket client.
type WSClient struct {
websocket.Dialer
client *Client
}
// NewWSClient return WebSocket client.
func (c *Client) NewWSClient() *WSClient { return &WSClient{client: c} }
// Stream is a struct of data that flows in streaming.
type Stream struct {
Event string `json:"event"`
Payload interface{} `json:"payload"`
}
// StreamingWSUser return channel to read events on home using WebSocket.
func (c *WSClient) StreamingWSUser(ctx context.Context) (chan Event, error) {
return c.streamingWS(ctx, "user", "")
}
// StreamingWSPublic return channel to read events on public using WebSocket.
func (c *WSClient) StreamingWSPublic(ctx context.Context, isLocal bool) (chan Event, error) {
s := "public"
if isLocal {
s += ":local"
}
return c.streamingWS(ctx, s, "")
}
// StreamingWSHashtag return channel to read events on tagged timeline using WebSocket.
func (c *WSClient) StreamingWSHashtag(ctx context.Context, tag string, isLocal bool) (chan Event, error) {
s := "hashtag"
if isLocal {
s += ":local"
}
return c.streamingWS(ctx, s, tag)
}
// StreamingWSList return channel to read events on a list using WebSocket.
func (c *WSClient) StreamingWSList(ctx context.Context, id string) (chan Event, error) {
return c.streamingWS(ctx, "list", string(id))
}
func (c *WSClient) streamingWS(ctx context.Context, stream, tag string) (chan Event, error) {
params := url.Values{}
params.Set("access_token", c.client.config.AccessToken)
params.Set("stream", stream)
if tag != "" {
params.Set("tag", tag)
}
u, err := changeWebSocketScheme(c.client.config.Server)
if err != nil {
return nil, err
}
u.Path = path.Join(u.Path, "/api/v1/streaming")
u.RawQuery = params.Encode()
q := make(chan Event)
go func() {
defer close(q)
for {
err := c.handleWS(ctx, u.String(), q)
if err != nil {
return
}
}
}()
return q, nil
}
func (c *WSClient) handleWS(ctx context.Context, rawurl string, q chan Event) error {
conn, err := c.dialRedirect(rawurl)
if err != nil {
q <- &ErrorEvent{err: err}
// End.
return err
}
// Close the WebSocket when the context is canceled.
go func() {
<-ctx.Done()
conn.Close()
}()
for {
select {
case <-ctx.Done():
q <- &ErrorEvent{err: ctx.Err()}
// End.
return ctx.Err()
default:
}
var s Stream
err := conn.ReadJSON(&s)
if err != nil {
q <- &ErrorEvent{err: err}
// Reconnect.
break
}
err = nil
switch s.Event {
case "update":
var status Status
err = json.Unmarshal([]byte(s.Payload.(string)), &status)
if err == nil {
q <- &UpdateEvent{Status: &status}
}
case "notification":
var notification Notification
err = json.Unmarshal([]byte(s.Payload.(string)), &notification)
if err == nil {
q <- &NotificationEvent{Notification: &notification}
}
case "delete":
if f, ok := s.Payload.(float64); ok {
q <- &DeleteEvent{ID: fmt.Sprint(int64(f))}
} else {
q <- &DeleteEvent{ID: strings.TrimSpace(s.Payload.(string))}
}
}
if err != nil {
q <- &ErrorEvent{err}
}
}
return nil
}
func (c *WSClient) dialRedirect(rawurl string) (conn *websocket.Conn, err error) {
for {
conn, rawurl, err = c.dial(rawurl)
if err != nil {
return nil, err
} else if conn != nil {
return conn, nil
}
}
}
func (c *WSClient) dial(rawurl string) (*websocket.Conn, string, error) {
conn, resp, err := c.Dial(rawurl, nil)
if err != nil && err != websocket.ErrBadHandshake {
return nil, "", err
}
defer resp.Body.Close()
if loc := resp.Header.Get("Location"); loc != "" {
u, err := changeWebSocketScheme(loc)
if err != nil {
return nil, "", err
}
return nil, u.String(), nil
}
return conn, "", err
}
func changeWebSocketScheme(rawurl string) (*url.URL, error) {
u, err := url.Parse(rawurl)
if err != nil {
return nil, err
}
switch u.Scheme {
case "http":
u.Scheme = "ws"
case "https":
u.Scheme = "wss"
}
return u, nil
}

20
mastodon/unixtime.go Normal file
View File

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

19
model/app.go Normal file
View File

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

23
model/session.go Normal file
View File

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

40
renderer/model.go Normal file
View File

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

112
renderer/renderer.go Normal file
View File

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

View File

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

View File

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

151
service/auth.go Normal file
View File

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

117
service/logging.go Normal file
View File

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

285
service/service.go Normal file
View File

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

165
service/transport.go Normal file
View File

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

77
static/main.css Normal file
View File

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

6
templates/error.tmpl Normal file
View File

@ -0,0 +1,6 @@
{{template "header.tmpl"}}
<h1> Error </h1>
<div> {{.}} </div>
<a href="/timeline"> Home </a>
{{template "footer.tmpl"}}

2
templates/footer.tmpl Normal file
View File

@ -0,0 +1,2 @@
</body>
</html>

10
templates/header.tmpl Normal file
View File

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

4
templates/homepage.tmpl Normal file
View File

@ -0,0 +1,4 @@
{{template "header.tmpl"}}
<h1> HOME </h1>
<a href="/signin"> Signin </a>
{{template "footer.tmpl"}}

9
templates/signin.tmpl Normal file
View File

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

43
templates/status.tmpl Normal file
View File

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

24
templates/thread.tmpl Normal file
View File

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

22
templates/timeline.tmpl Normal file
View File

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

22
util/rand.go Normal file
View File

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