initial commit

This commit is contained in:
Nick Gerakines 2020-02-27 10:43:16 -05:00
parent d7c0ed1ac0
commit 50cfa8e395
71 changed files with 7459 additions and 2 deletions

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2019 Nick Gerakines
Copyright (c) 2020 Nick Gerakines
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

107
README.md
View File

@ -1 +1,106 @@
# tavern
# tavern
A minimalistic Activity Pub server. Think [mastodon](https://joinmastodon.org/), but smaller and with fewer features.
**_This project is in active development and should not be used in production._**
# Build
$ go build -o tavern main.go
# Commands
```
NAME:
tavern - The tavern application.
USAGE:
main [global options] command [command options] [arguments...]
COMMANDS:
init Initialize the server
server Run the web server.
help, h Shows a list of commands or help for one command
GLOBAL OPTIONS:
--help, -h show help (default: false)
--version, -v print the version (default: false)
COPYRIGHT:
(c) 2020 Nick Gerakines
```
## Init
```
NAME:
main init - Initialize the server
USAGE:
main init [command options] [arguments...]
OPTIONS:
--environment value Set the environment the application is running in. (default: "development") [$ENVIRONMENT]
--listen value Configure the server to listen to this interface. (default: "0.0.0.0:8000") [$LISTEN]
--domain value Set the website domain. (default: "tavern.ngrok.io") [$DOMAIN]
--database value Select the database to connect to. (default: "host=localhost port=5432 user=postgres dbname=tavern password=password sslmode=disable") [$DATABASE]
--translations value The path translations are located (default: "translations") [$TRANSLATIONS]
--admin-email value The email address of the admin user
--admin-password value The password of the admin user
--admin-locale value The locale of the admin user (default: "en")
--admin-name value The name of the admin user
--admin-displayname value The display name of the admin user
--admin-about value The 'about me' of the admin user
--help, -h show help (default: false)
```
## Server
```
NAME:
main server - Run the web server.
USAGE:
main server [command options] [arguments...]
OPTIONS:
--environment value Set the environment the application is running in. (default: "development") [$ENVIRONMENT]
--listen value Configure the server to listen to this interface. (default: "0.0.0.0:8000") [$LISTEN]
--domain value Set the website domain. (default: "tavern.ngrok.io") [$DOMAIN]
--database value Select the database to connect to. (default: "host=localhost port=5432 user=postgres dbname=tavern password=password sslmode=disable") [$DATABASE]
--translations value The path translations are located (default: "translations") [$TRANSLATIONS]
--secret value Set the server secret [$SECRET]
--admin-name value The name of the admin user (default: "nick") [$ADMIN_NAME]
--help, -h show help (default: false)
```
# Roadmap
* [x] User authentication
* [x] Follow remote user
* [x] User inbox - Follower "Accept" activities
* [ ] Display feed - Note
* [ ] Display feed - Announce
* [ ] Announce from feed
* [ ] Reply to note with conversation and context
* [ ] Activity endpoint
* [ ] User inbox - "Undo Follow" activities
* [ ] Delete activities
* [ ] Server inbox
* [ ] Create note non-english
* [ ] Server stats
* [ ] Markdown support in notes
* [ ] User thumbnails/avatars
* [ ] Tags support in notes
* [ ] Mentions support in notes
* [ ] Actor outbox
* [ ] Public timeline API
* [ ] 2FA
* [ ] Unfollow
* [ ] Activity de-duplication
* [ ] Sentry integration
* [ ] Email notifications
* [ ] Pushover notifications
* [ ] HTML User profile pages
* [ ] HTML activity pages
* [ ] Direct message support

56
config/components.go Normal file
View File

@ -0,0 +1,56 @@
package config
import (
"encoding/hex"
"strings"
"github.com/gofrs/uuid"
"github.com/urfave/cli/v2"
"go.uber.org/zap"
)
var ProductionEnvironment = "production"
var SecretFlag = cli.StringFlag{
Name: "secret",
Usage: "Set the server secret",
EnvVars: []string{"SECRET"},
}
func Logger(c *cli.Context) (*zap.Logger, error) {
if c.String("environment") == ProductionEnvironment {
return zap.NewProduction()
}
return zap.NewDevelopment()
}
func ListenAddress(cliCtx *cli.Context) string {
if listen := cliCtx.String("listen"); len(listen) > 0 {
return listen
}
return ":8080"
}
func Secret(cliCtx *cli.Context) ([]byte, error) {
secret := cliCtx.String("secret")
secret = strings.TrimSpace(secret)
if len(secret) > 0 {
decoded, err := hex.DecodeString(secret)
if err != nil {
return nil, err
}
return first32Bytes(decoded), nil
}
id, err := uuid.NewV4()
if err != nil {
return nil, err
}
return first32Bytes(id.Bytes()), nil
}
func first32Bytes(in []byte) []byte {
if len(in) > 32 {
return in[0:32]
}
return in
}

11
config/flags.go Normal file
View File

@ -0,0 +1,11 @@
package config
import (
"github.com/urfave/cli/v2"
)
var EnvironmentFlag = cli.StringFlag{Name: "environment", Usage: "Set the environment the application is running in.", EnvVars: []string{"ENVIRONMENT",}, Value: "development",}
var ListenFlag = cli.StringFlag{Name: "listen", Usage: "Configure the server to listen to this interface.", EnvVars: []string{"LISTEN",}, Value: "0.0.0.0:8000",}
var DomainFlag = cli.StringFlag{Name: "domain", Usage: "Set the website domain.", Value: "tavern.ngrok.io", EnvVars: []string{"DOMAIN",},}

31
config/i18n.go Normal file
View File

@ -0,0 +1,31 @@
package config
import (
"github.com/go-playground/locales/en"
ut "github.com/go-playground/universal-translator"
"github.com/urfave/cli/v2"
)
var TranslationsFlag = cli.StringFlag{
Name: "translations",
Usage: "The path translations are located",
EnvVars: []string{"TRANSLATIONS"},
Value: "translations",
}
func Trans(cliCtx *cli.Context) (*ut.UniversalTranslator, error) {
english := en.New()
utrans := ut.New(english, english)
err := utrans.Import(ut.FormatJSON, cliCtx.String("translations"))
if err != nil {
return nil, err
}
err = utrans.VerifyTranslations()
if err != nil {
return nil, err
}
return utrans, nil
}

37
config/sentry.go Normal file
View File

@ -0,0 +1,37 @@
package config
import (
"fmt"
"github.com/urfave/cli/v2"
)
type SentryConfig struct {
Enabled bool
Key string
}
var EnableSentryFlag = cli.BoolFlag{
Name: "enable-sentry",
Usage: "Enable sentry integration",
EnvVars: []string{"ENABLE_SENTRY"},
Value: false,
}
var SentryFlag = cli.StringFlag{
Name: "sentry",
Usage: "Configure the sentry key to use.",
EnvVars: []string{"SENTRY_KEY",},
Value: "",
}
func NewSentryConfig(cliCtx *cli.Context) (SentryConfig, error) {
cfg := SentryConfig{
Enabled: cliCtx.Bool("enable-sentry"),
Key: cliCtx.String("sentry"),
}
if cfg.Enabled && len(cfg.Key) == 0 {
return cfg, fmt.Errorf("invalid sentry config: sentry enabled with empty key")
}
return cfg, nil
}

33
config/storage.go Normal file
View File

@ -0,0 +1,33 @@
package config
import (
"database/sql"
"github.com/urfave/cli/v2"
"go.uber.org/zap"
)
var DatabaseFlag = cli.StringFlag{
Name: "database",
Usage: "Select the database to connect to.",
Value: "host=localhost port=5432 user=postgres dbname=tavern password=password sslmode=disable",
EnvVars: []string{"DATABASE",},
}
func DB(c *cli.Context, logger *zap.Logger) (*sql.DB, func(), error) {
dbConnInfo := c.String("database")
db, err := sql.Open("postgres", dbConnInfo)
if err != nil {
return nil, nil, err
}
dbClose := func() {
if closeErr := db.Close(); closeErr != nil {
logger.Error("error closing database connection", zap.Error(closeErr))
}
}
if err := db.Ping(); err != nil {
dbClose()
return nil, nil, err
}
return db, dbClose, nil
}

1464
errors/errors_generated.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,840 @@
// Code generated by go generate; DO NOT EDIT.
// This file was generated by herr at 2020-02-25 12:24:47.40365038 -0500 EST m=+0.012451131
package errors
import (
"fmt"
"testing"
"errors"
)
func TestNotFound (t *testing.T) {
err1 := NewNotFoundError(nil)
{
err1, ok := err1.(NotFoundError)
if !ok {
t.Errorf("Assertion failed on NotFound: %T is not NotFoundError", err1)
}
if err1.Prefix() != "TAV" {
t.Errorf("Assertion failed on NotFound: %s != TAV", err1.Prefix())
}
if err1.Code() != 1 {
t.Errorf("Assertion failed on NotFound: %d != 1", err1.Code())
}
if err1.Description() != "Not found." {
t.Errorf("Assertion failed on NotFound: %s != Not found.", err1.Description())
}
}
errNotFound := fmt.Errorf("not found")
errThingNotFound := fmt.Errorf("thing: %w", errNotFound)
err2 := NewNotFoundError(errThingNotFound)
{
err2, ok := err2.(NotFoundError)
if !ok {
t.Errorf("Assertion failed on NotFound: %T is not NotFoundError", err2)
}
errNestErr2 := fmt.Errorf("oh snap: %w", err2)
if err2.Code() != 1 {
t.Errorf("Assertion failed on NotFound: %d != 1", err2.Code())
}
if !errors.Is(err2, errNotFound) {
t.Errorf("Assertion failed on NotFound: errNotFound not unwrapped correctly")
}
if !errors.Is(err2, errThingNotFound) {
t.Errorf("Assertion failed on NotFound: errThingNotFound not unwrapped correctly")
}
if !errors.Is(err2, NotFoundError{}) {
t.Errorf("Assertion failed on NotFound: NotFoundError{} not identified correctly")
}
if !errors.Is(errNestErr2, NotFoundError{}) {
t.Errorf("Assertion failed on NotFound: NotFoundError{} not identified correctly")
}
}
}
func TestEncryptFailed (t *testing.T) {
err1 := NewEncryptFailedError(nil)
{
err1, ok := err1.(EncryptFailedError)
if !ok {
t.Errorf("Assertion failed on EncryptFailed: %T is not EncryptFailedError", err1)
}
if err1.Prefix() != "TAV" {
t.Errorf("Assertion failed on EncryptFailed: %s != TAV", err1.Prefix())
}
if err1.Code() != 2 {
t.Errorf("Assertion failed on EncryptFailed: %d != 2", err1.Code())
}
if err1.Description() != "Encrypting data failed." {
t.Errorf("Assertion failed on EncryptFailed: %s != Encrypting data failed.", err1.Description())
}
}
errNotFound := fmt.Errorf("not found")
errThingNotFound := fmt.Errorf("thing: %w", errNotFound)
err2 := NewEncryptFailedError(errThingNotFound)
{
err2, ok := err2.(EncryptFailedError)
if !ok {
t.Errorf("Assertion failed on EncryptFailed: %T is not EncryptFailedError", err2)
}
errNestErr2 := fmt.Errorf("oh snap: %w", err2)
if err2.Code() != 2 {
t.Errorf("Assertion failed on EncryptFailed: %d != 2", err2.Code())
}
if !errors.Is(err2, errNotFound) {
t.Errorf("Assertion failed on EncryptFailed: errNotFound not unwrapped correctly")
}
if !errors.Is(err2, errThingNotFound) {
t.Errorf("Assertion failed on EncryptFailed: errThingNotFound not unwrapped correctly")
}
if !errors.Is(err2, EncryptFailedError{}) {
t.Errorf("Assertion failed on EncryptFailed: EncryptFailedError{} not identified correctly")
}
if !errors.Is(errNestErr2, EncryptFailedError{}) {
t.Errorf("Assertion failed on EncryptFailed: EncryptFailedError{} not identified correctly")
}
}
}
func TestDecryptFailed (t *testing.T) {
err1 := NewDecryptFailedError(nil)
{
err1, ok := err1.(DecryptFailedError)
if !ok {
t.Errorf("Assertion failed on DecryptFailed: %T is not DecryptFailedError", err1)
}
if err1.Prefix() != "TAV" {
t.Errorf("Assertion failed on DecryptFailed: %s != TAV", err1.Prefix())
}
if err1.Code() != 3 {
t.Errorf("Assertion failed on DecryptFailed: %d != 3", err1.Code())
}
if err1.Description() != "Decrypting data failed." {
t.Errorf("Assertion failed on DecryptFailed: %s != Decrypting data failed.", err1.Description())
}
}
errNotFound := fmt.Errorf("not found")
errThingNotFound := fmt.Errorf("thing: %w", errNotFound)
err2 := NewDecryptFailedError(errThingNotFound)
{
err2, ok := err2.(DecryptFailedError)
if !ok {
t.Errorf("Assertion failed on DecryptFailed: %T is not DecryptFailedError", err2)
}
errNestErr2 := fmt.Errorf("oh snap: %w", err2)
if err2.Code() != 3 {
t.Errorf("Assertion failed on DecryptFailed: %d != 3", err2.Code())
}
if !errors.Is(err2, errNotFound) {
t.Errorf("Assertion failed on DecryptFailed: errNotFound not unwrapped correctly")
}
if !errors.Is(err2, errThingNotFound) {
t.Errorf("Assertion failed on DecryptFailed: errThingNotFound not unwrapped correctly")
}
if !errors.Is(err2, DecryptFailedError{}) {
t.Errorf("Assertion failed on DecryptFailed: DecryptFailedError{} not identified correctly")
}
if !errors.Is(errNestErr2, DecryptFailedError{}) {
t.Errorf("Assertion failed on DecryptFailed: DecryptFailedError{} not identified correctly")
}
}
}
func TestQueryFailed (t *testing.T) {
err1 := NewQueryFailedError(nil)
{
err1, ok := err1.(QueryFailedError)
if !ok {
t.Errorf("Assertion failed on QueryFailed: %T is not QueryFailedError", err1)
}
if err1.Prefix() != "TAVDAT" {
t.Errorf("Assertion failed on QueryFailed: %s != TAVDAT", err1.Prefix())
}
if err1.Code() != 1 {
t.Errorf("Assertion failed on QueryFailed: %d != 1", err1.Code())
}
if err1.Description() != "The query operation failed." {
t.Errorf("Assertion failed on QueryFailed: %s != The query operation failed.", err1.Description())
}
}
errNotFound := fmt.Errorf("not found")
errThingNotFound := fmt.Errorf("thing: %w", errNotFound)
err2 := NewQueryFailedError(errThingNotFound)
{
err2, ok := err2.(QueryFailedError)
if !ok {
t.Errorf("Assertion failed on QueryFailed: %T is not QueryFailedError", err2)
}
errNestErr2 := fmt.Errorf("oh snap: %w", err2)
if err2.Code() != 1 {
t.Errorf("Assertion failed on QueryFailed: %d != 1", err2.Code())
}
if !errors.Is(err2, errNotFound) {
t.Errorf("Assertion failed on QueryFailed: errNotFound not unwrapped correctly")
}
if !errors.Is(err2, errThingNotFound) {
t.Errorf("Assertion failed on QueryFailed: errThingNotFound not unwrapped correctly")
}
if !errors.Is(err2, QueryFailedError{}) {
t.Errorf("Assertion failed on QueryFailed: QueryFailedError{} not identified correctly")
}
if !errors.Is(errNestErr2, QueryFailedError{}) {
t.Errorf("Assertion failed on QueryFailed: QueryFailedError{} not identified correctly")
}
}
}
func TestDatabaseTransactionFailed (t *testing.T) {
err1 := NewDatabaseTransactionFailedError(nil)
{
err1, ok := err1.(DatabaseTransactionFailedError)
if !ok {
t.Errorf("Assertion failed on DatabaseTransactionFailed: %T is not DatabaseTransactionFailedError", err1)
}
if err1.Prefix() != "TAVDAT" {
t.Errorf("Assertion failed on DatabaseTransactionFailed: %s != TAVDAT", err1.Prefix())
}
if err1.Code() != 2 {
t.Errorf("Assertion failed on DatabaseTransactionFailed: %d != 2", err1.Code())
}
if err1.Description() != "The database transaction failed." {
t.Errorf("Assertion failed on DatabaseTransactionFailed: %s != The database transaction failed.", err1.Description())
}
}
errNotFound := fmt.Errorf("not found")
errThingNotFound := fmt.Errorf("thing: %w", errNotFound)
err2 := NewDatabaseTransactionFailedError(errThingNotFound)
{
err2, ok := err2.(DatabaseTransactionFailedError)
if !ok {
t.Errorf("Assertion failed on DatabaseTransactionFailed: %T is not DatabaseTransactionFailedError", err2)
}
errNestErr2 := fmt.Errorf("oh snap: %w", err2)
if err2.Code() != 2 {
t.Errorf("Assertion failed on DatabaseTransactionFailed: %d != 2", err2.Code())
}
if !errors.Is(err2, errNotFound) {
t.Errorf("Assertion failed on DatabaseTransactionFailed: errNotFound not unwrapped correctly")
}
if !errors.Is(err2, errThingNotFound) {
t.Errorf("Assertion failed on DatabaseTransactionFailed: errThingNotFound not unwrapped correctly")
}
if !errors.Is(err2, DatabaseTransactionFailedError{}) {
t.Errorf("Assertion failed on DatabaseTransactionFailed: DatabaseTransactionFailedError{} not identified correctly")
}
if !errors.Is(errNestErr2, DatabaseTransactionFailedError{}) {
t.Errorf("Assertion failed on DatabaseTransactionFailed: DatabaseTransactionFailedError{} not identified correctly")
}
}
}
func TestInsertQueryFailed (t *testing.T) {
err1 := NewInsertQueryFailedError(nil)
{
err1, ok := err1.(InsertQueryFailedError)
if !ok {
t.Errorf("Assertion failed on InsertQueryFailed: %T is not InsertQueryFailedError", err1)
}
if err1.Prefix() != "TAVDAT" {
t.Errorf("Assertion failed on InsertQueryFailed: %s != TAVDAT", err1.Prefix())
}
if err1.Code() != 3 {
t.Errorf("Assertion failed on InsertQueryFailed: %d != 3", err1.Code())
}
if err1.Description() != "The insert query failed" {
t.Errorf("Assertion failed on InsertQueryFailed: %s != The insert query failed", err1.Description())
}
}
errNotFound := fmt.Errorf("not found")
errThingNotFound := fmt.Errorf("thing: %w", errNotFound)
err2 := NewInsertQueryFailedError(errThingNotFound)
{
err2, ok := err2.(InsertQueryFailedError)
if !ok {
t.Errorf("Assertion failed on InsertQueryFailed: %T is not InsertQueryFailedError", err2)
}
errNestErr2 := fmt.Errorf("oh snap: %w", err2)
if err2.Code() != 3 {
t.Errorf("Assertion failed on InsertQueryFailed: %d != 3", err2.Code())
}
if !errors.Is(err2, errNotFound) {
t.Errorf("Assertion failed on InsertQueryFailed: errNotFound not unwrapped correctly")
}
if !errors.Is(err2, errThingNotFound) {
t.Errorf("Assertion failed on InsertQueryFailed: errThingNotFound not unwrapped correctly")
}
if !errors.Is(err2, InsertQueryFailedError{}) {
t.Errorf("Assertion failed on InsertQueryFailed: InsertQueryFailedError{} not identified correctly")
}
if !errors.Is(errNestErr2, InsertQueryFailedError{}) {
t.Errorf("Assertion failed on InsertQueryFailed: InsertQueryFailedError{} not identified correctly")
}
}
}
func TestSelectQueryFailed (t *testing.T) {
err1 := NewSelectQueryFailedError(nil)
{
err1, ok := err1.(SelectQueryFailedError)
if !ok {
t.Errorf("Assertion failed on SelectQueryFailed: %T is not SelectQueryFailedError", err1)
}
if err1.Prefix() != "TAVDAT" {
t.Errorf("Assertion failed on SelectQueryFailed: %s != TAVDAT", err1.Prefix())
}
if err1.Code() != 4 {
t.Errorf("Assertion failed on SelectQueryFailed: %d != 4", err1.Code())
}
if err1.Description() != "The select query failed" {
t.Errorf("Assertion failed on SelectQueryFailed: %s != The select query failed", err1.Description())
}
}
errNotFound := fmt.Errorf("not found")
errThingNotFound := fmt.Errorf("thing: %w", errNotFound)
err2 := NewSelectQueryFailedError(errThingNotFound)
{
err2, ok := err2.(SelectQueryFailedError)
if !ok {
t.Errorf("Assertion failed on SelectQueryFailed: %T is not SelectQueryFailedError", err2)
}
errNestErr2 := fmt.Errorf("oh snap: %w", err2)
if err2.Code() != 4 {
t.Errorf("Assertion failed on SelectQueryFailed: %d != 4", err2.Code())
}
if !errors.Is(err2, errNotFound) {
t.Errorf("Assertion failed on SelectQueryFailed: errNotFound not unwrapped correctly")
}
if !errors.Is(err2, errThingNotFound) {
t.Errorf("Assertion failed on SelectQueryFailed: errThingNotFound not unwrapped correctly")
}
if !errors.Is(err2, SelectQueryFailedError{}) {
t.Errorf("Assertion failed on SelectQueryFailed: SelectQueryFailedError{} not identified correctly")
}
if !errors.Is(errNestErr2, SelectQueryFailedError{}) {
t.Errorf("Assertion failed on SelectQueryFailed: SelectQueryFailedError{} not identified correctly")
}
}
}
func TestUpdateQueryFailed (t *testing.T) {
err1 := NewUpdateQueryFailedError(nil)
{
err1, ok := err1.(UpdateQueryFailedError)
if !ok {
t.Errorf("Assertion failed on UpdateQueryFailed: %T is not UpdateQueryFailedError", err1)
}
if err1.Prefix() != "TAVDAT" {
t.Errorf("Assertion failed on UpdateQueryFailed: %s != TAVDAT", err1.Prefix())
}
if err1.Code() != 5 {
t.Errorf("Assertion failed on UpdateQueryFailed: %d != 5", err1.Code())
}
if err1.Description() != "The update query failed" {
t.Errorf("Assertion failed on UpdateQueryFailed: %s != The update query failed", err1.Description())
}
}
errNotFound := fmt.Errorf("not found")
errThingNotFound := fmt.Errorf("thing: %w", errNotFound)
err2 := NewUpdateQueryFailedError(errThingNotFound)
{
err2, ok := err2.(UpdateQueryFailedError)
if !ok {
t.Errorf("Assertion failed on UpdateQueryFailed: %T is not UpdateQueryFailedError", err2)
}
errNestErr2 := fmt.Errorf("oh snap: %w", err2)
if err2.Code() != 5 {
t.Errorf("Assertion failed on UpdateQueryFailed: %d != 5", err2.Code())
}
if !errors.Is(err2, errNotFound) {
t.Errorf("Assertion failed on UpdateQueryFailed: errNotFound not unwrapped correctly")
}
if !errors.Is(err2, errThingNotFound) {
t.Errorf("Assertion failed on UpdateQueryFailed: errThingNotFound not unwrapped correctly")
}
if !errors.Is(err2, UpdateQueryFailedError{}) {
t.Errorf("Assertion failed on UpdateQueryFailed: UpdateQueryFailedError{} not identified correctly")
}
if !errors.Is(errNestErr2, UpdateQueryFailedError{}) {
t.Errorf("Assertion failed on UpdateQueryFailed: UpdateQueryFailedError{} not identified correctly")
}
}
}
func TestInvalidUserID (t *testing.T) {
err1 := NewInvalidUserIDError(nil)
{
err1, ok := err1.(InvalidUserIDError)
if !ok {
t.Errorf("Assertion failed on InvalidUserID: %T is not InvalidUserIDError", err1)
}
if err1.Prefix() != "TAVDAT" {
t.Errorf("Assertion failed on InvalidUserID: %s != TAVDAT", err1.Prefix())
}
if err1.Code() != 6 {
t.Errorf("Assertion failed on InvalidUserID: %d != 6", err1.Code())
}
if err1.Description() != "The user ID is invalid." {
t.Errorf("Assertion failed on InvalidUserID: %s != The user ID is invalid.", err1.Description())
}
}
errNotFound := fmt.Errorf("not found")
errThingNotFound := fmt.Errorf("thing: %w", errNotFound)
err2 := NewInvalidUserIDError(errThingNotFound)
{
err2, ok := err2.(InvalidUserIDError)
if !ok {
t.Errorf("Assertion failed on InvalidUserID: %T is not InvalidUserIDError", err2)
}
errNestErr2 := fmt.Errorf("oh snap: %w", err2)
if err2.Code() != 6 {
t.Errorf("Assertion failed on InvalidUserID: %d != 6", err2.Code())
}
if !errors.Is(err2, errNotFound) {
t.Errorf("Assertion failed on InvalidUserID: errNotFound not unwrapped correctly")
}
if !errors.Is(err2, errThingNotFound) {
t.Errorf("Assertion failed on InvalidUserID: errThingNotFound not unwrapped correctly")
}
if !errors.Is(err2, InvalidUserIDError{}) {
t.Errorf("Assertion failed on InvalidUserID: InvalidUserIDError{} not identified correctly")
}
if !errors.Is(errNestErr2, InvalidUserIDError{}) {
t.Errorf("Assertion failed on InvalidUserID: InvalidUserIDError{} not identified correctly")
}
}
}
func TestUserNotFound (t *testing.T) {
err1 := NewUserNotFoundError(nil)
{
err1, ok := err1.(UserNotFoundError)
if !ok {
t.Errorf("Assertion failed on UserNotFound: %T is not UserNotFoundError", err1)
}
if err1.Prefix() != "TAVDAT" {
t.Errorf("Assertion failed on UserNotFound: %s != TAVDAT", err1.Prefix())
}
if err1.Code() != 7 {
t.Errorf("Assertion failed on UserNotFound: %d != 7", err1.Code())
}
if err1.Description() != "The user was not found." {
t.Errorf("Assertion failed on UserNotFound: %s != The user was not found.", err1.Description())
}
}
errNotFound := fmt.Errorf("not found")
errThingNotFound := fmt.Errorf("thing: %w", errNotFound)
err2 := NewUserNotFoundError(errThingNotFound)
{
err2, ok := err2.(UserNotFoundError)
if !ok {
t.Errorf("Assertion failed on UserNotFound: %T is not UserNotFoundError", err2)
}
errNestErr2 := fmt.Errorf("oh snap: %w", err2)
if err2.Code() != 7 {
t.Errorf("Assertion failed on UserNotFound: %d != 7", err2.Code())
}
if !errors.Is(err2, errNotFound) {
t.Errorf("Assertion failed on UserNotFound: errNotFound not unwrapped correctly")
}
if !errors.Is(err2, errThingNotFound) {
t.Errorf("Assertion failed on UserNotFound: errThingNotFound not unwrapped correctly")
}
if !errors.Is(err2, UserNotFoundError{}) {
t.Errorf("Assertion failed on UserNotFound: UserNotFoundError{} not identified correctly")
}
if !errors.Is(errNestErr2, UserNotFoundError{}) {
t.Errorf("Assertion failed on UserNotFound: UserNotFoundError{} not identified correctly")
}
}
}
func TestCreateUserFailed (t *testing.T) {
err1 := NewCreateUserFailedError(nil)
{
err1, ok := err1.(CreateUserFailedError)
if !ok {
t.Errorf("Assertion failed on CreateUserFailed: %T is not CreateUserFailedError", err1)
}
if err1.Prefix() != "TAVDAT" {
t.Errorf("Assertion failed on CreateUserFailed: %s != TAVDAT", err1.Prefix())
}
if err1.Code() != 8 {
t.Errorf("Assertion failed on CreateUserFailed: %d != 8", err1.Code())
}
if err1.Description() != "The user was not able to be created." {
t.Errorf("Assertion failed on CreateUserFailed: %s != The user was not able to be created.", err1.Description())
}
}
errNotFound := fmt.Errorf("not found")
errThingNotFound := fmt.Errorf("thing: %w", errNotFound)
err2 := NewCreateUserFailedError(errThingNotFound)
{
err2, ok := err2.(CreateUserFailedError)
if !ok {
t.Errorf("Assertion failed on CreateUserFailed: %T is not CreateUserFailedError", err2)
}
errNestErr2 := fmt.Errorf("oh snap: %w", err2)
if err2.Code() != 8 {
t.Errorf("Assertion failed on CreateUserFailed: %d != 8", err2.Code())
}
if !errors.Is(err2, errNotFound) {
t.Errorf("Assertion failed on CreateUserFailed: errNotFound not unwrapped correctly")
}
if !errors.Is(err2, errThingNotFound) {
t.Errorf("Assertion failed on CreateUserFailed: errThingNotFound not unwrapped correctly")
}
if !errors.Is(err2, CreateUserFailedError{}) {
t.Errorf("Assertion failed on CreateUserFailed: CreateUserFailedError{} not identified correctly")
}
if !errors.Is(errNestErr2, CreateUserFailedError{}) {
t.Errorf("Assertion failed on CreateUserFailed: CreateUserFailedError{} not identified correctly")
}
}
}
func TestUserQueryFailed (t *testing.T) {
err1 := NewUserQueryFailedError(nil)
{
err1, ok := err1.(UserQueryFailedError)
if !ok {
t.Errorf("Assertion failed on UserQueryFailed: %T is not UserQueryFailedError", err1)
}
if err1.Prefix() != "TAVDAT" {
t.Errorf("Assertion failed on UserQueryFailed: %s != TAVDAT", err1.Prefix())
}
if err1.Code() != 9 {
t.Errorf("Assertion failed on UserQueryFailed: %d != 9", err1.Code())
}
if err1.Description() != "The user query failed." {
t.Errorf("Assertion failed on UserQueryFailed: %s != The user query failed.", err1.Description())
}
}
errNotFound := fmt.Errorf("not found")
errThingNotFound := fmt.Errorf("thing: %w", errNotFound)
err2 := NewUserQueryFailedError(errThingNotFound)
{
err2, ok := err2.(UserQueryFailedError)
if !ok {
t.Errorf("Assertion failed on UserQueryFailed: %T is not UserQueryFailedError", err2)
}
errNestErr2 := fmt.Errorf("oh snap: %w", err2)
if err2.Code() != 9 {
t.Errorf("Assertion failed on UserQueryFailed: %d != 9", err2.Code())
}
if !errors.Is(err2, errNotFound) {
t.Errorf("Assertion failed on UserQueryFailed: errNotFound not unwrapped correctly")
}
if !errors.Is(err2, errThingNotFound) {
t.Errorf("Assertion failed on UserQueryFailed: errThingNotFound not unwrapped correctly")
}
if !errors.Is(err2, UserQueryFailedError{}) {
t.Errorf("Assertion failed on UserQueryFailed: UserQueryFailedError{} not identified correctly")
}
if !errors.Is(errNestErr2, UserQueryFailedError{}) {
t.Errorf("Assertion failed on UserQueryFailed: UserQueryFailedError{} not identified correctly")
}
}
}
func TestUpdateUserFailed (t *testing.T) {
err1 := NewUpdateUserFailedError(nil)
{
err1, ok := err1.(UpdateUserFailedError)
if !ok {
t.Errorf("Assertion failed on UpdateUserFailed: %T is not UpdateUserFailedError", err1)
}
if err1.Prefix() != "TAVDAT" {
t.Errorf("Assertion failed on UpdateUserFailed: %s != TAVDAT", err1.Prefix())
}
if err1.Code() != 10 {
t.Errorf("Assertion failed on UpdateUserFailed: %d != 10", err1.Code())
}
if err1.Description() != "The user was not able to be updated." {
t.Errorf("Assertion failed on UpdateUserFailed: %s != The user was not able to be updated.", err1.Description())
}
}
errNotFound := fmt.Errorf("not found")
errThingNotFound := fmt.Errorf("thing: %w", errNotFound)
err2 := NewUpdateUserFailedError(errThingNotFound)
{
err2, ok := err2.(UpdateUserFailedError)
if !ok {
t.Errorf("Assertion failed on UpdateUserFailed: %T is not UpdateUserFailedError", err2)
}
errNestErr2 := fmt.Errorf("oh snap: %w", err2)
if err2.Code() != 10 {
t.Errorf("Assertion failed on UpdateUserFailed: %d != 10", err2.Code())
}
if !errors.Is(err2, errNotFound) {
t.Errorf("Assertion failed on UpdateUserFailed: errNotFound not unwrapped correctly")
}
if !errors.Is(err2, errThingNotFound) {
t.Errorf("Assertion failed on UpdateUserFailed: errThingNotFound not unwrapped correctly")
}
if !errors.Is(err2, UpdateUserFailedError{}) {
t.Errorf("Assertion failed on UpdateUserFailed: UpdateUserFailedError{} not identified correctly")
}
if !errors.Is(errNestErr2, UpdateUserFailedError{}) {
t.Errorf("Assertion failed on UpdateUserFailed: UpdateUserFailedError{} not identified correctly")
}
}
}
func TestUserSessionNotFound (t *testing.T) {
err1 := NewUserSessionNotFoundError(nil)
{
err1, ok := err1.(UserSessionNotFoundError)
if !ok {
t.Errorf("Assertion failed on UserSessionNotFound: %T is not UserSessionNotFoundError", err1)
}
if err1.Prefix() != "TAVDAT" {
t.Errorf("Assertion failed on UserSessionNotFound: %s != TAVDAT", err1.Prefix())
}
if err1.Code() != 11 {
t.Errorf("Assertion failed on UserSessionNotFound: %d != 11", err1.Code())
}
if err1.Description() != "The user session was not found." {
t.Errorf("Assertion failed on UserSessionNotFound: %s != The user session was not found.", err1.Description())
}
}
errNotFound := fmt.Errorf("not found")
errThingNotFound := fmt.Errorf("thing: %w", errNotFound)
err2 := NewUserSessionNotFoundError(errThingNotFound)
{
err2, ok := err2.(UserSessionNotFoundError)
if !ok {
t.Errorf("Assertion failed on UserSessionNotFound: %T is not UserSessionNotFoundError", err2)
}
errNestErr2 := fmt.Errorf("oh snap: %w", err2)
if err2.Code() != 11 {
t.Errorf("Assertion failed on UserSessionNotFound: %d != 11", err2.Code())
}
if !errors.Is(err2, errNotFound) {
t.Errorf("Assertion failed on UserSessionNotFound: errNotFound not unwrapped correctly")
}
if !errors.Is(err2, errThingNotFound) {
t.Errorf("Assertion failed on UserSessionNotFound: errThingNotFound not unwrapped correctly")
}
if !errors.Is(err2, UserSessionNotFoundError{}) {
t.Errorf("Assertion failed on UserSessionNotFound: UserSessionNotFoundError{} not identified correctly")
}
if !errors.Is(errNestErr2, UserSessionNotFoundError{}) {
t.Errorf("Assertion failed on UserSessionNotFound: UserSessionNotFoundError{} not identified correctly")
}
}
}
func TestInvalidEmailVerification (t *testing.T) {
err1 := NewInvalidEmailVerificationError(nil)
{
err1, ok := err1.(InvalidEmailVerificationError)
if !ok {
t.Errorf("Assertion failed on InvalidEmailVerification: %T is not InvalidEmailVerificationError", err1)
}
if err1.Prefix() != "TAVWEB" {
t.Errorf("Assertion failed on InvalidEmailVerification: %s != TAVWEB", err1.Prefix())
}
if err1.Code() != 1 {
t.Errorf("Assertion failed on InvalidEmailVerification: %d != 1", err1.Code())
}
if err1.Description() != "Invalid verification." {
t.Errorf("Assertion failed on InvalidEmailVerification: %s != Invalid verification.", err1.Description())
}
}
errNotFound := fmt.Errorf("not found")
errThingNotFound := fmt.Errorf("thing: %w", errNotFound)
err2 := NewInvalidEmailVerificationError(errThingNotFound)
{
err2, ok := err2.(InvalidEmailVerificationError)
if !ok {
t.Errorf("Assertion failed on InvalidEmailVerification: %T is not InvalidEmailVerificationError", err2)
}
errNestErr2 := fmt.Errorf("oh snap: %w", err2)
if err2.Code() != 1 {
t.Errorf("Assertion failed on InvalidEmailVerification: %d != 1", err2.Code())
}
if !errors.Is(err2, errNotFound) {
t.Errorf("Assertion failed on InvalidEmailVerification: errNotFound not unwrapped correctly")
}
if !errors.Is(err2, errThingNotFound) {
t.Errorf("Assertion failed on InvalidEmailVerification: errThingNotFound not unwrapped correctly")
}
if !errors.Is(err2, InvalidEmailVerificationError{}) {
t.Errorf("Assertion failed on InvalidEmailVerification: InvalidEmailVerificationError{} not identified correctly")
}
if !errors.Is(errNestErr2, InvalidEmailVerificationError{}) {
t.Errorf("Assertion failed on InvalidEmailVerification: InvalidEmailVerificationError{} not identified correctly")
}
}
}
func TestCannotSaveSession (t *testing.T) {
err1 := NewCannotSaveSessionError(nil)
{
err1, ok := err1.(CannotSaveSessionError)
if !ok {
t.Errorf("Assertion failed on CannotSaveSession: %T is not CannotSaveSessionError", err1)
}
if err1.Prefix() != "TAVWEB" {
t.Errorf("Assertion failed on CannotSaveSession: %s != TAVWEB", err1.Prefix())
}
if err1.Code() != 2 {
t.Errorf("Assertion failed on CannotSaveSession: %d != 2", err1.Code())
}
if err1.Description() != "Cannot save session." {
t.Errorf("Assertion failed on CannotSaveSession: %s != Cannot save session.", err1.Description())
}
}
errNotFound := fmt.Errorf("not found")
errThingNotFound := fmt.Errorf("thing: %w", errNotFound)
err2 := NewCannotSaveSessionError(errThingNotFound)
{
err2, ok := err2.(CannotSaveSessionError)
if !ok {
t.Errorf("Assertion failed on CannotSaveSession: %T is not CannotSaveSessionError", err2)
}
errNestErr2 := fmt.Errorf("oh snap: %w", err2)
if err2.Code() != 2 {
t.Errorf("Assertion failed on CannotSaveSession: %d != 2", err2.Code())
}
if !errors.Is(err2, errNotFound) {
t.Errorf("Assertion failed on CannotSaveSession: errNotFound not unwrapped correctly")
}
if !errors.Is(err2, errThingNotFound) {
t.Errorf("Assertion failed on CannotSaveSession: errThingNotFound not unwrapped correctly")
}
if !errors.Is(err2, CannotSaveSessionError{}) {
t.Errorf("Assertion failed on CannotSaveSession: CannotSaveSessionError{} not identified correctly")
}
if !errors.Is(errNestErr2, CannotSaveSessionError{}) {
t.Errorf("Assertion failed on CannotSaveSession: CannotSaveSessionError{} not identified correctly")
}
}
}
func TestInvalidCaptcha (t *testing.T) {
err1 := NewInvalidCaptchaError(nil)
{
err1, ok := err1.(InvalidCaptchaError)
if !ok {
t.Errorf("Assertion failed on InvalidCaptcha: %T is not InvalidCaptchaError", err1)
}
if err1.Prefix() != "TAVWEB" {
t.Errorf("Assertion failed on InvalidCaptcha: %s != TAVWEB", err1.Prefix())
}
if err1.Code() != 3 {
t.Errorf("Assertion failed on InvalidCaptcha: %d != 3", err1.Code())
}
if err1.Description() != "Invalid captcha." {
t.Errorf("Assertion failed on InvalidCaptcha: %s != Invalid captcha.", err1.Description())
}
}
errNotFound := fmt.Errorf("not found")
errThingNotFound := fmt.Errorf("thing: %w", errNotFound)
err2 := NewInvalidCaptchaError(errThingNotFound)
{
err2, ok := err2.(InvalidCaptchaError)
if !ok {
t.Errorf("Assertion failed on InvalidCaptcha: %T is not InvalidCaptchaError", err2)
}
errNestErr2 := fmt.Errorf("oh snap: %w", err2)
if err2.Code() != 3 {
t.Errorf("Assertion failed on InvalidCaptcha: %d != 3", err2.Code())
}
if !errors.Is(err2, errNotFound) {
t.Errorf("Assertion failed on InvalidCaptcha: errNotFound not unwrapped correctly")
}
if !errors.Is(err2, errThingNotFound) {
t.Errorf("Assertion failed on InvalidCaptcha: errThingNotFound not unwrapped correctly")
}
if !errors.Is(err2, InvalidCaptchaError{}) {
t.Errorf("Assertion failed on InvalidCaptcha: InvalidCaptchaError{} not identified correctly")
}
if !errors.Is(errNestErr2, InvalidCaptchaError{}) {
t.Errorf("Assertion failed on InvalidCaptcha: InvalidCaptchaError{} not identified correctly")
}
}
}
func TestEmailSendFailed (t *testing.T) {
err1 := NewEmailSendFailedError(nil)
{
err1, ok := err1.(EmailSendFailedError)
if !ok {
t.Errorf("Assertion failed on EmailSendFailed: %T is not EmailSendFailedError", err1)
}
if err1.Prefix() != "TAVWEB" {
t.Errorf("Assertion failed on EmailSendFailed: %s != TAVWEB", err1.Prefix())
}
if err1.Code() != 4 {
t.Errorf("Assertion failed on EmailSendFailed: %d != 4", err1.Code())
}
if err1.Description() != "Unable to send email." {
t.Errorf("Assertion failed on EmailSendFailed: %s != Unable to send email.", err1.Description())
}
}
errNotFound := fmt.Errorf("not found")
errThingNotFound := fmt.Errorf("thing: %w", errNotFound)
err2 := NewEmailSendFailedError(errThingNotFound)
{
err2, ok := err2.(EmailSendFailedError)
if !ok {
t.Errorf("Assertion failed on EmailSendFailed: %T is not EmailSendFailedError", err2)
}
errNestErr2 := fmt.Errorf("oh snap: %w", err2)
if err2.Code() != 4 {
t.Errorf("Assertion failed on EmailSendFailed: %d != 4", err2.Code())
}
if !errors.Is(err2, errNotFound) {
t.Errorf("Assertion failed on EmailSendFailed: errNotFound not unwrapped correctly")
}
if !errors.Is(err2, errThingNotFound) {
t.Errorf("Assertion failed on EmailSendFailed: errThingNotFound not unwrapped correctly")
}
if !errors.Is(err2, EmailSendFailedError{}) {
t.Errorf("Assertion failed on EmailSendFailed: EmailSendFailedError{} not identified correctly")
}
if !errors.Is(errNestErr2, EmailSendFailedError{}) {
t.Errorf("Assertion failed on EmailSendFailed: EmailSendFailedError{} not identified correctly")
}
}
}

3
errors/gen.go Normal file
View File

@ -0,0 +1,3 @@
package errors
//go:generate go run github.com/sslhound/herr -package errors -source tav.csv -source tavdat.csv -source tavweb.csv -out errors_generated.go -test-out errors_generated_test.go -locale-out ../translations/en/errors_generated_test.json

3
errors/tav.csv Normal file
View File

@ -0,0 +1,3 @@
1,TAV ,NotFound ,Not found.
2,TAV,EncryptFailed,Encrypting data failed.
3,TAV ,DecryptFailed ,Decrypting data failed.
1 1 TAV NotFound Not found.
2 2 TAV EncryptFailed Encrypting data failed.
3 3 TAV DecryptFailed Decrypting data failed.

14
errors/tavdat.csv Normal file
View File

@ -0,0 +1,14 @@
1 ,TAVDAT,QueryFailed ,The query operation failed.
2 ,TAVDAT,DatabaseTransactionFailed ,The database transaction failed.
3, TAVDAT,InsertQueryFailed,The insert query failed
4, TAVDAT,SelectQueryFailed,The select query failed
5, TAVDAT,UpdateQueryFailed,The update query failed
6 ,TAVDAT,InvalidUserID ,The user ID is invalid.
7 ,TAVDAT,UserNotFound ,The user was not found.
8 ,TAVDAT,CreateUserFailed ,The user was not able to be created.
9 ,TAVDAT,UserQueryFailed ,The user query failed.
10 ,TAVDAT,UpdateUserFailed ,The user was not able to be updated.
11,TAVDAT,UserSessionNotFound ,The user session was not found.
1 1 TAVDAT QueryFailed The query operation failed.
2 2 TAVDAT DatabaseTransactionFailed The database transaction failed.
3 3 TAVDAT InsertQueryFailed The insert query failed
4 4 TAVDAT SelectQueryFailed The select query failed
5 5 TAVDAT UpdateQueryFailed The update query failed
6 6 TAVDAT InvalidUserID The user ID is invalid.
7 7 TAVDAT UserNotFound The user was not found.
8 8 TAVDAT CreateUserFailed The user was not able to be created.
9 9 TAVDAT UserQueryFailed The user query failed.
10 10 TAVDAT UpdateUserFailed The user was not able to be updated.
11 11 TAVDAT UserSessionNotFound The user session was not found.

4
errors/tavweb.csv Normal file
View File

@ -0,0 +1,4 @@
1 ,TAVWEB,InvalidEmailVerification ,Invalid verification.
2 ,TAVWEB,CannotSaveSession ,Cannot save session.
3 ,TAVWEB,InvalidCaptcha ,Invalid captcha.
4 ,TAVWEB,EmailSendFailed ,Unable to send email.
1 1 TAVWEB InvalidEmailVerification Invalid verification.
2 2 TAVWEB CannotSaveSession Cannot save session.
3 3 TAVWEB InvalidCaptcha Invalid captcha.
4 4 TAVWEB EmailSendFailed Unable to send email.

12
errors/wrap.go Normal file
View File

@ -0,0 +1,12 @@
package errors
import (
"errors"
)
var (
New = errors.New
Is = errors.Is
As = errors.As
Unwrap = errors.Unwrap
)

53
fed/activity.go Normal file
View File

@ -0,0 +1,53 @@
package fed
import (
"fmt"
"io"
"io/ioutil"
"net/http"
"go.uber.org/zap"
"github.com/ngerakines/tavern/g"
"github.com/ngerakines/tavern/storage"
)
type ActivityClient struct {
HTTPClient HTTPClient
Logger *zap.Logger
}
func (client ActivityClient) Get(location string) (string, storage.Payload, error) {
client.Logger.Debug("Sending activity request", zap.String("url", location))
return ldJsonGet(client.HTTPClient, location)
}
func ldJsonGet(client HTTPClient, location string) (string, storage.Payload, error) {
request, err := http.NewRequest("GET", location, nil)
if err != nil {
return "", nil, err
}
request.Header.Add("Accept", "application/ld+json")
request.Header.Set("User-Agent", g.UserAgent())
resp, err := client.Do(request)
if err != nil {
return "", nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1*1024*1024))
if err != nil {
return "", nil, err
}
p, err := storage.PayloadFromBytes(body)
if err != nil {
return "", nil, err
}
return string(body), p, nil
}

158
fed/actor.go Normal file
View File

@ -0,0 +1,158 @@
package fed
import (
"context"
"fmt"
"io"
"io/ioutil"
"net/http"
"strings"
"go.uber.org/zap"
"github.com/ngerakines/tavern/storage"
)
type ActorClient struct {
HTTPClient HTTPClient
Logger *zap.Logger
}
func (client ActorClient) Get(location string) (string, storage.Payload, error) {
client.Logger.Debug("Sending actor request", zap.String("url", location))
request, err := http.NewRequest("GET", location, nil)
if err != nil {
return "", nil, err
}
request.Header.Add("Accept", "application/ld+json")
resp, err := client.HTTPClient.Do(request)
if err != nil {
return "", nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1*1024*1024))
if err != nil {
return "", nil, err
}
p, err := storage.PayloadFromBytes(body)
if err != nil {
return "", nil, err
}
// return storage.PayloadFromReader(io.LimitReader(resp.Body, 1*1024*1024))
return string(body), p, nil
}
func GetOrFetchActor(ctx context.Context, store storage.Storage, logger *zap.Logger, httpClient HTTPClient, hint string) (storage.Actor, error) {
var actorURL string
if strings.HasPrefix(hint, "https://") {
actorURL = hint
}
count, err := store.RowCount(ctx, `SELECT COUNT(*) FROM actors WHERE aliases @> ARRAY[$1]::varchar[]`, hint)
if err != nil {
return nil, err
}
if count > 0 {
return store.GetActorByAlias(ctx, hint)
}
if len(actorURL) == 0 {
wfc := WebFingerClient{
HTTPClient: httpClient,
Logger: logger,
}
wfp, err := wfc.Fetch(hint)
if err != nil {
return nil, err
}
actorURL, err = ActorIDFromWebFingerPayload(wfp)
if err != nil {
return nil, err
}
logger.Debug("parsed actor id from webfinger payload", zap.String("actor", actorURL))
}
ac := ActorClient{
HTTPClient: httpClient,
Logger: logger,
}
actorBody, actorPayload, err := ac.Get(actorURL)
if err != nil {
return nil, err
}
keyID, keyPEM, err := storage.KeyFromActor(actorPayload)
actorRowID := storage.NewV4()
keyRowID := storage.NewV4()
actorID, ok := storage.JSONString(actorPayload, "id")
if !ok {
return nil, fmt.Errorf("no id found for actor")
}
err = store.CreateActor(ctx, actorRowID, keyRowID, actorID, actorBody, keyID, keyPEM)
if err != nil {
return nil, err
}
for _, a := range []string{actorID, actorURL, hint} {
if err = store.RecordActorAlias(ctx, actorID, a); err != nil {
return nil, err
}
}
if endpoints, ok := storage.JSONMap(actorPayload, "endpoints"); ok {
sharedInbox, ok := storage.JSONString(endpoints, "sharedInbox")
if ok {
peerID := storage.NewV4()
err = store.CreatePeer(ctx, peerID, sharedInbox)
if err != nil {
return nil, err
}
}
}
return store.GetActor(ctx, actorID)
}
func ActorsFromActivity(activity storage.Payload) []string {
var actors []string
actor, ok := storage.JSONString(activity, "actor")
if ok {
actors = append(actors, actor)
}
obj, ok := storage.JSONMap(activity, "object")
if ok {
attributedTo, ok := storage.JSONString(obj, "attributedTo")
if ok {
actors = append(actors, attributedTo)
}
tag, ok := storage.JSONMapList(obj, "tag")
if ok {
for _, i := range tag {
tagType, hasTagType := storage.JSONString(i, "type")
href, hasHref := storage.JSONString(i, "href")
if hasTagType && hasHref && tagType == "Mention" {
actors = append(actors, href)
}
}
}
}
return actors
}

88
fed/network.go Normal file
View File

@ -0,0 +1,88 @@
package fed
import (
"bytes"
"context"
"fmt"
"io"
"io/ioutil"
"net/http"
"time"
"github.com/yukimochi/httpsig"
"go.uber.org/zap"
"github.com/ngerakines/tavern/g"
"github.com/ngerakines/tavern/storage"
)
type hasInbox interface {
GetInbox() string
}
type fakeInbox string
func (f fakeInbox) GetInbox() string {
return string(f)
}
func (client ActorClient) Broadcast(ctx context.Context, store storage.Storage, localActor storage.LocalActor, payload []byte) error {
followers, err := store.FollowerInboxes(ctx, localActor.User.ID)
if err != nil {
return err
}
for _, follower := range followers {
client.Logger.Info("sending payload to follower", zap.String("inbox", follower))
err = client.SendToInbox(ctx, localActor, fakeInbox(follower), payload)
if err != nil {
client.Logger.Error("unable to send payload to inbox", zap.String("inbox", follower), zap.Error(err))
}
}
return nil
}
func (client ActorClient) SendToInbox(ctx context.Context, localActor storage.LocalActor, actor hasInbox, payload []byte) error {
sigConfig := []httpsig.Algorithm{httpsig.RSA_SHA256}
headersToSign := []string{httpsig.RequestTarget, "date"}
signer, _, err := httpsig.NewSigner(sigConfig, headersToSign, httpsig.Signature)
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, "POST", actor.GetInbox(), bytes.NewReader(payload))
if err != nil {
return err
}
request.Header.Add("content-type", `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`)
request.Header.Add("date", time.Now().UTC().Format(http.TimeFormat))
request.Header.Set("User-Agent", g.UserAgent())
privateKey, err := localActor.User.GetPrivateKey()
if err != nil {
return err
}
if err = signer.SignRequest(privateKey, localActor.GetKeyID(), request); err != nil {
return err
}
resp, err := client.HTTPClient.Do(request)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
lr := io.LimitReader(resp.Body, 500000)
data, err := ioutil.ReadAll(lr)
if err != nil {
return err
}
return fmt.Errorf("unexpected response %d: %s", resp.StatusCode, string(data))
}

92
fed/webfinger.go Normal file
View File

@ -0,0 +1,92 @@
package fed
import (
"fmt"
"net/http"
"net/url"
"strings"
"go.uber.org/zap"
"github.com/ngerakines/tavern/storage"
)
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
type WebFingerClient struct {
HTTPClient HTTPClient
Logger *zap.Logger
}
func (client WebFingerClient) Fetch(location string) (storage.Payload, error) {
destination, err := BuildWebFingerURL(location)
if err != nil {
return nil, err
}
client.Logger.Debug("Sending webfinger request", zap.String("url", destination))
_, payload, err := ldJsonGet(client.HTTPClient, destination)
return payload, err
}
func BuildWebFingerURL(location string) (string, error) {
if strings.HasPrefix(location, "@") {
location = strings.TrimPrefix(location, "@")
parts := strings.Split(location, "@")
if len(parts) != 2 {
return "", fmt.Errorf("invalid actor location: %s", location)
}
q := url.QueryEscape(location)
return fmt.Sprintf("https://%s/.well-known/webfinger?resource=acct:%s", parts[1], q), nil
}
if strings.Index(location, "@") != -1 {
parts := strings.Split(strings.TrimPrefix(location, "@"), "@")
if len(parts) != 2 {
return "", fmt.Errorf("invalid actor location: %s", location)
}
q := url.QueryEscape(location)
return fmt.Sprintf("https://%s/.well-known/webfinger?resource=acct:%s", parts[1], q), nil
}
u, err := url.Parse(location)
if err != nil {
return "", err
}
q := url.QueryEscape(location)
return fmt.Sprintf("https://%s/.well-known/webfinger?resource=acct:%s", u.Host, q), nil
}
func ActorIDFromWebFingerPayload(wfp storage.Payload) (string, error) {
if wfp == nil {
return "", fmt.Errorf("unable to get actor href from webfinger content: wfp nil")
}
links, ok := wfp["links"]
if !ok {
return "", fmt.Errorf("unable to get actor href from webfinger content")
}
objLinks, ok := links.([]interface{})
if !ok {
return "", fmt.Errorf("unable to get actor href from webfinger content: links not array")
}
if len(objLinks) < 1 {
return "", fmt.Errorf("unable to get actor href from webfinger content: links empty")
}
first := objLinks[0]
firstMap, ok := first.(map[string]interface{})
if !ok {
return "", fmt.Errorf("unable to get actor href from webfinger content: first link not map")
}
href, ok := firstMap["href"]
if !ok {
return "", fmt.Errorf("unable to get actor href from webfinger content: href present")
}
hrefStr, ok := href.(string)
if !ok {
return "", fmt.Errorf("unable to get actor href from webfinger content: href not string")
}
return hrefStr, nil
}

17
g/vars.go Normal file
View File

@ -0,0 +1,17 @@
package g
import (
"fmt"
)
var ReleaseCode string
var GitCommit string
var BuildTime string
func Version() string {
return fmt.Sprintf("%s-%s", ReleaseCode, GitCommit)
}
func UserAgent() string {
return fmt.Sprintf("tavern/%s-%s (+https://github.com/ngerakines/tavern)", ReleaseCode, GitCommit)
}

28
go.mod Normal file
View File

@ -0,0 +1,28 @@
module github.com/ngerakines/tavern
go 1.13
require (
github.com/foolin/goview v0.2.0
github.com/getsentry/sentry-go v0.4.0
github.com/gin-contrib/sessions v0.0.3
github.com/gin-contrib/static v0.0.0-20191128031702-f81c604d8ac2
github.com/gin-contrib/zap v0.0.0-20191128031730-d12829f8f61b
github.com/gin-gonic/gin v1.5.0
github.com/go-playground/locales v0.12.1
github.com/go-playground/universal-translator v0.16.0
github.com/gofrs/uuid v3.2.0+incompatible
github.com/kr/pretty v0.1.0
github.com/lib/pq v1.3.0
github.com/oklog/run v1.0.0
github.com/pkg/errors v0.9.1 // indirect
github.com/spaolacci/murmur3 v1.1.0
github.com/stretchr/testify v1.4.0
github.com/urfave/cli/v2 v2.1.1
github.com/yukimochi/httpsig v0.1.3
go.uber.org/zap v1.13.0
golang.org/x/crypto v0.0.0-20200221170553-0f24fbd83dfb
golang.org/x/sys v0.0.0-20191010194322-b09406accb47 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v2 v2.2.7 // indirect
)

292
go.sum Normal file
View File

@ -0,0 +1,292 @@
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/CloudyKit/fastprinter v0.0.0-20170127035650-74b38d55f37a/go.mod h1:EFZQ978U7x8IRnstaskI3IysnWY5Ao3QgZUKOXlsAdw=
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible/go.mod h1:HPYO+50pSWkPoj9Q/eq0aRGByCL6ScRlUmiEX5Zgm+w=
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0=
github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
github.com/Joker/jade v1.0.1-0.20190614124447-d475f43051e7/go.mod h1:6E6s8o2AE4KhCrqr6GRJjdC/gNfTdxkIXvuGZZda2VM=
github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
github.com/bradfitz/gomemcache v0.0.0-20190329173943-551aad21a668/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI=
github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw=
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/flosch/pongo2 v0.0.0-20190707114632-bbf5a6c351f4/go.mod h1:T9YF2M40nIgbVgp3rreNmTged+9HrbNTIQf1PsaIiTA=
github.com/foolin/goview v0.2.0 h1:ovIVJmi2Lui+0obJ+NUhf2TN9mmV4hx5FA99oX1yH6U=
github.com/foolin/goview v0.2.0/go.mod h1:hmQ++dSxBx8Uod3ZIpH+g2l1NSg/1Yc3kfNuLfY0EoI=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/getsentry/sentry-go v0.4.0 h1:WqRI2/7EiALbdG9qGB47c0Aks1tdznG5DZd6GSQ1y/8=
github.com/getsentry/sentry-go v0.4.0/go.mod h1:xkGcb82SipKQloDNa5b7hTV4VdEyc2bhwd1/UczP52k=
github.com/gin-contrib/sessions v0.0.3 h1:PoBXki+44XdJdlgDqDrY5nDVe3Wk7wDV/UCOuLP6fBI=
github.com/gin-contrib/sessions v0.0.3/go.mod h1:8C/J6cad3Il1mWYYgtw0w+hqasmpvy25mPkXdOgeB9I=
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-contrib/static v0.0.0-20191128031702-f81c604d8ac2 h1:xLG16iua01X7Gzms9045s2Y2niNpvSY/Zb1oBwgNYZY=
github.com/gin-contrib/static v0.0.0-20191128031702-f81c604d8ac2/go.mod h1:VhW/Ch/3FhimwZb8Oj+qJmdMmoB8r7lmJ5auRjm50oQ=
github.com/gin-contrib/zap v0.0.0-20191128031730-d12829f8f61b h1:RbSrAGE5n0H4cQwsxWLWN8EFMoY5rdJQ83hZnHZ5plA=
github.com/gin-contrib/zap v0.0.0-20191128031730-d12829f8f61b/go.mod h1:vJJndZ8f44gsTHQrDPIB4YOZzwOwiEIdE0mMrZLOogk=
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
github.com/gin-gonic/gin v1.5.0 h1:fi+bqFAx/oLK54somfCtEZs9HeH1LHVoEPUgARpTqyc=
github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do=
github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc=
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM=
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU=
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI=
github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0=
github.com/iris-contrib/i18n v0.0.0-20171121225848-987a633949d0/go.mod h1:pMCz62A0xJL6I+umB2YTlFRwWXaDFA0jy+5HzGiJjqI=
github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.7 h1:KfgG9LzI+pYjr4xvmz/5H4FXjokeP+rlHLhv3iH62Fo=
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/juju/errors v0.0.0-20181118221551-089d3ea4e4d5/go.mod h1:W54LbzXuIE0boCoNJfwqpmkKJ1O4TCTZMetAt6jGk7Q=
github.com/juju/loggo v0.0.0-20180524022052-584905176618/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U=
github.com/juju/testing v0.0.0-20180920084828-472a3e8b2073/go.mod h1:63prj8cnj0tU0S9OHjGJn+b1h0ZghCndfnbQolrYTwA=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
github.com/kataras/golog v0.0.9/go.mod h1:12HJgwBIZFNGL0EJnMRhmvGA0PQGx8VFwrZtM4CqbAk=
github.com/kataras/iris/v12 v12.0.1/go.mod h1:udK4vLQKkdDqMGJJVd/msuMtN6hpYJhg/lSzuxjhO+U=
github.com/kataras/neffos v0.0.10/go.mod h1:ZYmJC07hQPW67eKuzlfY7SO3bC0mw83A3j6im82hfqw=
github.com/kataras/pio v0.0.0-20190103105442-ea782b38602d/go.mod h1:NV88laa9UiiDuX9AhMbDPkGYSPugBOV6yTZB1l2K9Z0=
github.com/kidstuff/mongostore v0.0.0-20181113001930-e650cd85ee4b/go.mod h1:g2nVr8KZVXJSS97Jo8pJ0jgq29P6H7dG0oplUA86MQw=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.9.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
github.com/labstack/echo/v4 v4.1.5/go.mod h1:3LbYC6VkwmUnmLPZ8WFdHdQHG77e9GQbjyhWdb1QvC4=
github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g=
github.com/labstack/gommon v0.2.8/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8=
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9 h1:d5US/mDsogSGW37IV293h//ZFaeajb69h+EHFsv2xGg=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
github.com/mediocregopher/mediocre-go-lib v0.0.0-20181029021733-cb65787f37ed/go.mod h1:dSsfyI2zABAdhcbvkXqgxOxrCsbYeHCPgrZkku60dSg=
github.com/mediocregopher/radix/v3 v3.3.0/go.mod h1:EmfVyvspXz1uZEyPBMyGK+kjWiKQGvsUt6O3Pj+LDCQ=
github.com/memcachier/mc v2.0.1+incompatible/go.mod h1:7bkvFE61leUBvXz+yxsOnGBQSZpBSPIMUQSmmSHvuXc=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/nats-io/nats.go v1.8.1/go.mod h1:BrFz9vVn0fU3AcH9Vn4Kd7W0NpJ651tD5omQ3M8LwxM=
github.com/nats-io/nkeys v0.0.2/go.mod h1:dab7URMsZm6Z/jp9Z5UGa87Uutgc2mVpXLC4B7TDb/4=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E=
github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw=
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quasoft/memstore v0.0.0-20180925164028-84a050167438/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
github.com/yukimochi/httpsig v0.1.3 h1:rosV0EcuZfmi7DvlW1QaxORYCEesm28Sa+lTyKh8+OA=
github.com/yukimochi/httpsig v0.1.3/go.mod h1:JY9Gq5vuFbLhrPZjoc+j+UiYu9/ohq5Ou5b4oZHc8Cw=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0 h1:OI5t8sDa1Or+q8AeE+yKeB/SDYioSHAgcVljj9JIETY=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0 h1:sFPn2GLc3poCkfrpIXGhBD2X0CMIo4Q/zSULXrj/+uc=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0 h1:nR6NoDBgAf67s68NhaXbsojM+2gxp3S1hWkHDl27pVU=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200221170553-0f24fbd83dfb h1:Bg7BRk6M/6/zfhJrglNmi/oiI2jwKuETZ3vrdYr1qFk=
golang.org/x/crypto v0.0.0-20200221170553-0f24fbd83dfb/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191010194322-b09406accb47 h1:/XfQ9z7ib8eEJX2hdgFTZJ/ntt0swNk5oYBziWeTCvY=
golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5 h1:hKsoRgsbwY1NafxrwTs+k64bikrLBkAgPir1TNCj3Zs=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/go-playground/validator.v9 v9.29.1 h1:SvGtYmN60a5CVKTOzMSyfzWDeZRxRuGvRQyEAKbw1xc=
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=

12
job/http.go Normal file
View File

@ -0,0 +1,12 @@
package job
import (
"net/http"
"time"
)
func DefaultHTTPClient() HTTPClient {
return &http.Client{
Timeout: 10 * time.Second,
}
}

41
job/job.go Normal file
View File

@ -0,0 +1,41 @@
package job
import (
"context"
"net/http"
"time"
"github.com/oklog/run"
"go.uber.org/zap"
)
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
type Job interface {
Run(context.Context) error
Shutdown(context.Context) error
}
func RunWorker(group *run.Group, logger *zap.Logger, j Job, parent context.Context) {
group.Add(func() error {
logger.Info("starting job")
return ignoreCanceled(j.Run(parent))
}, func(error) {
shutdownCtx, shutdownCtxCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCtxCancel()
logger.Info("stopping publisher")
err := ignoreCanceled(j.Shutdown(shutdownCtx))
if err != nil {
logger.Error("error stopping job", zap.Error(err))
}
})
}
func ignoreCanceled(err error) error {
if err == nil || err == context.Canceled {
return nil
}
return err
}

118
job/webfinger.go Normal file
View File

@ -0,0 +1,118 @@
package job
import (
"context"
"strings"
"time"
"go.uber.org/zap"
"github.com/ngerakines/tavern/fed"
"github.com/ngerakines/tavern/storage"
)
type webfinger struct {
ctx context.Context
cancel context.CancelFunc
logger *zap.Logger
queue storage.StringQueue
storage storage.Storage
httpClient HTTPClient
}
func NewWebFingerWorker(logger *zap.Logger, queue storage.StringQueue, storage storage.Storage) Job {
return &webfinger{
logger: logger,
queue: queue,
storage: storage,
httpClient: DefaultHTTPClient(),
}
}
func (job *webfinger) Run(parent context.Context) error {
job.ctx, job.cancel = context.WithCancel(parent)
defer job.cancel()
for {
select {
case <-time.After(time.Second):
err := job.work()
if err != nil {
job.logger.Error("error processing work", zap.Error(err))
return err
}
case <-job.ctx.Done():
return ignoreCanceled(job.ctx.Err())
}
}
}
func (job *webfinger) Shutdown(parent context.Context) error {
job.cancel()
select {
case <-parent.Done():
return parent.Err()
case <-job.ctx.Done():
return job.ctx.Err()
}
}
func (job *webfinger) work() error {
work, err := job.queue.Take()
if err != nil {
return err
}
if len(work) == 0 {
return nil
}
var actorID string
if strings.HasPrefix(work, "https://") {
actorID = work
}
if len(actorID) == 0 {
wfc := fed.WebFingerClient{
HTTPClient: job.httpClient,
Logger: job.logger,
}
wfp, err := wfc.Fetch(work)
if err != nil {
return err
}
actorID, err = fed.ActorIDFromWebFingerPayload(wfp)
if err != nil {
return err
}
job.logger.Debug("parsed actor id from webfinger payload", zap.String("actor", actorID))
}
count, err := job.storage.RowCount(job.ctx, `SELECT COUNT(*) FROM actors WHERE actor_id = $1`, actorID)
if err != nil {
return err
}
if count > 0 {
return nil
}
ac := fed.ActorClient{
HTTPClient: job.httpClient,
Logger: job.logger,
}
actorBody, actorPayload, err := ac.Get(actorID)
if err != nil {
return err
}
keyID, keyPEM, err := storage.KeyFromActor(actorPayload)
actorRowID := storage.NewV4()
keyRowID := storage.NewV4()
return job.storage.CreateActor(context.Background(), actorRowID, keyRowID, actorID, actorBody, keyID, keyPEM)
}

103
job/webfinger_test.go Normal file
View File

@ -0,0 +1,103 @@
package job
import (
"context"
"testing"
"github.com/gin-contrib/sessions"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
"github.com/ngerakines/tavern/storage"
)
type mockStorage struct {
GetActorFunc func(context.Context, string) (storage.Actor, error)
CreateActorFunc func(context.Context, uuid.UUID, uuid.UUID, string, string, string, string) error
}
func (m mockStorage) RowCount(ctx context.Context, query string, args ...interface{}) (int, error) {
panic("implement me")
}
func (m mockStorage) AuthenticateUser(ctx context.Context, email string, password []byte) (uuid.UUID, error) {
panic("implement me")
}
func (m mockStorage) CreateUser(ctx context.Context, userID uuid.UUID, email, locale, name, displayName, about, publicKey, privateKey string, password []byte) error {
panic("implement me")
}
func (m mockStorage) GetUser(ctx context.Context, userID uuid.UUID) (*storage.User, error) {
panic("implement me")
}
func (m mockStorage) GetUserByName(ctx context.Context, name string) (*storage.User, error) {
panic("implement me")
}
func (m mockStorage) GetUserBySession(ctx context.Context, session sessions.Session) (*storage.User, error) {
panic("implement me")
}
func (m mockStorage) UpdateUserLastAuth(ctx context.Context, userID uuid.UUID) error {
panic("implement me")
}
func (m mockStorage) GetUserFollowers(ctx context.Context, userID uuid.UUID, limit, offset int) ([]string, error) {
panic("implement me")
}
func (m mockStorage) GetUserFollowing(ctx context.Context, userID uuid.UUID, limit, offset int) ([]string, error) {
panic("implement me")
}
func (m mockStorage) RecordActivity(ctx context.Context, activityID uuid.UUID, payload string) error {
panic("implement me")
}
func (m mockStorage) GetActor(ctx context.Context, id string) (storage.Actor, error) {
if m.GetActorFunc != nil {
return m.GetActorFunc(ctx, id)
}
return nil, nil
}
func (m mockStorage) CreateActor(a context.Context, b uuid.UUID, c uuid.UUID, d string, e string, f string, g string) error {
if m.CreateActorFunc != nil {
return m.CreateActorFunc(a, b, c, d, e, f, g)
}
return nil
}
var _ storage.Storage = mockStorage{}
func TestWebfinger_Run(t *testing.T) {
// if skip, _ := strconv.ParseBool(os.Getenv("TEST_INTEGRATION")); !skip {
// t.Skip("skipping integration test")
// return
// }
logger, _ := zap.NewDevelopment()
q := storage.NewStringQueue()
// assert.NoError(t, q.Add("https://mastodon.social/users/ngerakines"))
// assert.NoError(t, q.Add("@ngerakines@mastodon.social"))
assert.NoError(t, q.Add("ngerakines@mastodon.social"))
j := &webfinger{
logger: logger,
queue: q,
storage: mockStorage{
CreateActorFunc: func(ctx context.Context, u uuid.UUID, u2 uuid.UUID, actorID string, s2 string, keyID string, s4 string) error {
assert.Equal(t, "https://mastodon.social/@ngerakines", actorID)
assert.Equal(t, "https://mastodon.social/users/ngerakines#main-key", keyID)
return nil
},
},
httpClient: DefaultHTTPClient(),
}
assert.NoError(t, j.work())
}

56
main.go Normal file
View File

@ -0,0 +1,56 @@
package main
import (
"fmt"
"log"
"os"
"sort"
"time"
"github.com/urfave/cli/v2"
"github.com/ngerakines/tavern/g"
"github.com/ngerakines/tavern/start"
"github.com/ngerakines/tavern/web"
)
var ReleaseCode string
var GitCommit string
var BuildTime string
func main() {
compiledAt, err := time.Parse(time.RFC822Z, BuildTime)
if err != nil {
compiledAt = time.Now()
}
if ReleaseCode == "" {
ReleaseCode = "na"
}
if GitCommit == "" {
GitCommit = "na"
}
g.BuildTime = BuildTime
g.ReleaseCode = ReleaseCode
g.GitCommit = GitCommit
app := cli.NewApp()
app.Name = "tavern"
app.Usage = "The tavern application."
app.Version = fmt.Sprintf("%s-%s", ReleaseCode, GitCommit)
app.Compiled = compiledAt
app.Copyright = "(c) 2020 Nick Gerakines"
app.Commands = []*cli.Command{
&start.Command,
&web.Command,
}
sort.Sort(cli.FlagsByName(app.Flags))
sort.Sort(cli.CommandsByName(app.Commands))
err = app.Run(os.Args)
if err != nil {
log.Fatal(err)
}
}

7
public/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

7
public/bootstrap.min.js vendored Normal file

File diff suppressed because one or more lines are too long

8
public/humans.txt Normal file
View File

@ -0,0 +1,8 @@
/* TEAM */
Author: Nick Gerakines
Twitter: @ngerakines
ActivityPub: ngerakines@mastodon.social
Location: Dayton, Ohio, USA
/* SITE */
Language: English

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: /secrets

160
start/command.go Normal file
View File

@ -0,0 +1,160 @@
package start
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"runtime"
"github.com/getsentry/sentry-go"
"github.com/urfave/cli/v2"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"github.com/ngerakines/tavern/config"
"github.com/ngerakines/tavern/g"
"github.com/ngerakines/tavern/storage"
)
var Command = cli.Command{
Name: "init",
Usage: "Initialize the server",
Flags: []cli.Flag{
&config.EnvironmentFlag,
&config.ListenFlag,
&config.DomainFlag,
&config.DatabaseFlag,
&config.TranslationsFlag,
&cli.StringFlag{
Name: "admin-email",
Usage: "The email address of the admin user",
Required: true,
},
&cli.StringFlag{
Name: "admin-password",
Usage: "The password of the admin user",
Required: true,
},
&cli.StringFlag{
Name: "admin-locale",
Usage: "The locale of the admin user",
Value: "en",
},
&cli.StringFlag{
Name: "admin-name",
Usage: "The name of the admin user",
Required: true,
},
&cli.StringFlag{
Name: "admin-displayname",
Usage: "The display name of the admin user",
},
&cli.StringFlag{
Name: "admin-about",
Usage: "The 'about me' of the admin user",
},
},
Action: serverCommandAction,
}
func serverCommandAction(cliCtx *cli.Context) error {
logger, err := config.Logger(cliCtx)
if err != nil {
return err
}
domain := cliCtx.String("domain")
siteBase := fmt.Sprintf("https://%s", domain)
logger.Info("Starting",
zap.String("command", cliCtx.Command.Name),
zap.String("GOOS", runtime.GOOS),
zap.String("site", siteBase),
zap.String("env", cliCtx.String("environment")))
sentryConfig, err := config.NewSentryConfig(cliCtx)
if err != nil {
return err
}
if sentryConfig.Enabled {
err = sentry.Init(sentry.ClientOptions{
Dsn: sentryConfig.Key,
Environment: cliCtx.String("environment"),
Release: fmt.Sprintf("%s-%s", g.ReleaseCode, g.GitCommit),
})
if err != nil {
return err
}
sentry.ConfigureScope(func(scope *sentry.Scope) {
scope.SetTags(map[string]string{"container": "server"})
})
defer sentry.Recover()
}
db, dbClose, err := config.DB(cliCtx, logger)
if err != nil {
return err
}
defer dbClose()
s := storage.DefaultStorage(db)
userID := storage.NewV4()
name := cliCtx.String("admin-name")
displayName := cliCtx.String("admin-displayname")
if len(displayName) == 0 {
displayName = name
}
about := cliCtx.String("admin-about")
if len(about) == 0 {
about = "Just a user"
}
encPassword, err := bcrypt.GenerateFromPassword([]byte(cliCtx.String("admin-password")), bcrypt.DefaultCost)
if err != nil {
return err
}
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
privateKeyBytes := x509.MarshalPKCS1PrivateKey(key)
var privateKeyBuffer bytes.Buffer
if err := pem.Encode(&privateKeyBuffer, &pem.Block{
Type: "PRIVATE KEY",
Bytes: privateKeyBytes,
}); err != nil {
return err
}
privateKey := string(privateKeyBuffer.Bytes())
publicKeyBytes, err := x509.MarshalPKIXPublicKey(key.Public())
if err != nil {
return err
}
var publicKeyBuffer bytes.Buffer
if err = pem.Encode(&publicKeyBuffer, &pem.Block{
Type: "PUBLIC KEY",
Bytes: publicKeyBytes,
}); err != nil {
return err
}
publicKey := string(publicKeyBuffer.Bytes())
err = s.CreateUser(context.Background(), userID, cliCtx.String("admin-email"), cliCtx.String("admin-locale"), name, displayName, about, publicKey, privateKey, encPassword)
if err != nil {
logger.Error("unable to create user", zap.Error(err))
return err
}
return nil
}

86
storage/activity.go Normal file
View File

@ -0,0 +1,86 @@
package storage
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/gofrs/uuid"
"github.com/spaolacci/murmur3"
"github.com/ngerakines/tavern/errors"
)
type ActivityStorage interface {
RecordActivity(ctx context.Context, rowID uuid.UUID, activityID, payload string) error
GetActivitiesByIDs(ctx context.Context, activityIDs []uuid.UUID) ([]Activity, error)
RecordUserActivity(ctx context.Context, rowID, userID, activityID uuid.UUID) error
}
type Activity struct {
ID uuid.UUID
Payload string
CreatedAt time.Time
}
func (s pgStorage) RecordActivity(ctx context.Context, rowID uuid.UUID, activityID, payload string) error {
checksum := ActivityChecksum([]byte(payload))
_, err := s.db.ExecContext(ctx, "INSERT INTO activities (id, activity_id, checksum, payload, created_at) VALUES ($1, $2, $3, $4, $5)", rowID, activityID, checksum, payload, s.now())
return errors.WrapInsertQueryFailedError(err)
}
func (s pgStorage) RecordUserActivity(ctx context.Context, rowID, userID, activityID uuid.UUID) error {
_, err := s.db.ExecContext(ctx, "INSERT INTO user_activities (id, user_id, activity_id, created_at) VALUES ($1, $2, $3, $4)", rowID, userID, activityID, s.now())
return errors.WrapInsertQueryFailedError(err)
}
func (s pgStorage) GetActivitiesByIDs(ctx context.Context, activityIDs []uuid.UUID) ([]Activity, error) {
if len(activityIDs) == 0 {
return nil, nil
}
params := make([]string, len(activityIDs))
args := make([]interface{}, len(activityIDs))
for i, id := range activityIDs {
params[i] = fmt.Sprintf("$%d", i+1)
args[i] = id
}
query := fmt.Sprintf("SELECT id, payload, created_at FROM activities WHERE id IN (%s) ORDER BY created_at ASC", strings.Join(params, ", "))
return s.activitiesQuery(ctx, query, args...)
}
func (s pgStorage) activitiesQuery(ctx context.Context, query string, args ...interface{}) ([]Activity, error) {
var activities []Activity
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, errors.NewSelectQueryFailedError(err)
}
defer rows.Close()
for rows.Next() {
var activity Activity
if err := rows.Scan(&activity.ID, &activity.Payload, &activity.CreatedAt); err != nil {
return nil, errors.NewSelectQueryFailedError(err)
}
activities = append(activities, activity)
}
return activities, nil
}
func ActivityChecksum(raw []byte) string {
h := murmur3.New64()
h.Write(raw)
return strconv.FormatUint(h.Sum64(), 10)
}
func ValidateActivity(activity Payload) (string, error) {
id, ok := JSONString(activity, "id")
if !ok {
return "", fmt.Errorf("missing activity attribute: id")
}
return id, nil
}

280
storage/actor.go Normal file
View File

@ -0,0 +1,280 @@
package storage
import (
"context"
"crypto/rsa"
"crypto/x509"
"database/sql"
"encoding/pem"
"fmt"
"time"
"github.com/gofrs/uuid"
"github.com/ngerakines/tavern/errors"
)
type ActorStorage interface {
GetActor(ctx context.Context, id string) (Actor, error)
GetActorByAlias(ctx context.Context, alias string) (Actor, error)
CreateActor(context.Context, uuid.UUID, uuid.UUID, string, string, string, string) error
GetKey(ctx context.Context, keyID string) (*Key, error)
RecordActorAlias(ctx context.Context, actorID, alias string) error
}
type Actor interface {
GetID() string
GetInbox() string
GetPublicKey() string
GetKeyID() string
GetDecodedPublicKey() (*rsa.PublicKey, error)
}
type ActorID string
type actor struct {
ID uuid.UUID
ActorID string
CreatedAt time.Time
UpdatedAt time.Time
Inbox string
PublicKey string
KeyID string
payload string
}
type LocalActor struct {
User *User
ActorID ActorID
}
type Key struct {
ID uuid.UUID
KeyID string
PublicKey string
CreatedAt time.Time
}
var _ Actor = &actor{}
var _ Actor = &LocalActor{}
func (a *actor) GetDecodedPublicKey() (*rsa.PublicKey, error) {
block, _ := pem.Decode([]byte(a.PublicKey))
if block == nil {
return nil, fmt.Errorf("invalid RSA PEM")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("invalid RSA PEM")
}
rsaPublicKey, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("invalid RSA PEM")
}
return rsaPublicKey, nil
}
func (a *actor) GetID() string {
return a.ActorID
}
func (a *actor) GetInbox() string {
return a.Inbox
}
func (a *actor) GetKeyID() string {
return a.KeyID
}
func (a *actor) GetPublicKey() string {
return a.PublicKey
}
func (a *actor) init() error {
p, err := PayloadFromString(a.payload)
if err != nil {
return err
}
a.Inbox, err = InboxFromActor(p)
if err != nil {
return err
}
a.KeyID, a.PublicKey, err = KeyFromActor(p)
if err != nil {
return err
}
return nil
}
func (l LocalActor) GetID() string {
return string(l.ActorID)
}
func (l LocalActor) GetInbox() string {
return l.ActorID.Inbox()
}
func (l LocalActor) GetPublicKey() string {
return l.User.PublicKey
}
func (l LocalActor) GetKeyID() string {
return l.ActorID.MainKey()
}
func (l LocalActor) GetDecodedPublicKey() (*rsa.PublicKey, error) {
return l.GetDecodedPublicKey()
}
func (k *Key) GetDecodedPublicKey() (*rsa.PublicKey, error) {
block, _ := pem.Decode([]byte(k.PublicKey))
if block == nil {
return nil, fmt.Errorf("invalid RSA PEM")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("invalid RSA PEM")
}
rsaPublicKey, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("invalid RSA PEM")
}
return rsaPublicKey, nil
}
func (s pgStorage) GetActor(ctx context.Context, id string) (Actor, error) {
a := &actor{}
err := s.db.
QueryRowContext(ctx, `SELECT id, actor_id, payload, created_at, updated_at from actors WHERE actor_id = $1`, id).
Scan(&a.ID,
&a.ActorID,
&a.payload,
&a.CreatedAt,
&a.UpdatedAt)
if err != nil {
return nil, err
}
if err = a.init(); err != nil {
return nil, err
}
return a, nil
}
func (s pgStorage) GetActorByAlias(ctx context.Context, alias string) (Actor, error) {
a := &actor{}
err := s.db.
QueryRowContext(ctx, `SELECT id, actor_id, payload, created_at, updated_at from actors WHERE aliases @> ARRAY[$1]::varchar[]`, alias).
Scan(&a.ID,
&a.ActorID,
&a.payload,
&a.CreatedAt,
&a.UpdatedAt)
if err != nil {
return nil, err
}
if err = a.init(); err != nil {
return nil, err
}
return a, nil
}
func (s pgStorage) GetKey(ctx context.Context, keyID string) (*Key, error) {
key := &Key{}
err := s.db.
QueryRowContext(ctx, `SELECT id, key_id, public_key, created_at from keys WHERE key_id = $1`, keyID).
Scan(&key.ID,
&key.KeyID,
&key.PublicKey,
&key.CreatedAt)
if err != nil {
return nil, err
}
return key, nil
}
func (s pgStorage) CreateActor(ctx context.Context, actorRowID, keyRowID uuid.UUID, actorID, payload, keyID, pem string) error {
now := s.now()
return runTransactionWithOptions(s.db, func(tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, "INSERT INTO actors (id, actor_id, payload, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", actorRowID, actorID, payload, now)
if err != nil {
return errors.NewInsertQueryFailedError(err)
}
_, err = tx.ExecContext(ctx, "INSERT INTO keys (id, key_id, public_key, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", keyRowID, keyID, pem, now)
return errors.WrapInsertQueryFailedError(err)
})
}
func (s pgStorage) RecordActorAlias(ctx context.Context, actorID, alias string) error {
now := s.now()
_, err := s.db.ExecContext(ctx, `UPDATE actors SET aliases = array_append(aliases, $2), updated_at = $3 WHERE actor_id = $1 AND NOT(aliases @> ARRAY[$2]::varchar[])`, actorID, alias, now)
return errors.WrapUpdateQueryFailedError(err)
}
func (ID ActorID) Followers() string {
return fmt.Sprintf("%s/followers", ID)
}
func (ID ActorID) FollowersPage(page int) string {
return fmt.Sprintf("%s/followers?page=%d", ID, page)
}
func (ID ActorID) Following() string {
return fmt.Sprintf("%s/following", ID)
}
func (ID ActorID) FollowingPage(page int) string {
return fmt.Sprintf("%s/following?page=%d", ID, page)
}
func (ID ActorID) Outbox() string {
return fmt.Sprintf("%s/outbox", ID)
}
func (ID ActorID) OutboxPage(page int) string {
return fmt.Sprintf("%s/outbox?page=%d", ID, page)
}
func (ID ActorID) Inbox() string {
return fmt.Sprintf("%s/inbox", ID)
}
func (ID ActorID) MainKey() string {
return fmt.Sprintf("%s#main-key", ID)
}
func NewActorID(name, domain string) ActorID {
return ActorID(fmt.Sprintf("https://%s/users/%s", domain, name))
}
func KeyFromActor(actor Payload) (string, string, error) {
if actor == nil {
return "", "", fmt.Errorf("unable to get key from actor: actor nil")
}
publicKey, ok := JSONMap(actor, "publicKey")
if !ok {
return "", "", fmt.Errorf("unable to get key from actor: public key object missing")
}
keyID, ok := JSONString(publicKey, "id")
if !ok {
return "", "", fmt.Errorf("unable to get key from actor: public key object id invalid")
}
pem, ok := JSONString(publicKey, "publicKeyPem")
if !ok {
return "", "", fmt.Errorf("unable to get key from actor: public key object id invalid")
}
return keyID, pem, nil
}
func InboxFromActor(actor Payload) (string, error) {
inbox, ok := JSONString(actor, "inbox")
if !ok {
return "", fmt.Errorf("unable to get inbox from actor: inbox field")
}
return inbox, nil
}

124
storage/followers.go Normal file
View File

@ -0,0 +1,124 @@
package storage
import (
"context"
"time"
"github.com/gofrs/uuid"
"github.com/ngerakines/tavern/errors"
)
type FollowerStorage interface {
GetUserFollowers(ctx context.Context, userID uuid.UUID, limit, offset int) ([]Relationship, error)
GetUserFollowing(ctx context.Context, userID uuid.UUID, limit, offset int) ([]Relationship, error)
IsFollowing(ctx context.Context, userID uuid.UUID, actor string) (bool, error)
IsFollower(ctx context.Context, userID uuid.UUID, actor string) (bool, error)
FollowRequest(ctx context.Context, followID, userID, requestActivityID uuid.UUID, actor string) error
FollowAccept(ctx context.Context, userID, acceptActivityID uuid.UUID, actor string) error
FollowerRequest(ctx context.Context, followerID, userID, requestActivityID, acceptActivityID uuid.UUID, actor string) error
FollowerInboxes(ctx context.Context, userID uuid.UUID) ([]string, error)
}
type RelationshipDirection int
const (
FollowerRelationshipDirection = 0
FollowingRelationshipDirection = 1
)
type Relationship struct {
ID uuid.UUID
UserID uuid.UUID
Actor string
CreatedAt time.Time
Direction RelationshipDirection
}
func (s pgStorage) GetUserFollowers(ctx context.Context, userID uuid.UUID, limit, offset int) ([]Relationship, error) {
query := `SELECT id, user_id, actor, created_at, 1 FROM followers WHERE user_id = $3 ORDER BY created_at ASC LIMIT $1 OFFSET $2`
return s.relationshipQuery(ctx, query, limit, offset, userID)
}
func (s pgStorage) IsFollowing(ctx context.Context, userID uuid.UUID, actor string) (bool, error) {
count, err := s.RowCount(ctx, `SELECT COUNT(*) FROM following WHERE user_id = $1 AND actor = $2`, userID, actor)
if err != nil {
return false, err
}
return count == 1, nil
}
func (s pgStorage) IsFollower(ctx context.Context, userID uuid.UUID, actor string) (bool, error) {
count, err := s.RowCount(ctx, `SELECT COUNT(*) FROM followers WHERE user_id = $1 AND actor = $2`, userID, actor)
if err != nil {
return false, err
}
return count == 1, nil
}
func (s pgStorage) GetUserFollowing(ctx context.Context, userID uuid.UUID, limit, offset int) ([]Relationship, error) {
query := `SELECT id, user_id, actor, created_at, 1 FROM following WHERE user_id = $3 ORDER BY created_at ASC LIMIT $1 OFFSET $2`
return s.relationshipQuery(ctx, query, limit, offset, userID)
}
func (s pgStorage) relationshipQuery(ctx context.Context, query string, args ...interface{}) ([]Relationship, error) {
var rels []Relationship
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, errors.NewSelectQueryFailedError(err)
}
defer rows.Close()
for rows.Next() {
var rel Relationship
if err := rows.Scan(&rel.ID, &rel.UserID, &rel.Actor, &rel.CreatedAt, &rel.Direction); err != nil {
return nil, errors.NewSelectQueryFailedError(err)
}
rels = append(rels, rel)
}
return rels, nil
}
func (s pgStorage) FollowRequest(ctx context.Context, followID, userID, requestActivityID uuid.UUID, actor string) error {
now := s.now()
_, err := s.db.ExecContext(ctx, "INSERT INTO following (id, user_id, actor, request_activity_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $5)", followID, userID, actor, requestActivityID, now)
return errors.WrapInsertQueryFailedError(err)
}
func (s pgStorage) FollowAccept(ctx context.Context, userID, acceptActivityID uuid.UUID, actor string) error {
now := s.now()
_, err := s.db.ExecContext(ctx, `UPDATE following SET updated_at = $1, accept_activity_id = $2 WHERE user_id = $3 AND actor = $4`, now, acceptActivityID, userID, actor)
return errors.WrapInsertQueryFailedError(err)
}
func (s pgStorage) FollowerRequest(ctx context.Context, followerID, userID, requestActivityID, acceptActivityID uuid.UUID, actor string) error {
now := s.now()
_, err := s.db.ExecContext(ctx, "INSERT INTO followers (id, user_id, actor, request_activity_id, accept_activity_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $6)", followerID, userID, actor, requestActivityID, acceptActivityID, now)
return errors.WrapInsertQueryFailedError(err)
}
func (s pgStorage) FollowerInboxes(ctx context.Context, userID uuid.UUID) ([]string, error) {
query := `SELECT payload FROM actors WHERE actor_id IN (SELECT actor FROM followers WHERE user_id = $1)`
var results []string
rows, err := s.db.QueryContext(ctx, query, userID)
if err != nil {
return nil, errors.NewSelectQueryFailedError(err)
}
defer rows.Close()
for rows.Next() {
var payload string
if err := rows.Scan(&payload); err != nil {
return nil, errors.NewSelectQueryFailedError(err)
}
p, err := PayloadFromString(payload)
if err != nil {
return nil, err
}
inbox, ok := JSONString(p, "inbox")
if ok {
results = append(results, inbox)
}
}
return results, nil
}

79
storage/json.go Normal file
View File

@ -0,0 +1,79 @@
package storage
func JSONDeepString(document map[string]interface{}, keys ...string) (string, bool) {
if len(keys) == 1 {
return JSONString(document, keys[0])
}
if len(keys) > 1 {
inner, ok := JSONMap(document, keys[0])
if ok {
return JSONDeepString(inner, keys[1:]...)
}
}
return "", false
}
func JSONMap(document map[string]interface{}, key string) (map[string]interface{}, bool) {
if value, ok := document[key]; ok {
if mapVal, isMap := value.(map[string]interface{}); isMap {
return mapVal, true
}
}
return nil, false
}
func JSONString(document map[string]interface{}, key string) (string, bool) {
if value, ok := document[key]; ok {
if strValue, isString := value.(string); isString {
return strValue, true
}
}
return "", false
}
func JSONStrings(document map[string]interface{}, key string) ([]string, bool) {
var results []string
value, ok := document[key]
if !ok {
return nil, false
}
switch v := value.(type) {
case string:
results = append(results, v)
case []interface{}:
for _, el := range v {
if strValue, isString := el.(string); isString {
results = append(results, strValue)
}
}
}
return results, false
}
func JSONMapList(document map[string]interface{}, key string) ([]map[string]interface{}, bool) {
var results []map[string]interface{}
value, ok := document[key]
if !ok {
return nil, false
}
switch v := value.(type) {
case map[string]interface{}:
results = append(results, v)
case []interface{}:
for _, el := range v {
if mapValue, isMap := el.(map[string]interface{}); isMap {
results = append(results, mapValue)
}
}
}
return results, false
}
func StringsContainsString(things []string, value string) bool {
for _, thing := range things {
if thing == value {
return true
}
}
return false
}

57
storage/json_test.go Normal file
View File

@ -0,0 +1,57 @@
package storage
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestJSONString(t *testing.T) {
doc := map[string]interface{}{
"foo": 1,
"bar": "a",
"baz": map[string]interface{}{
"foo": "b",
},
}
{
value, ok := JSONString(doc, "foo")
assert.Equal(t, "", value)
assert.False(t, ok)
}
{
value, ok := JSONString(doc, "bar")
assert.Equal(t, "a", value)
assert.True(t, ok)
}
{
value, ok := JSONString(doc, "baz")
assert.Equal(t, "", value)
assert.False(t, ok)
}
}
func TestJSONDeepString(t *testing.T) {
doc := map[string]interface{}{
"foo": 1,
"bar": "a",
"baz": map[string]interface{}{
"foo": "b",
},
}
{
value, ok := JSONDeepString(doc, "foo")
assert.Equal(t, "", value)
assert.False(t, ok)
}
{
value, ok := JSONDeepString(doc, "bar")
assert.Equal(t, "a", value)
assert.True(t, ok)
}
{
value, ok := JSONDeepString(doc, "baz", "foo")
assert.Equal(t, "b", value)
assert.True(t, ok)
}
}

44
storage/payload.go Normal file
View File

@ -0,0 +1,44 @@
package storage
import (
"bytes"
"encoding/json"
"io"
"strings"
)
type Payload map[string]interface{}
func (p Payload) Write(w io.Writer) error {
e := json.NewEncoder(w)
e.SetEscapeHTML(false)
return e.Encode(p)
}
func (p Payload) Bytes() []byte {
var buf bytes.Buffer
p.Write(&buf)
return buf.Bytes()
}
func EmptyPayload() Payload {
return make(map[string]interface{})
}
func PayloadFromReader(r io.Reader) (Payload, error) {
lr := io.LimitReader(r, 1048576)
decoder := json.NewDecoder(lr)
var p Payload
if err := decoder.Decode(&p); err != nil {
return nil, err
}
return p, nil
}
func PayloadFromBytes(b []byte) (Payload, error) {
return PayloadFromReader(bytes.NewReader(b))
}
func PayloadFromString(s string) (Payload, error) {
return PayloadFromReader(strings.NewReader(s))
}

19
storage/peers.go Normal file
View File

@ -0,0 +1,19 @@
package storage
import (
"context"
"github.com/gofrs/uuid"
"github.com/ngerakines/tavern/errors"
)
type PeerStorage interface {
CreatePeer(ctx context.Context, peerID uuid.UUID, inbox string) error
}
func (s pgStorage) CreatePeer(ctx context.Context, peerID uuid.UUID, inbox string) error {
now := s.now()
_, err := s.db.ExecContext(ctx, "INSERT INTO keys (id, inbox, created_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", peerID, inbox, now)
return errors.WrapInsertQueryFailedError(err)
}

29
storage/session.go Normal file
View File

@ -0,0 +1,29 @@
package storage
import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
"github.com/ngerakines/tavern/errors"
)
func userIDFromSession(session sessions.Session) (uuid.UUID, error) {
return userIDFromSessionKey(session, gin.AuthUserKey)
}
func userIDFromSessionKey(session sessions.Session, key string) (uuid.UUID, error) {
sessionData := session.Get(key)
if sessionData == nil {
return uuid.Nil, errors.NewUserSessionNotFoundError(nil)
}
userIDStr, ok := sessionData.(string)
if !ok {
return uuid.Nil, errors.NewInvalidUserIDError(nil)
}
userID, err := uuid.FromString(userIDStr)
if !ok {
return uuid.Nil, errors.NewInvalidUserIDError(err)
}
return userID, nil
}

67
storage/storage.go Normal file
View File

@ -0,0 +1,67 @@
package storage
import (
"context"
"database/sql"
"time"
_ "github.com/lib/pq"
"github.com/ngerakines/tavern/errors"
)
type Storage interface {
RowCount(ctx context.Context, query string, args ...interface{}) (int, error)
UserStorage
ActorStorage
FollowerStorage
ActivityStorage
PeerStorage
ThreadStorage
}
func DefaultStorage(db *sql.DB) Storage {
return pgStorage{
db: db,
now: defaultNowFunc,
}
}
type pgStorage struct {
db *sql.DB
now nowFunc
}
type transactionScopedWork func(db *sql.Tx) error
type nowFunc func() time.Time
func defaultNowFunc() time.Time {
return time.Now().UTC()
}
func runTransactionWithOptions(db *sql.DB, txBody transactionScopedWork) error {
tx, err := db.Begin()
if err != nil {
return errors.NewDatabaseTransactionFailedError(err)
}
err = txBody(tx)
if err != nil {
if txErr := tx.Rollback(); txErr != nil {
return errors.NewDatabaseTransactionFailedError(txErr)
}
return err
}
return errors.WrapDatabaseTransactionFailedError(tx.Commit())
}
func (s pgStorage) RowCount(ctx context.Context, query string, args ...interface{}) (int, error) {
var total int
err := s.db.QueryRowContext(ctx, query, args...).Scan(&total)
if err != nil {
return -1, err
}
return total, nil
}

44
storage/string_queue.go Normal file
View File

@ -0,0 +1,44 @@
package storage
import (
"sync"
)
type stringQueue struct {
values []string
lock sync.Mutex
}
type StringQueue interface {
Add(value string) error
Take() (string, error)
}
func NewStringQueue() StringQueue {
return &stringQueue{
values: make([]string, 0),
}
}
func (q *stringQueue) Add(value string) error {
q.lock.Lock()
defer q.lock.Unlock()
q.values = append(q.values, value)
return nil
}
func (q *stringQueue) Take() (string, error) {
q.lock.Lock()
defer q.lock.Unlock()
if len(q.values) == 0 {
return "", nil
}
var value string
value, q.values = q.values[0], q.values[1:]
return value, nil
}

View File

@ -0,0 +1,30 @@
package storage
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestStringQueue(t *testing.T) {
q := NewStringQueue()
assert.NoError(t, q.Add("a"))
assert.NoError(t, q.Add("b"))
assert.NoError(t, q.Add("c"))
a, e1 := q.Take()
assert.Equal(t, "a", a)
assert.NoError(t, e1)
b, e2 := q.Take()
assert.Equal(t, "b", b)
assert.NoError(t, e2)
c, e3 := q.Take()
assert.Equal(t, "c", c)
assert.NoError(t, e3)
d, e4 := q.Take()
assert.Equal(t, "", d)
assert.NoError(t, e4)
}

65
storage/threads.go Normal file
View File

@ -0,0 +1,65 @@
package storage
import (
"context"
"time"
"github.com/gofrs/uuid"
"github.com/ngerakines/tavern/errors"
)
type ThreadStorage interface {
PartOfThread(ctx context.Context, userID uuid.UUID, conversation string) (bool, error)
WatchThread(ctx context.Context, userID uuid.UUID, conversation string) error
AddToUserFeed(ctx context.Context, userID, activityID uuid.UUID) error
PaginateUserFeed(ctx context.Context, userID uuid.UUID, limit, offset int) ([]UserFeed, error)
}
type UserFeed struct {
ID uuid.UUID
UserID uuid.UUID
ActivityID uuid.UUID
CreatedAt time.Time
}
func (s pgStorage) PartOfThread(ctx context.Context, userID uuid.UUID, conversation string) (bool, error) {
c, err := s.RowCount(ctx, `SELECT COUNT(*) FROM user_threads WHERE user_id = $1 AND thread = $2`, userID, conversation)
if err != nil {
return false, err
}
return c > 0, nil
}
func (s pgStorage) WatchThread(ctx context.Context, userID uuid.UUID, conversation string) error {
rowID := NewV4()
_, err := s.db.ExecContext(ctx, "INSERT INTO user_threads (id, user_id, conversation, created_at) VALUES ($1, $2, $3, $4)", rowID, userID, conversation, s.now())
return errors.WrapInsertQueryFailedError(err)
}
func (s pgStorage) AddToUserFeed(ctx context.Context, userID, activityID uuid.UUID) error {
rowID := NewV4()
_, err := s.db.ExecContext(ctx, "INSERT INTO user_feed (id, user_id, activity_id, created_at) VALUES ($1, $2, $3, $4)", rowID, userID, activityID, s.now())
return errors.WrapInsertQueryFailedError(err)
}
func (s pgStorage) PaginateUserFeed(ctx context.Context, userID uuid.UUID, limit, offset int) ([]UserFeed, error) {
query := `SELECT id, user_id, activity_id, created_at FROM user_feed WHERE user_id = $3 ORDER BY created_at ASC LIMIT $1 OFFSET $2`
var results []UserFeed
rows, err := s.db.QueryContext(ctx, query, limit, offset, userID)
if err != nil {
return nil, errors.NewSelectQueryFailedError(err)
}
defer rows.Close()
for rows.Next() {
var uf UserFeed
if err := rows.Scan(&uf.ID, &uf.UserID, &uf.ActivityID, &uf.CreatedAt); err != nil {
return nil, errors.NewSelectQueryFailedError(err)
}
results = append(results, uf)
}
return results, nil
}

183
storage/user.go Normal file
View File

@ -0,0 +1,183 @@
package storage
import (
"context"
"crypto/rsa"
"crypto/x509"
"database/sql"
"encoding/pem"
"fmt"
"strings"
"time"
"github.com/gin-contrib/sessions"
"github.com/gofrs/uuid"
"golang.org/x/crypto/bcrypt"
"github.com/ngerakines/tavern/errors"
)
type UserStorage interface {
AuthenticateUser(ctx context.Context, email string, password []byte) (uuid.UUID, error)
CreateUser(ctx context.Context, userID uuid.UUID, email, locale, name, displayName, about, publicKey, privateKey string, password []byte) error
GetUser(ctx context.Context, userID uuid.UUID) (*User, error)
GetUserByName(ctx context.Context, name string) (*User, error)
GetUserBySession(ctx context.Context, session sessions.Session) (*User, error)
UpdateUserLastAuth(ctx context.Context, userID uuid.UUID) error
}
type User struct {
ID uuid.UUID
Email string
Password []byte
CreatedAt time.Time
UpdatedAt time.Time
LastAuthAt time.Time
Location string
MuteEmail bool
Locale string
PrivateKey string
PublicKey string
Name string
DisplayName string
About string
AcceptFollowers bool
}
func (u *User) GetID() string {
return ""
}
func (u *User) GetInbox() string {
return ""
}
func (u *User) GetKeyID() string {
return ""
}
func (u *User) GetPrivateKey() (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(u.PrivateKey))
if block == nil {
return nil, errors.New("invalid RSA PEM")
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
return key, nil
}
func (u *User) GetDecodedPublicKey() (*rsa.PublicKey, error) {
block, _ := pem.Decode([]byte(u.PublicKey))
if block == nil {
return nil, fmt.Errorf("invalid RSA PEM")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("invalid RSA PEM")
}
rsaPublicKey, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("invalid RSA PEM")
}
return rsaPublicKey, nil
}
var userFields = []string{
"id",
"email",
"created_at",
"updated_at",
"last_auth_at",
"location",
"mute_email",
"locale",
"public_key",
"private_key",
"name",
"display_name",
"about",
"accept_followers",
}
func (s pgStorage) GetUserBySession(ctx context.Context, session sessions.Session) (*User, error) {
userID, err := userIDFromSession(session)
if err != nil {
return nil, err
}
return s.GetUser(ctx, userID)
}
func (s pgStorage) GetUserByName(ctx context.Context, name string) (*User, error) {
query := fmt.Sprintf("SELECT %s FROM users WHERE name = $1", strings.Join(userFields, ", "))
return s.GetUserWithQuery(ctx, query, name)
}
func (s pgStorage) GetUser(ctx context.Context, userID uuid.UUID) (*User, error) {
query := fmt.Sprintf("SELECT %s FROM users WHERE id = $1", strings.Join(userFields, ", "))
return s.GetUserWithQuery(ctx, query, userID)
}
func (s pgStorage) GetUserWithQuery(ctx context.Context, query string, args ...interface{}) (*User, error) {
user := &User{}
err := s.db.
QueryRowContext(ctx, query, args...).
Scan(&user.ID,
&user.Email,
&user.CreatedAt,
&user.UpdatedAt,
&user.LastAuthAt,
&user.Location,
&user.MuteEmail, &user.Locale,
&user.PublicKey,
&user.PrivateKey,
&user.Name,
&user.DisplayName,
&user.About,
&user.AcceptFollowers)
if err != nil {
if err == sql.ErrNoRows {
return nil, errors.NewUserNotFoundError(err)
}
return nil, errors.NewUserQueryFailedError(err)
}
return user, nil
}
func (s pgStorage) CreateUser(ctx context.Context, userID uuid.UUID, email, locale, name, displayName, about, publicKey, privateKey string, password []byte) error {
now := s.now()
_, err := s.db.ExecContext(ctx, "INSERT INTO users (id, email, password, created_at, updated_at, last_auth_at, locale, public_key, private_key, name, display_name, about) VALUES ($1, $2, $3, $4, $4, $4, $5, $6, $7, $8, $9, $10)", userID, email, password, now, locale, publicKey, privateKey, name, displayName, about)
if err != nil {
return errors.NewCreateUserFailedError(err)
}
return nil
}
func (s pgStorage) AuthenticateUser(ctx context.Context, email string, password []byte) (uuid.UUID, error) {
var userID uuid.UUID
var currentPassword []byte
err := s.db.QueryRowContext(ctx, "SELECT id, password FROM users WHERE email = $1", email).Scan(&userID, &currentPassword)
if err != nil {
if err == sql.ErrNoRows {
return uuid.Nil, errors.NewUserNotFoundError(err)
}
return uuid.Nil, errors.NewUserQueryFailedError(err)
}
err = bcrypt.CompareHashAndPassword(currentPassword, password)
if err != nil {
return uuid.Nil, errors.NewUserNotFoundError(err)
}
return userID, nil
}
func (s pgStorage) UpdateUserLastAuth(ctx context.Context, userID uuid.UUID) error {
now := s.now()
_, err := s.db.ExecContext(ctx, "UPDATE users SET last_auth_at = $2, updated_at = $2 WHERE id = $1", userID, now)
return errors.WrapUpdateUserFailedError(err)
}

13
storage/uuid.go Normal file
View File

@ -0,0 +1,13 @@
package storage
import (
"github.com/gofrs/uuid"
)
func NewV4() uuid.UUID {
u, err := uuid.NewV4()
if err != nil {
panic(err)
}
return u
}

11
templates/configure.html Normal file
View File

@ -0,0 +1,11 @@
{{define "head"}}{{end}}
{{define "footer_script"}}{{end}}
{{define "content"}}
{{ $t := .Trans }}
{{ template "dashboard_menu" "configure" }}
<div class="row pt-3">
<div class="col">
<h1>Configure</h1>
</div>
</div>
{{end}}

18
templates/dashboard.html Normal file
View File

@ -0,0 +1,18 @@
{{define "head"}}{{end}}
{{define "footer_script"}}{{end}}
{{define "content"}}
{{ $t := .Trans }}
{{ template "dashboard_menu" "dashboard" }}
<div class="row pt-3">
<div class="col">
<h1>Your Dashboard</h1>
</div>
</div>
{{ range $a := .activities }}
<div class="row pt-3">
<div class="col">
<pre>{{ $a.Payload }}</pre>
</div>
</div>
{{ end }}
{{end}}

View File

@ -0,0 +1,70 @@
{{define "head"}}{{end}}
{{define "footer_script"}}{{end}}
{{define "content"}}
{{ $t := .Trans }}
{{ template "dashboard_menu" "network" }}
<div class="row pt-3">
<div class="col">
<h1 class="pb-1">Follow</h1>
<form method="POST" action="/dashboard/network/follow" id="follow">
<div class="form-group">
<label for="followUser">Actor</label>
<input type="text" class="form-control" id="followUser" name="actor"
placeholder="ngerakines@mastodon.social" required>
</div>
<input type="submit" class="btn btn-dark" name="submit" value="Follow"/>
</form>
</div>
</div>
<div class="row pt-3">
<div class="col">
<h1>Following</h1>
<table class="table table-striped">
<thead>
<tr>
<th>Actor</th>
<th>Since</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range $f := .following }}
<tr>
<td>{{ $f.Actor }}</td>
<td>{{ date $f.CreatedAt }}</td>
<td>
<a href="#">Unfollow</a>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
<div class="row pt-3">
<div class="col">
<h1>Followers</h1>
<table class="table table-striped">
<thead>
<tr>
<th>Actor</th>
<th>Since</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range $f := .followers }}
<tr>
<td>{{ $f.Actor }}</td>
<td>{{ date $f.CreatedAt }}</td>
<td>
<a href="#">Unfollow</a>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
{{end}}

View File

@ -0,0 +1,18 @@
{{define "head"}}{{end}}
{{define "footer_script"}}{{end}}
{{define "content"}}
{{ $t := .Trans }}
{{ template "dashboard_menu" "notes" }}
<div class="row pt-3">
<div class="col">
<h1 class="pb-1">Create Note</h1>
<form method="POST" action="/dashboard/notes/create/note" id="create_note">
<div class="form-group">
<label for="createNoteContent">Content</label>
<input type="text" class="form-control" id="createNoteContent" name="content" required>
</div>
<input type="submit" class="btn btn-dark" name="submit" value="Create Note"/>
</form>
</div>
</div>
{{end}}

7
templates/error.html Normal file
View File

@ -0,0 +1,7 @@
{{define "head"}}{{end}}
{{define "footer_script"}}{{end}}
{{define "content"}}
<div class="alert alert-danger" role="alert">
{{ .Trans.T .error }}
</div>
{{end}}

27
templates/index.html Normal file
View File

@ -0,0 +1,27 @@
{{define "head"}}{{end}}
{{define "footer_script"}}{{end}}
{{define "content"}}
<div class="row pt-3 pb-3">
<div class="col">
<h1>Please Authenticate</h1>
</div>
</div>
<div class="row pt-md-3 pt-3">
<div class="col-sm-12 col-lg-6">
<a id="login"></a>
<h3>Sign-in</h3>
<form method="POST" action="{{ url "signin" }}" id="login">
<div class="form-group">
<label for="loginEmail">Email</label>
<input type="email" class="form-control" id="loginEmail" name="email" required>
</div>
<div class="form-group">
<label for="loginPassword">Password</label>
<input type="password" class="form-control" id="loginPassword" name="password" required>
</div>
<input type="submit" class="btn btn-dark" name="submit" value="Submit"/>
</form>
</div>
</div>
{{end}}

View File

@ -0,0 +1,12 @@
<div class="row border-top mt-4">
<div class="col">
<p class="lead">Copyright 2020 Nick Gerakines</p>
</div>
<div class="col">
<p class="lead text-center">
<i class="fal fa-taco"></i>
<i class="fal fa-taco"></i>
<i class="fal fa-taco"></i>
</p>
</div>
</div>

View File

@ -0,0 +1,38 @@
<!doctype html>
<html lang="{{ .Trans.Locale }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>{{.title}}</title>
<link rel="stylesheet" href="/public/bootstrap.min.css">
<link rel="stylesheet" href="/public/all.min.css">
{{template "head" .}}
</head>
<body>
<main role="main" class="container">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="{{ url }}">Tavern</a>
<div class="collapse navbar-collapse" id="navbarItems">
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<a class="nav-link"
href="{{ url "public_notes" }}">Public Notes</a>
</li>
</ul>
</div>
</nav>
{{ template "flashes" . }}
<div class="container">
{{template "content" .}}
</div>
{{include "layouts/footer"}}
</main>
<script src="https://code.jquery.com/jquery-3.4.1.slim.min.js"
integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n"
crossorigin="anonymous"></script>
<script src="/public/bootstrap.min.js"></script>
{{ template "footer_script" . }}
</body>
</html>

View File

@ -0,0 +1,18 @@
{{ define "dashboard_menu" }}
<div role="navigation" class="pt-3">
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link{{ if eq "dashboard" . }} active{{ end }}" href="{{ url "dashboard" }}">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link{{ if eq "network" . }} active{{ end }}" href="{{ url "network" }}">Network</a>
</li>
<li class="nav-item">
<a class="nav-link{{ if eq "notes" . }} active{{ end }}" href="{{ url "notes" }}">Notes</a>
</li>
<li class="nav-item">
<a class="nav-link{{ if eq "configure" . }} active{{ end }}" href="{{ url "configure" }}">Configure</a>
</li>
</ul>
</div>
{{ end }}

View File

@ -0,0 +1,22 @@
{{ define "flashes" }}
{{ $t := .Trans }}
{{ if and .flashes .flashes.Show }}
<div class="container pt-3 ">
{{ range .flashes.Error }}
<div class="alert alert-danger" role="alert">
{{ . }}
</div>
{{ end }}
{{ range .flashes.Success }}
<div class="alert alert-success" role="alert">
{{ . }}
</div>
{{ end }}
{{ range .flashes.Info }}
<div class="alert alert-secondary" role="alert">
{{ . }}
</div>
{{ end }}
</div>
{{ end }}
{{ end }}

View File

@ -0,0 +1,92 @@
[
{
"locale": "en",
"key": "TAVAAAAAAAB",
"trans": "Not found."
},
{
"locale": "en",
"key": "TAVAAAAAAAC",
"trans": "Encrypting data failed."
},
{
"locale": "en",
"key": "TAVAAAAAAAD",
"trans": "Decrypting data failed."
},
{
"locale": "en",
"key": "TAVDATAAAAAAAB",
"trans": "The query operation failed."
},
{
"locale": "en",
"key": "TAVDATAAAAAAAC",
"trans": "The database transaction failed."
},
{
"locale": "en",
"key": "TAVDATAAAAAAAD",
"trans": "The insert query failed"
},
{
"locale": "en",
"key": "TAVDATAAAAAAAE",
"trans": "The select query failed"
},
{
"locale": "en",
"key": "TAVDATAAAAAAAF",
"trans": "The update query failed"
},
{
"locale": "en",
"key": "TAVDATAAAAAAAG",
"trans": "The user ID is invalid."
},
{
"locale": "en",
"key": "TAVDATAAAAAAAH",
"trans": "The user was not found."
},
{
"locale": "en",
"key": "TAVDATAAAAAAAI",
"trans": "The user was not able to be created."
},
{
"locale": "en",
"key": "TAVDATAAAAAAAJ",
"trans": "The user query failed."
},
{
"locale": "en",
"key": "TAVDATAAAAAAAK",
"trans": "The user was not able to be updated."
},
{
"locale": "en",
"key": "TAVDATAAAAAAAL",
"trans": "The user session was not found."
},
{
"locale": "en",
"key": "TAVWEBAAAAAAAB",
"trans": "Invalid verification."
},
{
"locale": "en",
"key": "TAVWEBAAAAAAAC",
"trans": "Cannot save session."
},
{
"locale": "en",
"key": "TAVWEBAAAAAAAD",
"trans": "Invalid captcha."
},
{
"locale": "en",
"key": "TAVWEBAAAAAAAE",
"trans": "Unable to send email."
}
]

271
web/command.go Normal file
View File

@ -0,0 +1,271 @@
package web
import (
"context"
"fmt"
"html/template"
"net/http"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/foolin/goview"
"github.com/foolin/goview/supports/ginview"
"github.com/getsentry/sentry-go"
sentrygin "github.com/getsentry/sentry-go/gin"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-contrib/static"
ginzap "github.com/gin-contrib/zap"
"github.com/gin-gonic/gin"
ut "github.com/go-playground/universal-translator"
"github.com/oklog/run"
"github.com/urfave/cli/v2"
"go.uber.org/zap"
"github.com/ngerakines/tavern/config"
"github.com/ngerakines/tavern/g"
"github.com/ngerakines/tavern/job"
"github.com/ngerakines/tavern/storage"
)
var Command = cli.Command{
Name: "server",
Usage: "Run the web server.",
Flags: []cli.Flag{
&config.EnvironmentFlag,
&config.ListenFlag,
&config.DomainFlag,
&config.DatabaseFlag,
&config.TranslationsFlag,
&config.SecretFlag,
&cli.StringFlag{
Name: "admin-name",
Usage: "The name of the admin user",
EnvVars: []string{"ADMIN_NAME"},
Value: "nick",
},
},
Action: serverCommandAction,
}
func serverCommandAction(cliCtx *cli.Context) error {
logger, err := config.Logger(cliCtx)
if err != nil {
return err
}
domain := cliCtx.String("domain")
siteBase := fmt.Sprintf("https://%s", domain)
logger.Info("Starting",
zap.String("command", cliCtx.Command.Name),
zap.String("GOOS", runtime.GOOS),
zap.String("site", siteBase),
zap.String("env", cliCtx.String("environment")))
ohioLocal, err = time.LoadLocation("America/New_York")
if err != nil {
return err
}
sentryConfig, err := config.NewSentryConfig(cliCtx)
if err != nil {
return err
}
if sentryConfig.Enabled {
err = sentry.Init(sentry.ClientOptions{
Dsn: sentryConfig.Key,
Environment: cliCtx.String("environment"),
Release: fmt.Sprintf("%s-%s", g.ReleaseCode, g.GitCommit),
})
if err != nil {
return err
}
sentry.ConfigureScope(func(scope *sentry.Scope) {
scope.SetTags(map[string]string{"container": "server"})
})
defer sentry.Recover()
}
db, dbClose, err := config.DB(cliCtx, logger)
if err != nil {
return err
}
defer dbClose()
s := storage.DefaultStorage(db)
utrans, err := config.Trans(cliCtx)
if err != nil {
panic(err)
return err
}
r := gin.New()
if sentryConfig.Enabled {
r.Use(sentrygin.New(sentrygin.Options{
Repanic: true,
}))
}
r.HTMLRender = ginview.New(goview.Config{
Root: "templates",
Extension: ".html",
Master: filepath.Join("layouts", "master"),
Partials: []string{"partials/flashes", "partials/dashboard_menu"},
Funcs: template.FuncMap{
"date": tmplDate,
"odate": tmplOptionalDate,
"time": tmplTime,
"otime": tmplOptionalTime,
"datetime": tmplDateTime,
"odatetime": tmplOptionalDateTime,
"shortUUID": tmplShortUUID,
"short": tmplShort,
"title": strings.Title,
"a": tmplAnchor,
"toHTML": tmplToHTML,
"strong": tmplStrong,
"url": tmplUrlGen(siteBase),
},
DisableCache: true,
})
r.Use(func(i *gin.Context) {
nonce := i.GetHeader("X-Request-ID")
if nonce != "" {
i.Set("nonce", nonce)
}
i.Next()
})
r.Use(ginzap.Ginzap(logger, time.RFC3339, true))
r.GET("/health", func(i *gin.Context) {
i.Data(200, "text/plain", []byte("OK"))
})
r.Use(static.Serve("/public", static.LocalFile("./public", false)))
secret, err := config.Secret(cliCtx)
if err != nil {
return err
}
webFingerQueue := storage.NewStringQueue()
h := handler{
storage: s,
logger: logger,
domain: domain,
sentryConfig: sentryConfig,
webFingerQueue: webFingerQueue,
adminUser: cliCtx.String("admin-name"),
}
configI18nMiddleware(sentryConfig, logger, utrans, domain, r)
root := r.Group("/")
{
root.GET("/.well-known/webfinger", h.webFinger)
root.GET("/.well-known/nodeinfo", h.nodeInfo)
root.GET("/nodeinfo/2.0", h.nodeInfoDetails)
}
apiV1 := r.Group("/api/v1")
{
apiV1.GET("/instance", h.apiV1Instance)
apiV1.GET("/instance/peers", h.apiV1InstancePeers)
}
authenticated := r.Group("/")
{
configSessionMiddleware(secret, domain, authenticated)
authenticated.GET("/", h.home)
authenticated.POST("/signin", h.signin)
authenticated.GET("/signout", h.signout)
authenticated.GET("/dashboard", h.dashboard)
authenticated.GET("/dashboard/network", h.dashboardNetwork)
authenticated.POST("/dashboard/network/follow", h.followActor)
authenticated.GET("/dashboard/notes", h.dashboardNotes)
authenticated.POST("/dashboard/notes/create/note", h.createNote)
authenticated.GET("/configure", h.configure)
}
server := r.Group("/server")
{
server.POST("/inbox", h.serverInbox)
}
actor := r.Group("/users")
{
actor.GET("/:name", h.actorInfo)
actor.POST("/:name/inbox", h.actorInbox)
}
var group run.Group
srv := &http.Server{
Addr: config.ListenAddress(cliCtx),
Handler: r,
}
parentCtx, parentCtxCancel := context.WithCancel(context.Background())
defer parentCtxCancel()
group.Add(func() error {
logger.Info("starting http service", zap.String("address", srv.Addr))
return srv.ListenAndServe()
}, func(error) {
httpCancelCtx, httpCancelCtxCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer httpCancelCtxCancel()
logger.Info("stopping http service")
if err := srv.Shutdown(httpCancelCtx); err != nil {
logger.Info("error stopping http service:", zap.Error(err))
}
})
webFingerJob := job.NewWebFingerWorker(logger, webFingerQueue, s)
job.RunWorker(&group, logger, webFingerJob, parentCtx)
quit := make(chan os.Signal)
signal.Notify(quit, os.Interrupt)
group.Add(func() error {
logger.Info("starting signal listener")
<-quit
return nil
}, func(error) {
logger.Info("stopping signal listener")
close(quit)
})
return group.Run()
}
func configI18nMiddleware(sentryCfg config.SentryConfig, logger *zap.Logger, utrans *ut.UniversalTranslator, domain string, r gin.IRouter) {
im := i18nMiddleware{
utrans: utrans,
logger: logger,
domain: domain,
enableSentry: sentryCfg.Enabled,
}
r.Use(im.findLocale)
}
func configSessionMiddleware(secret []byte, domain string, r gin.IRouter) {
store := cookie.NewStore(secret)
store.Options(sessions.Options{
Path: "/",
Domain: domain,
MaxAge: 86400 * 30,
Secure: true,
HttpOnly: true,
})
r.Use(sessions.Sessions("sess", store))
}

50
web/flashes.go Normal file
View File

@ -0,0 +1,50 @@
package web
import (
"github.com/gin-contrib/sessions"
"github.com/kr/pretty"
"github.com/ngerakines/tavern/errors"
)
const (
flashError = "error"
flashSuccess = "success"
flashInfo = "_flash"
)
type flashes struct {
Success []interface{}
Info []interface{}
Error []interface{}
}
func getFlashes(session sessions.Session) flashes {
return flashes{
Success: session.Flashes(flashSuccess),
Info: session.Flashes(flashInfo),
Error: session.Flashes(flashError),
}
}
func (f flashes) Show() bool {
pretty.Println(f)
return len(f.Success) > 0 || len(f.Info) > 0 || len(f.Error) > 0
}
func appendFlashError(session sessions.Session, messages ...string) error {
for _, message := range messages {
session.AddFlash(message, flashError)
}
return errors.WrapCannotSaveSessionError(session.Save())
}
func appendFlashSuccess(session sessions.Session, message string) error {
session.AddFlash(message, flashSuccess)
return errors.WrapCannotSaveSessionError(session.Save())
}
func appendFlashInfo(session sessions.Session, message string) error {
session.AddFlash(message, flashInfo)
return errors.WrapCannotSaveSessionError(session.Save())
}

97
web/handler.go Normal file
View File

@ -0,0 +1,97 @@
package web
import (
"net/http"
"strconv"
"github.com/getsentry/sentry-go"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ngerakines/tavern/config"
"github.com/ngerakines/tavern/errors"
"github.com/ngerakines/tavern/storage"
)
type handler struct {
storage storage.Storage
logger *zap.Logger
domain string
sentryConfig config.SentryConfig
webFingerQueue storage.StringQueue
adminUser string
}
func (h handler) hardFail(ctx *gin.Context, err error, fields ...zap.Field) {
if h.sentryConfig.Enabled {
hub := sentry.CurrentHub().Clone()
hub.Scope().SetRequest(sentry.Request{}.FromHTTPRequest(ctx.Request))
hub.CaptureException(err)
}
trans, transOK := ctx.Get("trans")
if !transOK {
panic("trans not found in context")
}
fields = append(fields, zap.Error(err), zap.Strings("error_chain", errors.ErrorChain(err)))
h.logger.Error("request hard failed", fields...)
ctx.HTML(http.StatusInternalServerError, "error", gin.H{"error": err.Error(), "Trans": trans})
ctx.Abort()
}
func (h handler) hardFailJson(ctx *gin.Context, err error, fields ...zap.Field) {
if h.sentryConfig.Enabled {
hub := sentry.CurrentHub().Clone()
hub.Scope().SetRequest(sentry.Request{}.FromHTTPRequest(ctx.Request))
hub.CaptureException(err)
}
fields = append(fields, zap.Error(err), zap.Strings("error_chain", errors.ErrorChain(err)))
h.logger.Error("request hard failed", fields...)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
ctx.Abort()
}
func (h handler) WriteJRD(c *gin.Context, data map[string]interface{}) {
c.Writer.Header().Set("Content-Type", "application/jrd+json")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Pragma", "no-cache")
c.JSON(200, data)
}
func (h handler) writeJSONLD(c *gin.Context, data map[string]interface{}) {
c.Writer.Header().Set("Content-Type", "application/activity+json")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Pragma", "no-cache")
c.JSON(200, data)
}
func (h handler) writeActivityJsonWithProfile(c *gin.Context, data map[string]interface{}) {
c.Writer.Header().Set("Content-Type", `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`)
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Pragma", "no-cache")
c.JSON(200, data)
}
func (h handler) flashErrorOrFail(c *gin.Context, location string, err error) {
session := sessions.Default(c)
h.logger.Error("error", zap.Error(err))
if err := appendFlashError(session, err.Error()); err != nil {
h.hardFail(c, errors.NewCannotSaveSessionError(err))
return
}
c.Redirect(http.StatusFound, location)
}
func intParam(c *gin.Context, name string, defaultValue int) int {
if input := c.Query(name); input != "" {
value, err := strconv.Atoi(input)
if err == nil && value >= 0 {
return value
}
}
return defaultValue
}

171
web/handler_actor.go Normal file
View File

@ -0,0 +1,171 @@
package web
import (
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ngerakines/tavern/storage"
)
func (h handler) actorInfo(c *gin.Context) {
name := c.Param("name")
user, err := h.storage.GetUserByName(c.Request.Context(), name)
if err != nil {
h.logger.Error("unable to get user by name", zap.Error(err), zap.String("name", name))
c.AbortWithStatus(http.StatusNotFound)
return
}
actorID := storage.NewActorID(user.Name, h.domain)
h.logger.Debug("User info request received", zap.String("user", user.Name))
j := storage.EmptyPayload()
j["@context"] = "https://www.w3.org/ns/activitystreams"
j["id"] = actorID
j["inbox"] = actorID.Inbox()
j["outbox"] = actorID.Outbox()
j["name"] = user.DisplayName
j["preferredUsername"] = user.Name
j["summary"] = ""
j["type"] = "Person"
j["url"] = actorID
j["followers"] = actorID.Followers()
j["following"] = actorID.Following()
k := storage.EmptyPayload()
k["id"] = actorID.MainKey()
k["owner"] = actorID
k["publicKeyPem"] = user.PublicKey
j["publicKey"] = k
h.writeActivityJsonWithProfile(c, j)
}
func (h handler) actorFollowers(c *gin.Context) {
name := c.Param("name")
user, err := h.storage.GetUserByName(c.Request.Context(), name)
if err != nil {
h.logger.Error("unable to get user by name", zap.Error(err), zap.String("name", name))
c.AbortWithStatus(http.StatusNotFound)
return
}
total, err := h.storage.RowCount(c.Request.Context(), `SELECT COUNT(*) FROM followers WHERE user_id = $1`, user.ID)
if err != nil {
h.logger.Error("unable to count followers", zap.Error(err), zap.String("name", name))
c.AbortWithStatus(http.StatusNotFound)
return
}
j := storage.EmptyPayload()
j["@context"] = "https://www.w3.org/ns/activitystreams"
actorID := storage.NewActorID(user.Name, h.domain)
page := intParam(c, "page", 0)
if page == 0 {
j["id"] = actorID.Followers()
j["type"] = "OrderedCollection"
j["totalItems"] = total
j["first"] = actorID.FollowersPage(1)
h.writeJSONLD(c, j)
return
}
offset := (page - 1) * 20
actors, err := h.storage.GetUserFollowers(c.Request.Context(), user.ID, 20, offset)
if err != nil {
h.logger.Error("unable to get followers", zap.Error(err), zap.String("name", name))
c.AbortWithStatus(http.StatusNotFound)
return
}
actorIDs := make([]string, len(actors))
for i, actor := range actors {
actorIDs[i] = actor.Actor
}
j["id"] = actorID.FollowersPage(1)
j["type"] = "OrderedCollectionPage"
j["totalItems"] = total
j["partOf"] = actorID.Followers()
if offset < total {
j["next"] = actorID.FollowersPage(page + 1)
}
if page > 1 {
j["prev"] = actorID.FollowersPage(page - 1)
}
j["orderedItems"] = actorIDs
h.writeJSONLD(c, j)
}
func (h handler) actorFollowing(c *gin.Context) {
name := c.Param("name")
user, err := h.storage.GetUserByName(c.Request.Context(), name)
if err != nil {
h.logger.Error("unable to get user by name", zap.Error(err), zap.String("name", name))
c.AbortWithStatus(http.StatusNotFound)
return
}
total, err := h.storage.RowCount(c.Request.Context(), `SELECT COUNT(*) FROM following WHERE user_id = $1`, user.ID)
if err != nil {
h.logger.Error("unable to count following", zap.Error(err), zap.String("name", name))
c.AbortWithStatus(http.StatusNotFound)
return
}
j := storage.EmptyPayload()
j["@context"] = "https://www.w3.org/ns/activitystreams"
actorID := storage.NewActorID(user.Name, h.domain)
page := intParam(c, "page", 0)
if page == 0 {
j["id"] = actorID.Following()
j["type"] = "OrderedCollection"
j["totalItems"] = total
j["first"] = actorID.FollowingPage(1)
h.writeJSONLD(c, j)
return
}
offset := (page - 1) * 20
actors, err := h.storage.GetUserFollowing(c.Request.Context(), user.ID, 20, offset)
if err != nil {
h.logger.Error("unable to get following", zap.Error(err), zap.String("name", name))
c.AbortWithStatus(http.StatusNotFound)
return
}
actorIDs := make([]string, len(actors))
for i, actor := range actors {
actorIDs[i] = actor.Actor
}
j["id"] = actorID.FollowersPage(1)
j["type"] = "OrderedCollectionPage"
j["totalItems"] = total
j["partOf"] = actorID.Following()
if offset < total {
j["next"] = actorID.FollowingPage(page + 1)
}
if page > 1 {
j["prev"] = actorID.FollowingPage(page - 1)
}
j["orderedItems"] = actorIDs
h.writeJSONLD(c, j)
}

500
web/handler_actor_inbox.go Normal file
View File

@ -0,0 +1,500 @@
package web
import (
"context"
"fmt"
"io"
"io/ioutil"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/yukimochi/httpsig"
"go.uber.org/zap"
"github.com/ngerakines/tavern/fed"
"github.com/ngerakines/tavern/job"
"github.com/ngerakines/tavern/storage"
)
func (h handler) actorInbox(c *gin.Context) {
name := c.Param("name")
user, err := h.storage.GetUserByName(c.Request.Context(), name)
if err != nil {
h.logger.Error("unable to get user by name", zap.Error(err), zap.String("name", name))
c.AbortWithStatus(http.StatusNotFound)
return
}
defer c.Request.Body.Close()
body, err := ioutil.ReadAll(io.LimitReader(c.Request.Body, 1*1024*1024))
if err != nil {
h.logger.Error("unable to slurp request body into payload", zap.Error(err))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
payload, err := storage.PayloadFromBytes(body)
if err != nil {
h.logger.Error("unable to marshall request body into payload", zap.Error(err))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
if skipActorInbox(payload) {
h.logger.Debug("actor inbox can ignore message", zap.String("user", name))
c.Status(http.StatusOK)
return
}
fmt.Println(c.GetHeader("Signature"))
fmt.Println(string(body))
payloadType, _ := storage.JSONString(payload, "type")
actor, _ := storage.JSONString(payload, "actor")
if len(actor) > 0 {
err = h.webFingerQueue.Add(actor)
if err != nil {
h.logger.Error("unable to add actor to web finger queue", zap.String("actor", actor))
}
}
switch payloadType {
case "Ping":
h.logger.Debug("User received ping payload", zap.String("user", name))
h.actorInboxPing(c, user, payload)
case "Pong":
h.logger.Debug("User received pong payload", zap.String("user", name))
h.actorInboxPong(c, user, payload)
case "Follow":
h.logger.Debug("User received follow payload", zap.String("user", name))
h.actorInboxFollow(c, user, payload, body)
case "Undo":
h.logger.Debug("User received undo payload", zap.String("user", name))
h.actorInboxUndo(c, user, payload)
case "Update":
h.logger.Debug("User received update payload", zap.String("user", name))
h.actorInboxUpdate(c, user, payload)
case "Accept":
h.logger.Debug("User received accept payload", zap.String("user", name))
h.actorInboxAccept(c, user, payload, body)
case "Create":
h.logger.Debug("User received accept payload", zap.String("user", name))
h.actorInboxCreate(c, user, payload, body)
case "Announce":
h.logger.Debug("User received announce payload", zap.String("user", name))
h.actorInboxAnnounce(c, user, payload, body)
default:
h.logger.Debug("User received unexpected payload type", zap.String("type", payloadType), zap.String("user", name))
c.Status(http.StatusOK)
}
}
func (h handler) actorInboxPing(c *gin.Context, user *storage.User, payload storage.Payload) {
c.Status(http.StatusOK)
}
func (h handler) actorInboxPong(c *gin.Context, user *storage.User, payload storage.Payload) {
c.Status(http.StatusOK)
}
func (h handler) actorInboxAccept(c *gin.Context, user *storage.User, payload storage.Payload, body []byte) {
if err := h.verifySignature(c); err != nil {
h.hardFail(c, err)
return
}
payloadID, err := storage.ValidateActivity(payload)
if err != nil {
h.hardFail(c, err)
return
}
obj, ok := storage.JSONMap(payload, "object")
if !ok {
h.hardFail(c, fmt.Errorf("object key not present in payload"))
return
}
requestID, ok := storage.JSONString(obj, "id")
if !ok {
h.hardFail(c, fmt.Errorf("id key not present in payload object"))
return
}
target, ok := storage.JSONString(obj, "object")
if !ok {
h.hardFail(c, fmt.Errorf("object key not present in payload object"))
return
}
h.logger.Info("accept received", zap.String("request_activity_id", requestID))
requestActivityID := strings.TrimPrefix(requestID, fmt.Sprintf("https://%s/activity/", h.domain))
c1, err := h.storage.RowCount(c.Request.Context(), `SELECT COUNT(*) FROM following WHERE user_id = $1 AND actor = $2 AND request_activity_id = $3 AND accept_activity_id IS NULL`, user.ID, target, requestActivityID)
if err != nil {
h.hardFail(c, err)
return
}
if c1 == 1 {
incomingActivityID := storage.NewV4()
err = h.storage.RecordActivity(c.Request.Context(), incomingActivityID, payloadID, string(body))
if err != nil {
h.logger.Error("unable to record activity", zap.Error(err))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
err = h.storage.FollowAccept(c.Request.Context(), user.ID, incomingActivityID, target)
if err != nil {
h.hardFail(c, err)
return
}
}
c.Status(http.StatusOK)
}
func (h handler) actorInboxFollow(c *gin.Context, user *storage.User, payload storage.Payload, body []byte) {
ctx := c.Request.Context()
payloadID, err := storage.ValidateActivity(payload)
if err != nil {
h.hardFail(c, err)
return
}
localActor, ok := storage.JSONString(payload, "object")
if !ok {
h.hardFail(c, fmt.Errorf("actor missing from payload"))
return
}
if localActor != fmt.Sprintf("https://%s/users/%s", h.domain, user.Name) {
h.logger.Warn("follow request for unexpected local actor", zap.String("object", localActor))
c.Status(http.StatusOK)
return
}
remoteActorID, ok := storage.JSONString(payload, "actor")
if !ok {
h.hardFail(c, fmt.Errorf("actor missing from payload"))
return
}
actor, err := fed.GetOrFetchActor(ctx, h.storage, h.logger, job.DefaultHTTPClient(), remoteActorID)
if err != nil {
h.hardFail(c, err)
return
}
if err = h.verifySignature(c); err != nil {
h.hardFail(c, err)
return
}
incomingActivityID := storage.NewV4()
err = h.storage.RecordActivity(c.Request.Context(), incomingActivityID, payloadID, string(body))
if err != nil {
h.logger.Error("unable to record activity", zap.Error(err))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
response := storage.EmptyPayload()
acceptActivityID := storage.NewV4()
response["@context"] = "https://www.w3.org/ns/activitystreams"
response["actor"] = storage.NewActorID(user.Name, h.domain)
response["id"] = fmt.Sprintf("https://%s/activity/%s", h.domain, acceptActivityID)
response["object"] = payload
response["to"] = actor.GetID()
response["type"] = "Accept"
response["published"] = time.Now().Format("2006-01-02T15:04:05Z")
responsePayload := response.Bytes()
err = h.storage.RecordActivity(ctx, acceptActivityID, response["id"].(string), string(responsePayload))
if err != nil {
h.hardFail(c, err)
return
}
nc := fed.ActorClient{
HTTPClient: job.DefaultHTTPClient(),
Logger: h.logger,
}
err = nc.SendToInbox(ctx, storage.LocalActor{User: user, ActorID: storage.NewActorID(user.Name, h.domain),}, actor, responsePayload)
if err != nil {
h.hardFail(c, err)
return
}
followerID := storage.NewV4()
err = h.storage.FollowerRequest(ctx, followerID, user.ID, incomingActivityID, acceptActivityID, remoteActorID)
if err != nil {
h.hardFail(c, err)
return
}
c.Status(http.StatusOK)
}
func (h handler) actorInboxUndo(c *gin.Context, user *storage.User, payload storage.Payload) {
c.Status(http.StatusOK)
}
func (h handler) actorInboxUpdate(c *gin.Context, user *storage.User, payload storage.Payload) {
c.Status(http.StatusOK)
}
func (h handler) actorInboxCreate(c *gin.Context, user *storage.User, payload storage.Payload, body []byte) {
payloadID, err := storage.ValidateActivity(payload)
if err != nil {
h.hardFail(c, err)
return
}
isRelevant, err := h.isActivityRelevant(c.Request.Context(), payload, user)
if err != nil {
h.hardFail(c, err)
return
}
if !isRelevant {
h.logger.Info("activity is not relevant to user")
c.Status(http.StatusOK)
}
if err = h.verifySignature(c); err != nil {
h.hardFail(c, err)
return
}
incomingActivityID := storage.NewV4()
err = h.storage.RecordActivity(c.Request.Context(), incomingActivityID, payloadID, string(body))
if err != nil {
h.logger.Error("unable to record activity", zap.Error(err))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
err = h.storage.AddToUserFeed(c.Request.Context(), user.ID, incomingActivityID)
if err != nil {
h.hardFail(c, err)
return
}
c.Status(http.StatusOK)
}
func (h handler) actorInboxAnnounce(c *gin.Context, user *storage.User, payload storage.Payload, body []byte) {
isRelevant, err := h.isActivityRelevant(c.Request.Context(), payload, user)
if err != nil {
h.hardFail(c, err)
return
}
if !isRelevant {
h.logger.Info("activity is not relevant to user")
c.Status(http.StatusOK)
}
if err = h.verifySignature(c); err != nil {
h.hardFail(c, err)
return
}
object, ok := storage.JSONString(payload, "object")
if !ok {
h.hardFail(c, fmt.Errorf("object missing from announce payload"))
return
}
payloadID, err := storage.ValidateActivity(payload)
if err != nil {
h.hardFail(c, err)
return
}
incomingActivityID := storage.NewV4()
err = h.storage.RecordActivity(c.Request.Context(), incomingActivityID, payloadID, string(body))
if err != nil {
h.logger.Error("unable to record activity", zap.Error(err))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
err = h.storage.AddToUserFeed(c.Request.Context(), user.ID, incomingActivityID)
if err != nil {
h.hardFail(c, err)
return
}
localActivityPrefix := fmt.Sprintf("https://%s/activity/", h.domain)
if strings.HasPrefix(object, localActivityPrefix) {
// activity is local, nothing else to do
c.Status(http.StatusOK)
return
}
ac := fed.ActivityClient{
HTTPClient: job.DefaultHTTPClient(),
Logger: h.logger,
}
rawAnnounced, payloadAnnounced, err := ac.Get(object)
if err != nil {
h.hardFail(c, err)
return
}
announcementID, err := storage.ValidateActivity(payloadAnnounced)
if err != nil {
h.hardFail(c, err)
return
}
announcedActivityID := storage.NewV4()
err = h.storage.RecordActivity(c.Request.Context(), announcedActivityID, announcementID, rawAnnounced)
if err != nil {
h.hardFail(c, err)
return
}
allActors := fed.ActorsFromActivity(payloadAnnounced)
for _, activityActor := range allActors {
_, err := fed.GetOrFetchActor(c.Request.Context(), h.storage, h.logger, job.DefaultHTTPClient(), activityActor)
if err != nil {
h.logger.Warn("unable to fetch activity actor", zap.String("actor", activityActor))
}
}
/*
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://mastodon.social/users/ngerakines/statuses/103731083662350899/activity",
"type": "Announce",
"actor": "https://mastodon.social/users/ngerakines",
"published": "2020-02-27T13:38:44Z",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://tavern.ngrok.io/users/nick",
"https://mastodon.social/users/ngerakines/followers"
],
"object": "https://tavern.ngrok.io/activity/0cec0e12-ff08-444b-90ec-d13c2e0449bc",
"atomUri": "https://mastodon.social/users/ngerakines/statuses/103731083662350899/activity",
"signature": {
"type": "RsaSignature2017",
"creator": "https://mastodon.social/users/ngerakines#main-key",
"created": "2020-02-27T13:38:44Z",
"signatureValue": "MVKS4SWKSj3swdmPlpAtrJOPJzHT6xblMASVAbKAyq01ErNcugB5RjjCLhrSKWvI80tYO3SmUtDYiZedbrOPdN947ZTqeECqXtUFNCWMtoBaHqtrkvsKTIeLiZ6zyx8bEKCA/P3RrZGoi6KxeLed7n4b0cZIKBpsstq7CALbP+/5DECs47gOW/cw6He4RbC9JpGRjTmmCF53rmQMgCEng0ixrTfjzqVlDMZpvPgY+BJXne9Y5Y5pzkWglXRTmT/BOQOGqmLtx3aFTB827FdhNE+AbxYMt3ot9dAso9YwdqeNC9ocvNsPwpevEjcedq7/1vVB175FRXluILxzs+J7EA=="
}
}
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://mastodon.social/users/ngerakines/statuses/103731105039051714/activity",
"type": "Announce",
"actor": "https://mastodon.social/users/ngerakines",
"published": "2020-02-27T13:44:11Z",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://mastodon.social/users/aindri",
"https://mastodon.social/users/ngerakines/followers"
],
"object": "https://mastodon.social/users/aindri/statuses/103730835135743634",
"atomUri": "https://mastodon.social/users/ngerakines/statuses/103731105039051714/activity",
"signature": {
"type": "RsaSignature2017",
"creator": "https://mastodon.social/users/ngerakines#main-key",
"created": "2020-02-27T13:44:11Z",
"signatureValue": "FXs2qNTs9FmRyIId/ywuOTOARhureFOA3K/1nqXsq7HGvcHaZ9mPxpBaXxFxdO/wAPoBvVJpidPFc5dOMoNTC4fhfAG3VmDZYlFTjJBqK5UqEHqu0xHzzbmeA65QBdwYryUxccgs6uJ0tcIBvgrNIHYOeEub1jpT153dgi5YcSpvQegafpuBAkEdP74RXXEsfyTH1WSQ/57FVaA4eZQhg5gm53HT/eEMuzk9PbyEwW4vbvlzrWC6gVnFaS1kQzQfJk1zZTDmu/Ee/SyQrS4ZIHY2SbFW/zzKpCyuX4Li3fqqzVCfBDe0nl2p5YY3E/5RxsZONIt6HOEk8sVTryJhIg=="
}
}
*/
c.Status(http.StatusOK)
}
func skipActorInbox(j storage.Payload) bool {
t, _ := storage.JSONString(j, "type")
a, _ := storage.JSONString(j, "actor")
o, _ := storage.JSONString(j, "object")
if t == "Delete" && a == o {
return true
}
return false
}
func (h handler) isActivityRelevant(ctx context.Context, activity storage.Payload, user *storage.User) (bool, error) {
actor, ok := storage.JSONString(activity, "actor")
if ok {
isFollowing, err := h.storage.IsFollowing(ctx, user.ID, actor)
if err != nil {
return false, err
}
if isFollowing {
return true, nil
}
}
actorID := storage.NewActorID(user.Name, h.domain)
obj, ok := storage.JSONMap(activity, "object")
if ok {
to, ok := storage.JSONStrings(obj, "to")
if ok {
for _, i := range to {
if i == string(actorID) {
return true, nil
}
}
}
cc, ok := storage.JSONStrings(obj, "cc")
if ok {
for _, i := range cc {
if i == string(actorID) {
return true, nil
}
}
}
conversation, ok := storage.JSONString(obj, "conversation")
if ok {
if isWatching, err := h.storage.PartOfThread(ctx, user.ID, conversation); err == nil && isWatching {
return true, nil
}
}
tag, ok := storage.JSONMapList(obj, "tag")
if ok {
for _, i := range tag {
tagType, hasTagType := storage.JSONString(i, "type")
href, hasHref := storage.JSONString(i, "href")
if hasTagType && hasHref && tagType == "Mention" && href == string(actorID) {
return true, nil
}
}
}
}
return false, nil
}
func (h handler) verifySignature(c *gin.Context) error {
// Host header isn't set for some reason.
c.Request.Header.Add("Host", c.Request.Host)
verifier, err := httpsig.NewVerifier(c.Request)
if err != nil {
return err
}
key, err := h.storage.GetKey(c.Request.Context(), verifier.KeyId())
if err != nil {
return err
}
publicKey, err := key.GetDecodedPublicKey()
if err != nil {
return err
}
if err = verifier.Verify(publicKey, httpsig.RSA_SHA256); err != nil {
return err
}
return nil
}

79
web/handler_api_v1.go Normal file
View File

@ -0,0 +1,79 @@
package web
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/ngerakines/tavern/g"
"github.com/ngerakines/tavern/storage"
)
func (h handler) apiV1Instance(c *gin.Context) {
user, err := h.storage.GetUserByName(c.Request.Context(), h.adminUser)
if err != nil {
h.hardFail(c, err)
return
}
followers, err := h.storage.RowCount(c.Request.Context(), `SELECT COUNT(*) FROM followers WHERE user_id = $1`, user.ID)
if err != nil {
h.hardFail(c, err)
return
}
following, err := h.storage.RowCount(c.Request.Context(), `SELECT COUNT(*) FROM following WHERE user_id = $1`, user.ID)
if err != nil {
h.hardFail(c, err)
return
}
users, err := h.storage.RowCount(c.Request.Context(), `SELECT COUNT(*) FROM users`)
if err != nil {
h.hardFail(c, err)
return
}
c.JSON(http.StatusOK, map[string]interface{}{
"uri": h.domain,
"title": "Tavern",
"short_description": "An ActivityPub server.",
"description": "An ActivityPub server.",
"email": fmt.Sprintf("admin@%s", h.domain),
"version": g.Version(),
"urls": map[string]interface{}{},
"stats": map[string]interface{}{
"user_count": users,
"status_count": 0,
"domain_count": 0,
},
"thumbnail": fmt.Sprintf("https://%s/public/server_thumbnail.png", h.domain),
"languages": []string{"en"},
"registrations": false,
"approval_required": true,
"contact_account": map[string]interface{}{
"id": user.ID.String(),
"username": user.Name,
"acct": user.Name,
"display_name": user.DisplayName,
"locked": false,
"bot": false,
"discoverable": true,
"group": false,
"created_at": user.CreatedAt,
"note": user.About,
"url": storage.NewActorID(user.Name, h.domain),
"avatar": fmt.Sprintf("https://%s/avatars/%s", h.domain, user.Name),
"avatar_static": fmt.Sprintf("https://%s/avatars/%s", h.domain, user.Name),
"followers_count": followers,
"following_count": following,
"statuses_count": 0,
"last_status_at": time.Now().Format("2006-01-02"),
"fields": []string{},
},
})
}
func (h handler) apiV1InstancePeers(c *gin.Context) {
c.JSON(http.StatusOK, []string{
"mastodon.social",
})
}

48
web/handler_configure.go Normal file
View File

@ -0,0 +1,48 @@
package web
import (
"net/http"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/ngerakines/tavern/errors"
)
func (h handler) configure(c *gin.Context) {
session := sessions.Default(c)
ctx := c.Request.Context()
trans, transOK := c.Get("trans")
if !transOK {
panic("trans not found in context")
}
data := gin.H{
"flashes": getFlashes(session),
"Trans": trans,
}
user, err := h.storage.GetUserBySession(ctx, session)
if err != nil {
if errors.Is(err, errors.UserSessionNotFoundError{}) {
if err = appendFlashError(session, "Not Authenticated"); err != nil {
h.hardFail(c, errors.NewCannotSaveSessionError(err))
return
}
c.Redirect(http.StatusFound, "/")
return
}
h.hardFail(c, err)
return
}
data["user"] = user
if err = session.Save(); err != nil {
h.hardFail(c, err)
return
}
c.HTML(http.StatusOK, "configure", data)
}

88
web/handler_dashboard.go Normal file
View File

@ -0,0 +1,88 @@
package web
import (
"math"
"net/http"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
"github.com/ngerakines/tavern/errors"
)
func (h handler) dashboard(c *gin.Context) {
session := sessions.Default(c)
ctx := c.Request.Context()
trans, transOK := c.Get("trans")
if !transOK {
panic("trans not found in context")
}
data := gin.H{
"flashes": getFlashes(session),
"Trans": trans,
}
user, err := h.storage.GetUserBySession(ctx, session)
if err != nil {
if errors.Is(err, errors.UserSessionNotFoundError{}) {
if err = appendFlashError(session, "Not Authenticated"); err != nil {
h.hardFail(c, errors.NewCannotSaveSessionError(err))
return
}
c.Redirect(http.StatusFound, "/")
return
}
h.hardFail(c, err)
return
}
data["user"] = user
page := intParam(c, "page", 1)
limit := intParam(c, "limit", 100)
total, err := h.storage.RowCount(ctx, `SELECT COUNT(*) FROM user_feed WHERE user_id = $1`, user.ID)
if err != nil {
h.hardFail(c, err)
return
}
uf, err := h.storage.PaginateUserFeed(ctx, user.ID, limit, (page-1)*limit)
if err != nil {
h.hardFail(c, err)
return
}
pageCount := math.Ceil(float64(total) / float64(limit))
data["page_count"] = int(pageCount)
data["page"] = page
data["limit"] = limit
var activityIDs []uuid.UUID
for _, ufi := range uf {
activityIDs = append(activityIDs, ufi.ActivityID)
}
activities, err := h.storage.GetActivitiesByIDs(ctx, activityIDs)
if err != nil {
h.hardFail(c, err)
return
}
data["activities"] = activities
var pages []int
for i := page - 3; i <= page+3; i++ {
if i > 0 && i <= int(pageCount) {
pages = append(pages, i)
}
}
data["pages"] = pages
if err = session.Save(); err != nil {
h.hardFail(c, err)
return
}
c.HTML(http.StatusOK, "dashboard", data)
}

91
web/handler_home.go Normal file
View File

@ -0,0 +1,91 @@
package web
import (
"net/http"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/ngerakines/tavern/errors"
)
func (h handler) home(c *gin.Context) {
session := sessions.Default(c)
ctx := c.Request.Context()
trans, transOK := c.Get("trans")
if !transOK {
panic("trans not found in context")
}
data := gin.H{
"flashes": getFlashes(session),
"authenticated": false,
"Trans": trans,
}
if c.Query("landing") == "true" {
c.HTML(http.StatusOK, "index", data)
return
}
_, err := h.storage.GetUserBySession(ctx, session)
if err == nil {
c.Redirect(http.StatusFound, "/dashboard")
return
}
if errors.Is(err, errors.UserSessionNotFoundError{}) {
if err := session.Save(); err != nil {
h.hardFail(c, errors.NewCannotSaveSessionError(err))
return
}
c.HTML(http.StatusOK, "index", data)
return
}
h.hardFail(c, err)
}
func (h handler) signin(c *gin.Context) {
session := sessions.Default(c)
ctx := c.Request.Context()
email := c.PostForm("email")
password := c.PostForm("password")
userID, err := h.storage.AuthenticateUser(ctx, email, []byte(password))
if err != nil {
h.hardFail(c, err)
return
}
user, err := h.storage.GetUser(ctx, userID)
if err != nil {
h.hardFail(c, err)
return
}
err = h.storage.UpdateUserLastAuth(ctx, userID)
if err != nil {
h.hardFail(c, err)
return
}
c.SetCookie("lang", user.Locale, 0, "/", h.domain, true, true)
session.Set(gin.AuthUserKey, userID.String())
if err = session.Save(); err != nil {
h.hardFail(c, errors.NewCannotSaveSessionError(err))
return
}
c.Redirect(http.StatusFound, "/")
}
func (h handler) signout(c *gin.Context) {
session := sessions.Default(c)
session.Clear()
if err := session.Save(); err != nil {
h.hardFail(c, errors.NewCannotSaveSessionError(err))
return
}
c.Redirect(http.StatusFound, "/")
}

151
web/handler_network.go Normal file
View File

@ -0,0 +1,151 @@
package web
import (
"fmt"
"net/http"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/ngerakines/tavern/errors"
"github.com/ngerakines/tavern/fed"
"github.com/ngerakines/tavern/job"
"github.com/ngerakines/tavern/storage"
)
func (h handler) dashboardNetwork(c *gin.Context) {
session := sessions.Default(c)
ctx := c.Request.Context()
trans, transOK := c.Get("trans")
if !transOK {
panic("trans not found in context")
}
data := gin.H{
"flashes": getFlashes(session),
"Trans": trans,
}
user, err := h.storage.GetUserBySession(ctx, session)
if err != nil {
if errors.Is(err, errors.UserSessionNotFoundError{}) {
if err = appendFlashError(session, "Not Authenticated"); err != nil {
h.hardFail(c, errors.NewCannotSaveSessionError(err))
return
}
c.Redirect(http.StatusFound, "/")
return
}
h.hardFail(c, err)
return
}
data["user"] = user
totalFollowers, err := h.storage.RowCount(ctx, `SELECT COUNT(*) FROM followers WHERE user_id = $1`, user.ID)
if err != nil {
h.hardFail(c, err)
return
}
followers, err := h.storage.GetUserFollowers(ctx, user.ID, totalFollowers+1, 0)
if err != nil {
h.hardFail(c, err)
return
}
data["followers"] = followers
totalFollowing, err := h.storage.RowCount(ctx, `SELECT COUNT(*) FROM following WHERE user_id = $1`, user.ID)
if err != nil {
h.hardFail(c, err)
return
}
following, err := h.storage.GetUserFollowing(ctx, user.ID, totalFollowing+1, 0)
if err != nil {
h.hardFail(c, err)
return
}
data["following"] = following
c.HTML(http.StatusOK, "dashboard_network", data)
}
func (h handler) followActor(c *gin.Context) {
session := sessions.Default(c)
ctx := c.Request.Context()
user, err := h.storage.GetUserBySession(ctx, session)
if err != nil {
if errors.Is(err, errors.UserSessionNotFoundError{}) {
if err = appendFlashError(session, "Not Authenticated"); err != nil {
h.hardFail(c, errors.NewCannotSaveSessionError(err))
return
}
c.Redirect(http.StatusFound, "/")
return
}
h.hardFail(c, err)
return
}
actor, err := fed.GetOrFetchActor(ctx, h.storage, h.logger, job.DefaultHTTPClient(), c.PostForm("actor"))
if err != nil {
h.flashErrorOrFail(c, "/dashboard/network", err)
return
}
isFollowing, err := h.storage.IsFollowing(ctx, user.ID, actor.GetID())
if err != nil {
h.flashErrorOrFail(c, "/dashboard/network", err)
return
}
if isFollowing {
c.Redirect(http.StatusFound, "/dashboard/network")
return
}
follow := storage.EmptyPayload()
activityID := storage.NewV4()
follow["@context"] = "https://www.w3.org/ns/activitystreams"
follow["actor"] = storage.NewActorID(user.Name, h.domain)
follow["id"] = fmt.Sprintf("https://%s/activity/%s", h.domain, activityID)
follow["object"] = actor.GetID()
follow["to"] = actor.GetID()
follow["type"] = "Follow"
follow["published"] = time.Now().Format("2006-01-02T15:04:05Z")
payload := follow.Bytes()
fmt.Println(string(payload))
err = h.storage.RecordActivity(ctx, activityID, fmt.Sprintf("https://%s/activity/%s", h.domain, activityID), string(payload))
if err != nil {
h.flashErrorOrFail(c, "/dashboard/network", err)
return
}
nc := fed.ActorClient{
HTTPClient: job.DefaultHTTPClient(),
Logger: h.logger,
}
err = nc.SendToInbox(ctx, storage.LocalActor{User: user, ActorID: storage.NewActorID(user.Name, h.domain),}, actor, payload)
if err != nil {
h.flashErrorOrFail(c, "/dashboard/network", err)
return
}
followID := storage.NewV4()
err = h.storage.FollowRequest(ctx, followID, user.ID, activityID, actor.GetID())
if err != nil {
h.flashErrorOrFail(c, "/dashboard/network", err)
return
}
if err = session.Save(); err != nil {
h.hardFail(c, err)
return
}
c.Redirect(http.StatusFound, "/dashboard/network")
}

148
web/handler_notes.go Normal file
View File

@ -0,0 +1,148 @@
package web
import (
"fmt"
"net/http"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/ngerakines/tavern/errors"
"github.com/ngerakines/tavern/fed"
"github.com/ngerakines/tavern/job"
"github.com/ngerakines/tavern/storage"
)
func (h handler) dashboardNotes(c *gin.Context) {
session := sessions.Default(c)
ctx := c.Request.Context()
trans, transOK := c.Get("trans")
if !transOK {
panic("trans not found in context")
}
data := gin.H{
"flashes": getFlashes(session),
"Trans": trans,
}
user, err := h.storage.GetUserBySession(ctx, session)
if err != nil {
if errors.Is(err, errors.UserSessionNotFoundError{}) {
if err = appendFlashError(session, "Not Authenticated"); err != nil {
h.hardFail(c, errors.NewCannotSaveSessionError(err))
return
}
c.Redirect(http.StatusFound, "/")
return
}
h.hardFail(c, err)
return
}
data["user"] = user
if err = session.Save(); err != nil {
h.hardFail(c, err)
return
}
c.HTML(http.StatusOK, "dashboard_notes", data)
}
func (h handler) createNote(c *gin.Context) {
// "conversation":"tag:mastodon.social,2020-02-26:objectId=156298483:objectType=Conversation"
session := sessions.Default(c)
ctx := c.Request.Context()
user, err := h.storage.GetUserBySession(ctx, session)
if err != nil {
if errors.Is(err, errors.UserSessionNotFoundError{}) {
if err = appendFlashError(session, "Not Authenticated"); err != nil {
h.hardFail(c, errors.NewCannotSaveSessionError(err))
return
}
c.Redirect(http.StatusFound, "/")
return
}
h.hardFail(c, err)
return
}
content := c.PostForm("content")
if len(content) == 0 {
h.flashErrorOrFail(c, "/dashboard/network", fmt.Errorf("note is empty"))
return
}
actor := storage.NewActorID(user.Name, h.domain)
activityID := storage.NewV4()
activityURL := fmt.Sprintf("https://%s/activity/%s", h.domain, activityID)
now := time.Now()
publishedAt := now.Format("2006-01-02T15:04:05Z")
conversation := fmt.Sprintf("tag:%s,%s:objectId=%s:objectType=Conversation", h.domain, now.Format("2006-01-02"), activityID)
createNote := storage.EmptyPayload()
createNote["@context"] = "https://www.w3.org/ns/activitystreams"
createNote["actor"] = actor
createNote["id"] = activityURL
createNote["published"] = publishedAt
createNote["type"] = "Create"
createNote["to"] = []string{
"https://www.w3.org/ns/activitystreams#Public",
actor.Followers(),
}
note := storage.EmptyPayload()
note["attributedTo"] = actor
note["content"] = content
note["context"] = conversation
note["conversation"] = conversation
note["id"] = activityURL
note["published"] = publishedAt
note["summary"] = ""
note["to"] = "https://www.w3.org/ns/activitystreams#Public"
note["type"] = "Note"
note["url"] = activityURL
createNote["object"] = note
payload := createNote.Bytes()
err = h.storage.RecordActivity(ctx, activityID, activityURL, string(payload))
if err != nil {
h.flashErrorOrFail(c, "/dashboard/notes", err)
return
}
err = h.storage.WatchThread(ctx, user.ID, conversation)
if err != nil {
h.flashErrorOrFail(c, "/dashboard/notes", err)
return
}
err = h.storage.AddToUserFeed(ctx, user.ID, activityID)
if err != nil {
h.flashErrorOrFail(c, "/dashboard/notes", err)
return
}
err = h.storage.RecordUserActivity(ctx, storage.NewV4(), user.ID, activityID)
if err != nil {
h.flashErrorOrFail(c, "/dashboard/notes", err)
return
}
nc := fed.ActorClient{
HTTPClient: job.DefaultHTTPClient(),
Logger: h.logger,
}
err = nc.Broadcast(ctx, h.storage, storage.LocalActor{User: user, ActorID: storage.NewActorID(user.Name, h.domain)}, payload)
if err != nil {
h.flashErrorOrFail(c, "/dashboard/notes", err)
return
}
c.Redirect(http.StatusFound, "/dashboard/notes")
}

View File

@ -0,0 +1,66 @@
package web
import (
"io"
"io/ioutil"
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ngerakines/tavern/storage"
)
func (h handler) serverInbox(c *gin.Context) {
// TODO: verify signature
defer c.Request.Body.Close()
body, err := ioutil.ReadAll(io.LimitReader(c.Request.Body, 1*1024*1024))
if err != nil {
h.logger.Error("unable to slurp request body into payload", zap.Error(err))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
payload, err := storage.PayloadFromBytes(body)
if err != nil {
h.logger.Error("unable to marshall request body into payload", zap.Error(err))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
payloadID, err := storage.ValidateActivity(payload)
if err != nil {
h.hardFail(c, err)
return
}
activityID := storage.NewV4()
err = h.storage.RecordActivity(c.Request.Context(), activityID, payloadID, string(body))
if err != nil {
h.logger.Error("unable to record activity", zap.Error(err))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
payloadType, _ := storage.JSONString(payload, "type")
switch payloadType {
case "Follow":
h.logger.Debug("User received ping payload")
h.serverInboxFollow(c, payload)
case "Undo":
h.logger.Debug("User received pong payload")
h.serverInboxUndo(c, payload)
default:
h.logger.Debug("Server received unexpected payload type", zap.String("type", payloadType))
c.Status(http.StatusOK)
}
}
func (h handler) serverInboxFollow(c *gin.Context, payload storage.Payload) {
c.Status(http.StatusOK)
}
func (h handler) serverInboxUndo(c *gin.Context, payload storage.Payload) {
c.Status(http.StatusOK)
}

106
web/handler_wellknown.go Normal file
View File

@ -0,0 +1,106 @@
package web
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ngerakines/tavern/errors"
"github.com/ngerakines/tavern/g"
"github.com/ngerakines/tavern/storage"
)
func (h handler) nodeInfo(c *gin.Context) {
c.JSON(http.StatusOK, map[string]interface{}{
"links": []interface{}{
map[string]interface{}{
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
"href": fmt.Sprintf("https://%s/nodeinfo/2.0", h.domain),
},
},
})
}
func (h handler) nodeInfoDetails(c *gin.Context) {
c.JSON(http.StatusOK, map[string]interface{}{
"version": "2.0",
"software": map[string]interface{}{
"name": "tavern",
"version": g.Version(),
},
"protocols": []string{"activitypub"},
"usage": map[string]interface{}{
"users": map[string]interface{}{
"total": 1,
"activeMonth": 1,
"activeHalfyear": 1,
},
"localPosts": 1,
},
"openRegistrations": false,
})
}
func (h handler) webFinger(c *gin.Context) {
username, domain, err := fingerUserDomain(c.Query("resource"), h.domain)
if err != nil {
h.logger.Error("error parsing resource", zap.Error(err))
c.AbortWithStatus(http.StatusNotFound)
return
}
h.logger.Info("webfinger request", zap.String("resource", c.Query("resource")))
user, err := h.storage.GetUserByName(c.Request.Context(), username)
if err != nil {
// TODO: Handle not found
h.logger.Error("error getting user", zap.Error(err))
c.AbortWithStatus(http.StatusInternalServerError)
return
}
j := storage.EmptyPayload()
j["subject"] = fmt.Sprintf("acct:%s@%s", user.Name, domain)
j["aliases"] = []string{
fmt.Sprintf("https://%s/users/%s", domain, user.Name),
}
l := storage.EmptyPayload()
l["rel"] = "self"
l["type"] = `application/activity+json`
l["href"] = fmt.Sprintf("https://%s/users/%s", domain, user.Name)
j["links"] = []storage.Payload{l}
h.WriteJRD(c, j)
}
func fingerUserDomain(input, domain string) (string, string, error) {
input = strings.TrimPrefix(input, "acct:")
domainPrefix := fmt.Sprintf("https://%s/users/", domain)
if strings.HasPrefix(input, domainPrefix) {
user := strings.TrimPrefix(input, domainPrefix)
return user, domain, nil
}
if input == fmt.Sprintf("https://%s/users/%s") {
}
parts := strings.FieldsFunc(input, func(r rune) bool {
return r == '@'
})
if len(parts) != 2 {
return "", "", errors.New("malformed resource parameter")
}
if parts[1] != domain {
return "", "", errors.New("malformed resource parameter")
}
user := strings.TrimSpace(parts[0])
if len(user) == 0 {
return "", "", errors.New("malformed resource parameter")
}
return user, domain, nil
}

180
web/i18n.go Normal file
View File

@ -0,0 +1,180 @@
package web
import (
"fmt"
"html/template"
"strings"
"github.com/getsentry/sentry-go"
"github.com/gin-gonic/gin"
"github.com/go-playground/locales"
"github.com/go-playground/locales/currency"
ut "github.com/go-playground/universal-translator"
"go.uber.org/zap"
)
type Translator interface {
locales.Translator
T(key interface{}, params ...string) string
THTML(key interface{}, params ...template.HTML) template.HTML
C(key interface{}, num float64, digits uint64, param string) string
O(key interface{}, num float64, digits uint64, param string) string
R(key interface{}, num1 float64, digits1 uint64, num2 float64, digits2 uint64, param1, param2 string) string
Currency() currency.Type
}
type translator struct {
locales.Translator
trans ut.Translator
logger *zap.Logger
enableSentry bool
}
var _ Translator = (*translator)(nil)
func (t *translator) T(key interface{}, params ...string) string {
keyValue := fmt.Sprintf("%s", key)
s, err := t.trans.T(keyValue, params...)
if err != nil {
t.logger.Warn("issue translating key",
zap.String("key", keyValue),
zap.String("locale", t.Locale()),
zap.Error(err))
if t.enableSentry {
hub := sentry.CurrentHub().Clone()
hub.Scope().SetExtras(map[string]interface{}{
"key": keyValue,
"locale": t.Locale(),
"param_count": len(params),
})
hub.CaptureException(err)
}
return keyValue
}
return s
}
func (t *translator) THTML(key interface{}, params ...template.HTML) template.HTML {
var stringParams []string
for _, param := range params {
stringParams = append(stringParams, string(param))
}
s, err := t.trans.T(key, stringParams...)
if err != nil {
t.logger.Warn("issue translating key", zap.String("key", fmt.Sprintf("%v", key)), zap.Error(err))
return template.HTML(fmt.Sprintf("%s", key))
}
return template.HTML(s)
}
func (t *translator) C(key interface{}, num float64, digits uint64, param string) string {
s, err := t.trans.C(key, num, digits, param)
if err != nil {
t.logger.Warn("issue translating cardinal key", zap.String("key", fmt.Sprintf("%v", key)), zap.Error(err))
}
return s
}
func (t *translator) O(key interface{}, num float64, digits uint64, param string) string {
s, err := t.trans.C(key, num, digits, param)
if err != nil {
t.logger.Warn("issue translating ordinal key", zap.String("key", fmt.Sprintf("%v", key)), zap.Error(err))
}
return s
}
func (t *translator) R(key interface{}, num1 float64, digits1 uint64, num2 float64, digits2 uint64, param1, param2 string) string {
s, err := t.trans.R(key, num1, digits1, num2, digits2, param1, param2)
if err != nil {
t.logger.Warn("issue translating range key", zap.String("key", fmt.Sprintf("%v", key)), zap.Error(err))
}
return s
}
func (t *translator) Currency() currency.Type {
switch t.Locale() {
case "en":
return currency.USD
case "fr":
return currency.EUR
default:
return currency.USD
}
}
type i18nMiddleware struct {
utrans *ut.UniversalTranslator
logger *zap.Logger
domain string
enableSentry bool
}
func (h i18nMiddleware) findLocale(c *gin.Context) {
var langs []string
setCookie := false
if localeParam := c.Query("lang"); len(localeParam) > 0 {
setCookie = true
langs = append(langs, localeParam)
}
localeCookie, err := c.Cookie("lang")
if err == nil && len(localeCookie) > 0 {
langs = append(langs, localeCookie)
}
langs = append(langs, AcceptedLanguages(c)...)
langs = append(langs, "en")
t, found := h.utrans.FindTranslator(langs...)
if !found {
h.logger.Warn("no translation found for language", zap.Strings("langs", langs))
}
if localeCookie == "" || t.Locale() != localeCookie {
setCookie = true
}
if setCookie {
c.SetCookie("lang", t.Locale(), 0, "/", h.domain, true, true)
}
c.Set("locale", t.Locale())
c.Set("trans", &translator{
trans: t,
Translator: t.(locales.Translator),
logger: h.logger,
enableSentry: h.enableSentry,
})
c.Next()
}
func localeOrDefault(c *gin.Context) string {
ctxValue, ok := c.Get("locale")
if ok {
if locale, ok := ctxValue.(string); ok {
return locale
}
}
return "en"
}
func AcceptedLanguages(c *gin.Context) (languages []string) {
accepted := c.GetHeader("Accept-Language")
if accepted == "" {
return
}
options := strings.Split(accepted, ",")
l := len(options)
languages = make([]string, l)
for i := 0; i < l; i++ {
locale := strings.SplitN(options[i], ";", 2)
languages[i] = strings.Trim(locale[0], " ")
}
return
}

103
web/view.go Normal file
View File

@ -0,0 +1,103 @@
package web
import (
"database/sql"
"encoding/hex"
"fmt"
"html/template"
"math"
"time"
"github.com/gofrs/uuid"
)
var (
ohioLocal *time.Location
)
func tmplOptionalDate(value sql.NullTime, defaultValue string) string {
if value.Valid {
return tmplDate(value.Time)
}
return defaultValue
}
func tmplDate(value time.Time) string {
return value.In(ohioLocal).Format("02 Jan 2006") // " MST"
}
func tmplOptionalTime(value sql.NullTime, defaultValue string) string {
if value.Valid {
return tmplTime(value.Time)
}
return defaultValue
}
func tmplTime(value time.Time) string {
return value.In(ohioLocal).Format("3:04 PM") // " MST"
}
func tmplOptionalDateTime(value sql.NullTime, defaultValue string) string {
if value.Valid {
return tmplDateTime(value.Time)
}
return defaultValue
}
func tmplDateTime(value time.Time) string {
return value.In(ohioLocal).Format("02 Jan 2006 3:04 PM") // " MST"
}
func tmplShortUUID(id uuid.UUID) string {
return hex.EncodeToString(id.Bytes())
}
func tmplShort(input string, max int) string {
halfMax := int(math.Ceil(float64(max) / 2))
if l := len(input); l > max {
return fmt.Sprintf("%s...%s", input[0:halfMax], input[l-halfMax:])
}
return input
}
func tmplAnchor(url, label string) template.HTML {
return template.HTML(fmt.Sprintf(`<a href="%s">%s</a>`, url, label))
}
func tmplToHTML(input string) template.HTML {
return template.HTML(input)
}
func tmplStrong(args ...string) template.HTML {
if len(args) == 1 {
return template.HTML(fmt.Sprintf(`<strong>%s</strong>`, args[0]))
}
if len(args) == 2 {
return template.HTML(fmt.Sprintf(`<strong class="%s">%s</strong>`, args[0], args[1]))
}
return ""
}
func tmplUrlGen(siteBase string) func(parts ...interface{}) string {
return func(parts ...interface{}) string {
if len(parts) >= 1 {
if controller, ok := parts[0].(string); ok {
switch controller {
case "signin":
return fmt.Sprintf("%s/signin", siteBase)
case "signout":
return fmt.Sprintf("%s/signout", siteBase)
case "dashboard":
return fmt.Sprintf("%s/dashboard", siteBase)
case "network":
return fmt.Sprintf("%s/dashboard/network", siteBase)
case "notes":
return fmt.Sprintf("%s/dashboard/notes", siteBase)
case "configure":
return fmt.Sprintf("%s/configure", siteBase)
}
}
}
return fmt.Sprintf("%s/", siteBase)
}
}