Initial commit
* Initial codebase (lot of work!) * Uses vanilla Go net/http and implements by hand: session cookies backed by Redis; log in/out; CSRF protection; email verification flow; initial database models (User table)
This commit is contained in:
commit
2a8a1df6ab
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
/gosocial
|
||||
database.sqlite
|
||||
settings.json
|
23
Makefile
Normal file
23
Makefile
Normal file
|
@ -0,0 +1,23 @@
|
|||
SHELL := /bin/bash
|
||||
|
||||
VERSION=$(shell egrep -e 'Version\s+=' pkg/branding/branding.go | head -n 1 | cut -d '"' -f 2)
|
||||
BUILD=$(shell git describe --always)
|
||||
BUILD_DATE=$(shell date +"%Y-%m-%dT%H:%M:%S%z")
|
||||
CURDIR=$(shell curdir)
|
||||
|
||||
# Inject the build version (commit hash) into the executable.
|
||||
LDFLAGS := -ldflags "-X main.Build=$(BUILD) -X main.BuildDate=$(BUILD_DATE)"
|
||||
|
||||
all: build
|
||||
|
||||
.PHONY: setup
|
||||
setup:
|
||||
go get ./...
|
||||
|
||||
.PHONY: build
|
||||
build:
|
||||
go build $(LDFLAGS) -o gosocial cmd/gosocial/main.go
|
||||
|
||||
.PHONY: run
|
||||
run:
|
||||
go run cmd/gosocial/main.go --debug
|
46
README.md
Normal file
46
README.md
Normal file
|
@ -0,0 +1,46 @@
|
|||
# gosocial
|
||||
|
||||
## Building
|
||||
|
||||
Use the Makefile:
|
||||
|
||||
* `make setup`: install Go dependencies
|
||||
* `make build`: builds the program to ./gosocial
|
||||
* `make run`: run the app from Go sources in debug mode
|
||||
|
||||
## Configuring
|
||||
|
||||
On first run it will generate a `settings.json` file in the current
|
||||
working directory (which is intended to be the root of the git clone,
|
||||
with the ./web folder). Edit it to configure mail settings or choose
|
||||
a database.
|
||||
|
||||
For simple local development, just set `"UseSQLite": true` and the
|
||||
app will run with a SQLite database.
|
||||
|
||||
## Usage
|
||||
|
||||
The `gosocial` binary has sub-commands to either run the web server
|
||||
or perform maintenance tasks such as creating admin user accounts.
|
||||
|
||||
Run `gosocial --help` for its documentation.
|
||||
|
||||
Run `gosocial web` to start the web server.
|
||||
|
||||
## Create Admin User Accounts
|
||||
|
||||
Use the `gosocial user add` command like so:
|
||||
|
||||
```bash
|
||||
$ gosocial user add --admin \
|
||||
--email name@domain.com \
|
||||
--password secret \
|
||||
--username admin
|
||||
```
|
||||
|
||||
Shorthand options `-e`, `-p` and `-u` can work in place of the longer
|
||||
options `--email`, `--password` and `--username` respectively.
|
||||
|
||||
## License
|
||||
|
||||
GPLv2.
|
173
cmd/gosocial/main.go
Normal file
173
cmd/gosocial/main.go
Normal file
|
@ -0,0 +1,173 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
gosocial "git.kirsle.net/apps/gosocial/pkg"
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||
"git.kirsle.net/apps/gosocial/pkg/redis"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Build-time values.
|
||||
var (
|
||||
Build = "n/a"
|
||||
BuildDate = "n/a"
|
||||
)
|
||||
|
||||
func init() {
|
||||
config.RuntimeVersion = gosocial.Version
|
||||
config.RuntimeBuild = Build
|
||||
config.RuntimeBuildDate = BuildDate
|
||||
}
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
Name: "gosocial",
|
||||
Usage: "a niche social networking webapp",
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "web",
|
||||
Usage: "start the web server",
|
||||
Flags: []cli.Flag{
|
||||
// Debug mode.
|
||||
&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "debug mode (logging and reloading templates)",
|
||||
},
|
||||
|
||||
// HTTP settings.
|
||||
&cli.StringFlag{
|
||||
Name: "host",
|
||||
Aliases: []string{"H"},
|
||||
Value: "0.0.0.0",
|
||||
Usage: "host address to listen on",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "port",
|
||||
Aliases: []string{"P"},
|
||||
Value: 8080,
|
||||
Usage: "port number to listen on",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.Bool("debug") {
|
||||
config.Debug = true
|
||||
log.SetDebug(true)
|
||||
}
|
||||
|
||||
initdb(c)
|
||||
initcache(c)
|
||||
|
||||
log.Debug("Debug logging enabled.")
|
||||
|
||||
app := &gosocial.WebServer{
|
||||
Host: c.String("host"),
|
||||
Port: c.Int("port"),
|
||||
}
|
||||
|
||||
return app.Run()
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user",
|
||||
Usage: "manage user accounts such as to create admins",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "add",
|
||||
Usage: "add a new user account",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "username",
|
||||
Aliases: []string{"u"},
|
||||
Required: true,
|
||||
Usage: "username, case insensitive",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "email",
|
||||
Aliases: []string{"e"},
|
||||
Required: true,
|
||||
Usage: "email address",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Aliases: []string{"p"},
|
||||
Required: true,
|
||||
Usage: "set user password",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "admin",
|
||||
Usage: "set admin status",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
initdb(c)
|
||||
|
||||
log.Info("Creating user account: %s", c.String("username"))
|
||||
user, err := models.CreateUser(
|
||||
c.String("username"),
|
||||
c.String("email"),
|
||||
c.String("password"),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Making an admin?
|
||||
if c.Bool("admin") {
|
||||
log.Warn("Promoting user to admin status")
|
||||
user.IsAdmin = true
|
||||
user.Save()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func initdb(c *cli.Context) {
|
||||
// Load the settings.json
|
||||
config.LoadSettings()
|
||||
|
||||
// Initialize the database.
|
||||
log.Info("Initializing DB")
|
||||
if config.Current.Database.IsSQLite {
|
||||
db, err := gorm.Open(sqlite.Open(config.Current.Database.SQLite), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("failed to open SQLite DB")
|
||||
}
|
||||
models.DB = db
|
||||
} else if config.Current.Database.IsPostgres {
|
||||
db, err := gorm.Open(postgres.Open(config.Current.Database.Postgres), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to open Postgres DB: %s", err))
|
||||
}
|
||||
models.DB = db
|
||||
} else {
|
||||
log.Fatal("A choice of SQL database is required.")
|
||||
}
|
||||
|
||||
// Auto-migrate the DB.
|
||||
models.AutoMigrate()
|
||||
}
|
||||
|
||||
func initcache(c *cli.Context) {
|
||||
// Initialize Redis.
|
||||
log.Info("Initializing Redis")
|
||||
redis.Setup(c.String("redis"))
|
||||
}
|
44
go.mod
Normal file
44
go.mod
Normal file
|
@ -0,0 +1,44 @@
|
|||
module git.kirsle.net/apps/gosocial
|
||||
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/urfave/cli/v2 v2.11.1
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
|
||||
gorm.io/driver/postgres v1.3.8
|
||||
gorm.io/driver/sqlite v1.3.6
|
||||
gorm.io/gorm v1.23.8
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/go-redis/redis v6.15.9+incompatible // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||
github.com/jackc/pgconn v1.12.1 // indirect
|
||||
github.com/jackc/pgio v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgproto3/v2 v2.3.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
|
||||
github.com/jackc/pgtype v1.11.0 // indirect
|
||||
github.com/jackc/pgx/v4 v4.16.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.14 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.19 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect
|
||||
)
|
224
go.sum
Normal file
224
go.sum
Normal file
|
@ -0,0 +1,224 @@
|
|||
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b h1:TDxEEWOJqMzsu9JW8/QgmT1lgQ9WD2KWlb2lKN/Ql2o=
|
||||
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b/go.mod h1:jl+Qr58W3Op7OCxIYIT+b42jq8xFncJXzPufhrvza7Y=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
|
||||
github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
|
||||
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
|
||||
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
|
||||
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
|
||||
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
|
||||
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
|
||||
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
|
||||
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
|
||||
github.com/jackc/pgconn v1.12.1 h1:rsDFzIpRk7xT4B8FufgpCCeyjdNpKyghZeSefViE5W8=
|
||||
github.com/jackc/pgconn v1.12.1/go.mod h1:ZkhRC59Llhrq3oSfrikvwQ5NaxYExr6twkdkMLaKono=
|
||||
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
||||
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
||||
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
|
||||
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
|
||||
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
|
||||
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.3.0 h1:brH0pCGBDkBW07HWlN/oSBXrmo3WB0UvZd1pIuDcL8Y=
|
||||
github.com/jackc/pgproto3/v2 v2.3.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
||||
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
|
||||
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
|
||||
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
|
||||
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
|
||||
github.com/jackc/pgtype v1.11.0 h1:u4uiGPz/1hryuXzyaBhSk6dnIyyG2683olG2OV+UUgs=
|
||||
github.com/jackc/pgtype v1.11.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
|
||||
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
|
||||
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
|
||||
github.com/jackc/pgx/v4 v4.16.1 h1:JzTglcal01DrghUqt+PmzWsZx/Yh7SC/CTQmSBMTd0Y=
|
||||
github.com/jackc/pgx/v4 v4.16.1/go.mod h1:SIhx0D5hoADaiXZVyv+3gSm3LCIIINTVO0PficsvWGQ=
|
||||
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.2.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
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/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
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.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw=
|
||||
github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/microcosm-cc/bluemonday v1.0.19 h1:OI7hoF5FY4pFz2VA//RN8TfM0YJ2dJcl4P4APrCWy6c=
|
||||
github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
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/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e h1:Ee+VZw13r9NTOMnwTPs6O5KZ0MJU54hsxu9FpZ4pQ10=
|
||||
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e/go.mod h1:fSIW/szJHsRts/4U8wlMPhs+YqJC+7NYR+Qqb1uJVpA=
|
||||
github.com/urfave/cli/v2 v2.11.1 h1:UKK6SP7fV3eKOefbS87iT9YHefv7iB/53ih6e+GNAsE=
|
||||
github.com/urfave/cli/v2 v2.11.1/go.mod h1:f8iq5LtQ/bLxafbdBSLPPNsgaW0l/2fYYEHhAyPlwvo=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
||||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
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/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/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-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/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-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/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-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
||||
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.3.8 h1:8bEphSAB69t3odsCR4NDzt581iZEWQuRM27Cg6KgfPY=
|
||||
gorm.io/driver/postgres v1.3.8/go.mod h1:qB98Aj6AhRO/oyu/jmZsi/YM9g6UzVCjMxO/6frFvcA=
|
||||
gorm.io/driver/sqlite v1.3.6 h1:Fi8xNYCUplOqWiPa3/GuCeowRNBRGTf62DEmhMDHeQQ=
|
||||
gorm.io/driver/sqlite v1.3.6/go.mod h1:Sg1/pvnKtbQ7jLXxfZa+jSHvoX8hoZA8cn4xllOMTgE=
|
||||
gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.23.6/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.23.8 h1:h8sGJ+biDgBA1AD1Ha9gFCx7h8npU7AsLdlkX0n2TpE=
|
||||
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
51
pkg/config/config.go
Normal file
51
pkg/config/config.go
Normal file
|
@ -0,0 +1,51 @@
|
|||
// Package config holds some (mostly static) configuration for the app.
|
||||
package config
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Branding
|
||||
const (
|
||||
Title = "gosocial"
|
||||
Subtitle = "A purpose built social networking app."
|
||||
)
|
||||
|
||||
// Paths and layouts
|
||||
const (
|
||||
TemplatePath = "./web/templates"
|
||||
StaticPath = "./web/static"
|
||||
SettingsPath = "./settings.json"
|
||||
)
|
||||
|
||||
// Security
|
||||
const (
|
||||
BcryptCost = 14
|
||||
SessionCookieName = "session_id"
|
||||
CSRFCookieName = "csrf_token"
|
||||
CSRFInputName = "_csrf" // html input name
|
||||
SessionCookieMaxAge = 60 * 60 * 24 * 30
|
||||
SessionRedisKeyFormat = "session/%s"
|
||||
)
|
||||
|
||||
// Authentication
|
||||
const (
|
||||
// Skip the email verification step. The signup page will directly ask for
|
||||
// email+username+password rather than only email and needing verification.
|
||||
SkipEmailVerification = false
|
||||
SignupTokenRedisKey = "signup-token/%s"
|
||||
SignupTokenExpires = 24 * time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
UsernameRegexp = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
|
||||
)
|
||||
|
||||
// Variables set by main.go to make them readily available.
|
||||
var (
|
||||
RuntimeVersion string
|
||||
RuntimeBuild string
|
||||
RuntimeBuildDate string
|
||||
Debug bool // app is in debug mode
|
||||
)
|
104
pkg/config/variable.go
Normal file
104
pkg/config/variable.go
Normal file
|
@ -0,0 +1,104 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
)
|
||||
|
||||
// Current loaded settings.json
|
||||
var Current = DefaultVariable()
|
||||
|
||||
// Variable configuration attributes (loaded from settings.json).
|
||||
type Variable struct {
|
||||
BaseURL string
|
||||
Mail Mail
|
||||
Redis Redis
|
||||
Database Database
|
||||
}
|
||||
|
||||
// DefaultVariable returns the default settings.json data.
|
||||
func DefaultVariable() Variable {
|
||||
return Variable{
|
||||
BaseURL: "http://localhost:8080",
|
||||
Mail: Mail{
|
||||
Enabled: false,
|
||||
Host: "localhost",
|
||||
Port: 25,
|
||||
From: "no-reply@localhost",
|
||||
},
|
||||
Redis: Redis{
|
||||
Host: "localhost",
|
||||
Port: 6379,
|
||||
},
|
||||
Database: Database{
|
||||
SQLite: "database.sqlite",
|
||||
Postgres: "host=localhost user=gosocial password=gosocial dbname=gosocial port=5679 sslmode=disable TimeZone=America/Los_Angeles",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadSettings loads the settings.json file or, if not existing, creates it with the default settings.
|
||||
func LoadSettings() {
|
||||
if _, err := os.Stat(SettingsPath); !os.IsNotExist(err) {
|
||||
log.Info("Loading settings from %s", SettingsPath)
|
||||
content, err := ioutil.ReadFile(SettingsPath)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("LoadSettings: couldn't read settings.json: %s", err))
|
||||
}
|
||||
|
||||
var v Variable
|
||||
err = json.Unmarshal(content, &v)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("LoadSettings: couldn't parse settings.json: %s", err))
|
||||
}
|
||||
|
||||
Current = v
|
||||
} else {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetIndent("", " ")
|
||||
err := enc.Encode(DefaultVariable())
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("LoadSettings: couldn't marshal default settings: %s", err))
|
||||
}
|
||||
|
||||
ioutil.WriteFile(SettingsPath, buf.Bytes(), 0600)
|
||||
log.Warn("NOTICE: Created default settings.json file - review it and configure mail servers and database!")
|
||||
}
|
||||
|
||||
// If there is no DB configured, exit now.
|
||||
if !Current.Database.IsSQLite && !Current.Database.IsPostgres {
|
||||
log.Error("No database configured in settings.json. Choose SQLite or Postgres and update the DB connector string!")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Mail settings.
|
||||
type Mail struct {
|
||||
Enabled bool
|
||||
Host string // localhost
|
||||
Port int // 25
|
||||
From string // noreply@localhost
|
||||
Username string // SMTP credentials
|
||||
Password string
|
||||
}
|
||||
|
||||
// Redis settings.
|
||||
type Redis struct {
|
||||
Host string
|
||||
Port int
|
||||
DB int
|
||||
}
|
||||
|
||||
// Database settings.
|
||||
type Database struct {
|
||||
IsSQLite bool
|
||||
IsPostgres bool
|
||||
SQLite string
|
||||
Postgres string
|
||||
}
|
20
pkg/controller/account/dashboard.go
Normal file
20
pkg/controller/account/dashboard.go
Normal file
|
@ -0,0 +1,20 @@
|
|||
package account
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||
)
|
||||
|
||||
// User dashboard or landing page (/me).
|
||||
func Dashboard() http.HandlerFunc {
|
||||
tmpl := templates.Must("account/dashboard.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Error("Dashboard called")
|
||||
if err := tmpl.Execute(w, r, nil); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
66
pkg/controller/account/login.go
Normal file
66
pkg/controller/account/login.go
Normal file
|
@ -0,0 +1,66 @@
|
|||
package account
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||
)
|
||||
|
||||
// Login controller.
|
||||
func Login() http.HandlerFunc {
|
||||
tmpl := templates.Must("account/login.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Posting?
|
||||
if r.Method == http.MethodPost {
|
||||
var (
|
||||
// Collect form fields.
|
||||
username = strings.ToLower(r.PostFormValue("username"))
|
||||
password = r.PostFormValue("password")
|
||||
)
|
||||
|
||||
// Look up their account.
|
||||
user, err := models.FindUser(username)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Incorrect username or password.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
log.Warn("err: %+v user: %+v", err, user)
|
||||
|
||||
// Verify password.
|
||||
if err := user.CheckPassword(password); err != nil {
|
||||
session.FlashError(w, r, "Incorrect username or password.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// OK. Log in the user's session.
|
||||
session.LoginUser(w, r, user)
|
||||
|
||||
// Redirect to their dashboard.
|
||||
session.Flash(w, r, "Login successful.")
|
||||
templates.Redirect(w, "/me")
|
||||
return
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, nil); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Logout controller.
|
||||
func Logout() http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session.Flash(w, r, "You have been successfully logged out.")
|
||||
session.LogoutUser(w, r)
|
||||
templates.Redirect(w, "/")
|
||||
})
|
||||
}
|
193
pkg/controller/account/signup.go
Normal file
193
pkg/controller/account/signup.go
Normal file
|
@ -0,0 +1,193 @@
|
|||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
nm "net/mail"
|
||||
"strings"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"git.kirsle.net/apps/gosocial/pkg/mail"
|
||||
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||
"git.kirsle.net/apps/gosocial/pkg/redis"
|
||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SignupToken goes in Redis when the user first gives us their email address. They
|
||||
// verify their email before signing up, so cache only in Redis until verified.
|
||||
type SignupToken struct {
|
||||
Email string
|
||||
Token string
|
||||
}
|
||||
|
||||
// Delete a SignupToken when it's been used up.
|
||||
func (st SignupToken) Delete() error {
|
||||
return redis.Delete(fmt.Sprintf(config.SignupTokenRedisKey, st.Token))
|
||||
}
|
||||
|
||||
// Initial signup controller.
|
||||
func Signup() http.HandlerFunc {
|
||||
tmpl := templates.Must("account/signup.html")
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Template vars.
|
||||
var vars = map[string]interface{}{
|
||||
"SignupToken": "", // non-empty if user has clicked verification link
|
||||
"SkipEmailVerification": false, // true if email verification is disabled
|
||||
"Email": "", // pre-filled user email
|
||||
}
|
||||
|
||||
// Is email verification disabled?
|
||||
if config.SkipEmailVerification {
|
||||
vars["SkipEmailVerification"] = true
|
||||
}
|
||||
|
||||
// Are we called with an email verification token?
|
||||
var tokenStr = r.URL.Query().Get("token")
|
||||
if r.Method == http.MethodPost {
|
||||
tokenStr = r.PostFormValue("token")
|
||||
}
|
||||
|
||||
var token SignupToken
|
||||
log.Info("SignupToken: %s", tokenStr)
|
||||
if tokenStr != "" {
|
||||
// Validate it.
|
||||
if err := redis.Get(fmt.Sprintf(config.SignupTokenRedisKey, tokenStr), &token); err != nil || token.Token != tokenStr {
|
||||
session.FlashError(w, r, "Invalid email verification token. Please try signing up again.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
vars["SignupToken"] = tokenStr
|
||||
vars["Email"] = token.Email
|
||||
}
|
||||
log.Info("Vars: %+v", vars)
|
||||
|
||||
// Posting?
|
||||
if r.Method == http.MethodPost {
|
||||
var (
|
||||
// Collect form fields.
|
||||
email = strings.TrimSpace(strings.ToLower(r.PostFormValue("email")))
|
||||
confirm = r.PostFormValue("confirm") == "true"
|
||||
|
||||
// Only on full signup form
|
||||
username = strings.TrimSpace(strings.ToLower(r.PostFormValue("username")))
|
||||
password = strings.TrimSpace(r.PostFormValue("password"))
|
||||
password2 = strings.TrimSpace(r.PostFormValue("password2"))
|
||||
)
|
||||
|
||||
// Don't let them sneakily change their verified email address on us.
|
||||
if vars["SignupToken"] != "" && email != vars["Email"] {
|
||||
session.FlashError(w, r, "This email address is not verified. Please start over from the beginning.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// Cache username in case of passwd validation errors.
|
||||
vars["Email"] = email
|
||||
vars["Username"] = username
|
||||
|
||||
// Is the app not configured to send email?
|
||||
if !config.Current.Mail.Enabled {
|
||||
session.FlashError(w, r, "This app is not configured to send email so you can not sign up at this time. "+
|
||||
"Please contact the website administrator about this issue!")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the email.
|
||||
if _, err := nm.ParseAddress(email); err != nil {
|
||||
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// Didn't confirm?
|
||||
if !confirm {
|
||||
session.FlashError(w, r, "Confirm that you have read the rules.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// Already an account?
|
||||
if _, err := models.FindUser(email); err == nil {
|
||||
session.FlashError(w, r, "There is already an account with that e-mail address.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// Email verification step!
|
||||
if !config.SkipEmailVerification && vars["SignupToken"] == "" {
|
||||
// Create a SignupToken verification link to send to their inbox.
|
||||
token = SignupToken{
|
||||
Email: email,
|
||||
Token: uuid.New().String(),
|
||||
}
|
||||
if err := redis.Set(fmt.Sprintf(config.SignupTokenRedisKey, token.Token), token, config.SignupTokenExpires); err != nil {
|
||||
session.FlashError(w, r, "Error creating a link to send you: %s", err)
|
||||
}
|
||||
|
||||
err := mail.Send(mail.Message{
|
||||
To: email,
|
||||
Subject: "Verify your e-mail address",
|
||||
Template: "email/verify_email.html",
|
||||
Data: map[string]interface{}{
|
||||
"Title": config.Title,
|
||||
"URL": config.Current.BaseURL + "/signup?token=" + token.Token,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Error sending an email: %s", err)
|
||||
}
|
||||
|
||||
session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email)
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// Full sign-up step (w/ email verification token), validate more things.
|
||||
var hasError bool
|
||||
if len(password) < 3 {
|
||||
session.FlashError(w, r, "Please enter a password longer than 3 characters.")
|
||||
hasError = true
|
||||
} else if password != password2 {
|
||||
session.FlashError(w, r, "Your passwords do not match.")
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if !config.UsernameRegexp.MatchString(username) {
|
||||
session.FlashError(w, r, "Your username must consist of only numbers, letters, - . and be 3-32 characters.")
|
||||
hasError = true
|
||||
}
|
||||
|
||||
// Looking good?
|
||||
if !hasError {
|
||||
user, err := models.CreateUser(username, email, password)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, err.Error())
|
||||
} else {
|
||||
session.Flash(w, r, "User account created. Now logged in as %s.", user.Username)
|
||||
|
||||
// Burn the signup token.
|
||||
if token.Token != "" {
|
||||
if err := token.Delete(); err != nil {
|
||||
log.Error("SignupToken.Delete(%s): %s", token.Token, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Log in the user and send them to their dashboard.
|
||||
session.LoginUser(w, r, user)
|
||||
templates.Redirect(w, "/me")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
38
pkg/controller/api/auth.go
Normal file
38
pkg/controller/api/auth.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||
)
|
||||
|
||||
// LoginOK API tests the validity of a user's session cookie.
|
||||
func LoginOK() http.HandlerFunc {
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
UserID uint64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if we're logged in.
|
||||
var res Response
|
||||
if user, err := session.CurrentUser(r); err == nil {
|
||||
res = Response{
|
||||
Success: true,
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
}
|
||||
}
|
||||
|
||||
buf, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(buf)
|
||||
})
|
||||
}
|
33
pkg/controller/api/version.go
Normal file
33
pkg/controller/api/version.go
Normal file
|
@ -0,0 +1,33 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
)
|
||||
|
||||
// Version details of the running app.
|
||||
func Version() http.HandlerFunc {
|
||||
// Response JSON schema.
|
||||
type Response struct {
|
||||
Version string `json:"version"`
|
||||
Build string `json:"build"`
|
||||
BuildDate string `json:"buildDate"`
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
buf, err := json.Marshal(Response{
|
||||
Version: config.RuntimeVersion,
|
||||
Build: config.RuntimeBuild,
|
||||
BuildDate: config.RuntimeBuildDate,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(buf)
|
||||
})
|
||||
}
|
25
pkg/controller/index/index.go
Normal file
25
pkg/controller/index/index.go
Normal file
|
@ -0,0 +1,25 @@
|
|||
package index
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||
)
|
||||
|
||||
// Create the controller.
|
||||
func Create() http.HandlerFunc {
|
||||
tmpl := templates.Must("index.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" || r.Method != http.MethodGet {
|
||||
log.Error("404 Not Found: %s", r.URL.Path)
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, nil); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
33
pkg/controller/version/version.go
Normal file
33
pkg/controller/version/version.go
Normal file
|
@ -0,0 +1,33 @@
|
|||
package version
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
)
|
||||
|
||||
// Response JSON schema.
|
||||
type Response struct {
|
||||
Version string `json:"version"`
|
||||
Build string `json:"build"`
|
||||
BuildDate string `json:"buildDate"`
|
||||
}
|
||||
|
||||
// Create the controller.
|
||||
func Create() http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
buf, err := json.Marshal(Response{
|
||||
Version: config.RuntimeVersion,
|
||||
Build: config.RuntimeBuild,
|
||||
BuildDate: config.RuntimeBuildDate,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(buf)
|
||||
})
|
||||
}
|
55
pkg/log/log.go
Normal file
55
pkg/log/log.go
Normal file
|
@ -0,0 +1,55 @@
|
|||
// Package log centralizes logging for the app.
|
||||
package log
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
golog "git.kirsle.net/go/log"
|
||||
)
|
||||
|
||||
var log golog.Logger
|
||||
|
||||
func init() {
|
||||
log = *golog.GetLogger("main")
|
||||
log.Configure(&golog.Config{
|
||||
Colors: golog.ExtendedColor,
|
||||
Theme: golog.DarkTheme,
|
||||
})
|
||||
|
||||
log.Config.Level = golog.DebugLevel
|
||||
}
|
||||
|
||||
// SetDebug toggles debug level logging.
|
||||
func SetDebug(v bool) {
|
||||
if v {
|
||||
log.Config.Level = golog.DebugLevel
|
||||
} else {
|
||||
log.Config.Level = golog.InfoLevel
|
||||
}
|
||||
}
|
||||
|
||||
// Info log.
|
||||
func Info(message string, v ...interface{}) {
|
||||
log.Info(message, v...)
|
||||
}
|
||||
|
||||
// Debug log.
|
||||
func Debug(message string, v ...interface{}) {
|
||||
log.Debug(message, v...)
|
||||
}
|
||||
|
||||
// Warn log.
|
||||
func Warn(message string, v ...interface{}) {
|
||||
log.Warn(message, v...)
|
||||
}
|
||||
|
||||
// Error log.
|
||||
func Error(message string, v ...interface{}) {
|
||||
log.Error(message, v...)
|
||||
}
|
||||
|
||||
// Fatal logs an error and exits.
|
||||
func Fatal(message string, v ...interface{}) {
|
||||
log.Error(message, v...)
|
||||
os.Exit(1)
|
||||
}
|
89
pkg/mail/mail.go
Normal file
89
pkg/mail/mail.go
Normal file
|
@ -0,0 +1,89 @@
|
|||
// Package mail provides e-mail sending faculties.
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"gopkg.in/gomail.v2"
|
||||
)
|
||||
|
||||
// Message configuration.
|
||||
type Message struct {
|
||||
To string
|
||||
ReplyTo string
|
||||
Subject string
|
||||
Template string // path relative to the templates dir, e.g. "email/verify_email.html"
|
||||
Data map[string]interface{}
|
||||
}
|
||||
|
||||
// Send an email.
|
||||
func Send(msg Message) error {
|
||||
conf := config.Current.Mail
|
||||
|
||||
// Verify configuration.
|
||||
if !conf.Enabled {
|
||||
return errors.New(
|
||||
"Email sending is not configured for this app. Please contact the website administrator about this error.",
|
||||
)
|
||||
} else if conf.Host == "" || conf.Port == 0 || conf.From == "" {
|
||||
return errors.New(
|
||||
"Email settings are misconfigured for this app. Please contact the website administrator about this error.",
|
||||
)
|
||||
}
|
||||
|
||||
// Get and render the template to HTML.
|
||||
var html bytes.Buffer
|
||||
tmpl, err := template.New(msg.Template).ParseFiles(config.TemplatePath + "/" + msg.Template)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Execute the template.
|
||||
err = tmpl.ExecuteTemplate(&html, "content", msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Mail template execute error: %s", err)
|
||||
}
|
||||
|
||||
// Condense the HTML down into the plaintext version.
|
||||
rawLines := strings.Split(
|
||||
bluemonday.StrictPolicy().Sanitize(html.String()),
|
||||
"\n",
|
||||
)
|
||||
var lines []string
|
||||
for _, line := range rawLines {
|
||||
line = strings.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
plaintext := strings.Join(lines, "\n\n")
|
||||
|
||||
// Prepare the e-mail!
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", fmt.Sprintf("%s <%s>", config.Title, conf.From))
|
||||
m.SetHeader("To", msg.To)
|
||||
if msg.ReplyTo != "" {
|
||||
m.SetHeader("Reply-To", msg.ReplyTo)
|
||||
}
|
||||
m.SetHeader("Subject", msg.Subject)
|
||||
m.SetBody("text/plain", plaintext)
|
||||
m.AddAlternative("text/html", html.String())
|
||||
|
||||
// Deliver.
|
||||
d := gomail.NewDialer(conf.Host, conf.Port, conf.Username, conf.Password)
|
||||
|
||||
log.Info("mail.Send: %s (%s) to %s", msg.Subject, msg.Template, msg.To)
|
||||
if err := d.DialAndSend(m); err != nil {
|
||||
log.Error("mail.Send: %s", err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
23
pkg/middleware/authentication.go
Normal file
23
pkg/middleware/authentication.go
Normal file
|
@ -0,0 +1,23 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||
)
|
||||
|
||||
// LoginRequired middleware.
|
||||
func LoginRequired(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// User must be logged in.
|
||||
if _, err := session.CurrentUser(r); err != nil {
|
||||
errhandler := templates.MakeErrorPage("Login Required", "You must be signed in to view this page.", http.StatusForbidden)
|
||||
errhandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
60
pkg/middleware/csrf.go
Normal file
60
pkg/middleware/csrf.go
Normal file
|
@ -0,0 +1,60 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||
"git.kirsle.net/apps/gosocial/pkg/templates"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CSRF middleware. Other places to look: pkg/session/session.go, pkg/templates/template_funcs.go
|
||||
func CSRF(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Get or create the cookie CSRF value.
|
||||
token := MakeCSRFCookie(r, w)
|
||||
ctx := context.WithValue(r.Context(), session.CSRFKey, token)
|
||||
|
||||
// If we are running a POST request, validate the CSRF form value.
|
||||
if r.Method != http.MethodGet {
|
||||
r.ParseForm()
|
||||
check := r.FormValue(config.CSRFInputName)
|
||||
if check != token {
|
||||
log.Error("CSRF mismatch! %s <> %s", check, token)
|
||||
templates.MakeErrorPage(
|
||||
"CSRF Error",
|
||||
"An error occurred while processing your request. Please go back and try again.",
|
||||
http.StatusForbidden,
|
||||
)(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// MakeCSRFCookie gets or creates the CSRF cookie and returns its value.
|
||||
func MakeCSRFCookie(r *http.Request, w http.ResponseWriter) string {
|
||||
// Has a token already?
|
||||
cookie, err := r.Cookie(config.CSRFCookieName)
|
||||
if err == nil {
|
||||
// log.Debug("MakeCSRFCookie: user has token %s", cookie.Value)
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
// Generate a new CSRF token.
|
||||
token := uuid.New().String()
|
||||
cookie = &http.Cookie{
|
||||
Name: config.CSRFCookieName,
|
||||
Value: token,
|
||||
HttpOnly: true,
|
||||
}
|
||||
// log.Debug("MakeCSRFCookie: giving cookie value %s to user", token)
|
||||
http.SetCookie(w, cookie)
|
||||
|
||||
return token
|
||||
}
|
16
pkg/middleware/logging.go
Normal file
16
pkg/middleware/logging.go
Normal file
|
@ -0,0 +1,16 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Logging middleware.
|
||||
func Logging(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nw := time.Now()
|
||||
handler.ServeHTTP(w, r)
|
||||
fmt.Printf("%s %s %s %s\n", r.RemoteAddr, r.Method, r.URL, time.Since(nw))
|
||||
})
|
||||
}
|
22
pkg/middleware/recovery.go
Normal file
22
pkg/middleware/recovery.go
Normal file
|
@ -0,0 +1,22 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
)
|
||||
|
||||
// Recovery recovery middleware.
|
||||
func Recovery(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Error("PANIC: %v", err)
|
||||
debug.PrintStack()
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
20
pkg/middleware/session.go
Normal file
20
pkg/middleware/session.go
Normal file
|
@ -0,0 +1,20 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||
)
|
||||
|
||||
// Session middleware.
|
||||
func Session(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Check for the session_id cookie.
|
||||
sess := session.LoadOrNew(r)
|
||||
ctx := context.WithValue(r.Context(), session.ContextKey, sess)
|
||||
|
||||
handler.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
12
pkg/models/models.go
Normal file
12
pkg/models/models.go
Normal file
|
@ -0,0 +1,12 @@
|
|||
// Package models handles the database.
|
||||
package models
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
// DB to be set by calling app (SQLite or Postgres connection).
|
||||
var DB *gorm.DB
|
||||
|
||||
// AutoMigrate the schema.
|
||||
func AutoMigrate() {
|
||||
DB.AutoMigrate(&User{})
|
||||
}
|
86
pkg/models/user.go
Normal file
86
pkg/models/user.go
Normal file
|
@ -0,0 +1,86 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// User account table.
|
||||
type User struct {
|
||||
ID uint64 `gorm:"primaryKey`
|
||||
Username string `gorm:"uniqueIndex"`
|
||||
Email string `gorm:"uniqueIndex"`
|
||||
HashedPassword string
|
||||
IsAdmin bool `gorm:"index"`
|
||||
Status string `gorm:"index"` // pending, active, disabled
|
||||
Visibility string `gorm:"index"` // public, private
|
||||
Name *string
|
||||
Certified bool
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
UpdatedAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
// CreateUser. It is assumed username and email are correctly formatted.
|
||||
func CreateUser(username, email, password string) (*User, error) {
|
||||
// Verify username and email are unique.
|
||||
if _, err := FindUser(username); err == nil {
|
||||
return nil, errors.New("That username already exists. Please try a different username.")
|
||||
} else if _, err := FindUser(email); err == nil {
|
||||
return nil, errors.New("That email address is already registered.")
|
||||
}
|
||||
|
||||
u := &User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
}
|
||||
|
||||
if err := u.HashPassword(password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := DB.Create(u)
|
||||
return u, result.Error
|
||||
}
|
||||
|
||||
// GetUser by ID.
|
||||
func GetUser(userId uint64) (*User, error) {
|
||||
user := &User{}
|
||||
result := DB.First(&user, userId)
|
||||
return user, result.Error
|
||||
}
|
||||
|
||||
// FindUser by username or email.
|
||||
func FindUser(username string) (*User, error) {
|
||||
u := &User{}
|
||||
if strings.ContainsRune(username, '@') {
|
||||
result := DB.Where("email = ?", username).Limit(1).First(u)
|
||||
return u, result.Error
|
||||
}
|
||||
result := DB.Where("username = ?", username).Limit(1).First(u)
|
||||
return u, result.Error
|
||||
}
|
||||
|
||||
// HashPassword sets the user's hashed (bcrypt) password.
|
||||
func (u *User) HashPassword(password string) error {
|
||||
passwd, err := bcrypt.GenerateFromPassword([]byte(password), config.BcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.HashedPassword = string(passwd)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPassword verifies the password is correct. Returns nil on success.
|
||||
func (u *User) CheckPassword(password string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(u.HashedPassword), []byte(password))
|
||||
}
|
||||
|
||||
// Save user.
|
||||
func (u *User) Save() error {
|
||||
result := DB.Save(u)
|
||||
return result.Error
|
||||
}
|
81
pkg/redis/redis.go
Normal file
81
pkg/redis/redis.go
Normal file
|
@ -0,0 +1,81 @@
|
|||
// Package redis provides simple Redis cache functions.
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"github.com/go-redis/redis/v8"
|
||||
)
|
||||
|
||||
var ctx = context.Background()
|
||||
|
||||
var Client *redis.Client
|
||||
|
||||
/*
|
||||
Setup the Redis connection.
|
||||
|
||||
The addr format is like:
|
||||
|
||||
- localhost:6379
|
||||
- localhost:6379/6
|
||||
|
||||
The latter format to specify the DB number if not the default (0).
|
||||
*/
|
||||
func Setup(addr string) error {
|
||||
// Parse the addr string.
|
||||
parts := strings.Split(addr, "/")
|
||||
addr = parts[0]
|
||||
db := 0
|
||||
if len(parts) > 1 && len(parts[1]) > 0 {
|
||||
a, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("redis DB number was not an integer: %s", err)
|
||||
}
|
||||
db = a
|
||||
}
|
||||
|
||||
Client = redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
DB: db,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set a JSON serializable object in Redis.
|
||||
func Set(key string, v interface{}, expire time.Duration) error {
|
||||
bin, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug("redis.Set(%s): %s", key, bin)
|
||||
|
||||
_, err = Client.Set(ctx, key, bin, expire).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get a JSON serialized value out of Redis.
|
||||
func Get(key string, v any) error {
|
||||
val, err := Client.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug("redis.Get(%s): %s", key, val)
|
||||
return json.Unmarshal([]byte(val), v)
|
||||
}
|
||||
|
||||
// Delete a key from Redis.
|
||||
func Delete(key string) error {
|
||||
return Client.Del(ctx, key).Err()
|
||||
}
|
39
pkg/router/router.go
Normal file
39
pkg/router/router.go
Normal file
|
@ -0,0 +1,39 @@
|
|||
// Package router configures web routes.
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"git.kirsle.net/apps/gosocial/pkg/controller/account"
|
||||
"git.kirsle.net/apps/gosocial/pkg/controller/api"
|
||||
"git.kirsle.net/apps/gosocial/pkg/controller/index"
|
||||
"git.kirsle.net/apps/gosocial/pkg/middleware"
|
||||
)
|
||||
|
||||
func New() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Register controller endpoints.
|
||||
mux.HandleFunc("/", index.Create())
|
||||
mux.HandleFunc("/login", account.Login())
|
||||
mux.HandleFunc("/logout", account.Logout())
|
||||
mux.HandleFunc("/signup", account.Signup())
|
||||
|
||||
// Login Required.
|
||||
mux.Handle("/me", middleware.LoginRequired(account.Dashboard()))
|
||||
|
||||
// JSON API endpoints.
|
||||
mux.HandleFunc("/v1/version", api.Version())
|
||||
mux.HandleFunc("/v1/users/me", api.LoginOK())
|
||||
|
||||
// Static files.
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(config.StaticPath))))
|
||||
|
||||
// Global middlewares.
|
||||
withSession := middleware.Session(mux)
|
||||
withCSRF := middleware.CSRF(withSession)
|
||||
withRecovery := middleware.Recovery(withCSRF)
|
||||
withLogger := middleware.Logging(withRecovery)
|
||||
return withLogger
|
||||
}
|
48
pkg/router/template.go
Normal file
48
pkg/router/template.go
Normal file
|
@ -0,0 +1,48 @@
|
|||
package router
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
)
|
||||
|
||||
// LoadTemplate processes and returns a template. Filename is relative
|
||||
// to the template directory, e.g. "index.html"
|
||||
func LoadTemplate(filename string) *template.Template {
|
||||
files := templates(config.TemplatePath + "/" + filename)
|
||||
tmpl := template.Must(template.New("page").ParseFiles(files...))
|
||||
return tmpl
|
||||
}
|
||||
|
||||
// Default template funcs.
|
||||
var defaultFuncs = template.FuncMap{}
|
||||
|
||||
// Base template layout.
|
||||
var baseTemplates = []string{
|
||||
config.TemplatePath + "/base.html",
|
||||
}
|
||||
|
||||
// templates returns a template chain with the base templates preceding yours.
|
||||
// Files given are expected to be full paths (config.TemplatePath + file)
|
||||
func templates(files ...string) []string {
|
||||
return append(baseTemplates, files...)
|
||||
}
|
||||
|
||||
// RenderTemplate executes a template. Filename is relative to the templates
|
||||
// root, e.g. "index.html"
|
||||
func RenderTemplate(w io.Writer, filename string) error {
|
||||
files := templates(config.TemplatePath + "/" + filename)
|
||||
tmpl := template.Must(
|
||||
template.New("index").ParseFiles(files...),
|
||||
)
|
||||
|
||||
err := tmpl.ExecuteTemplate(w, "base", map[string]interface{}{
|
||||
"Title": config.Title,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
19
pkg/session/current_user.go
Normal file
19
pkg/session/current_user.go
Normal file
|
@ -0,0 +1,19 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||
)
|
||||
|
||||
// CurrentUser returns the current logged in user via session cookie.
|
||||
func CurrentUser(r *http.Request) (*models.User, error) {
|
||||
sess := Get(r)
|
||||
if sess.LoggedIn {
|
||||
// Load the associated user ID.
|
||||
return models.GetUser(sess.UserID)
|
||||
}
|
||||
|
||||
return nil, errors.New("request session is not logged in")
|
||||
}
|
158
pkg/session/session.go
Normal file
158
pkg/session/session.go
Normal file
|
@ -0,0 +1,158 @@
|
|||
// Package session handles user login and other cookies.
|
||||
package session
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"git.kirsle.net/apps/gosocial/pkg/models"
|
||||
"git.kirsle.net/apps/gosocial/pkg/redis"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Session cookie object that is kept server side in Redis.
|
||||
type Session struct {
|
||||
UUID string `json:"-"` // not stored
|
||||
LoggedIn bool `json:"loggedIn"`
|
||||
UserID uint64 `json:"userId,omitempty"`
|
||||
Flashes []string `json:"flashes,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
}
|
||||
|
||||
const (
|
||||
ContextKey = "session"
|
||||
CSRFKey = "csrf"
|
||||
)
|
||||
|
||||
// New creates a blank session object.
|
||||
func New() *Session {
|
||||
return &Session{
|
||||
UUID: uuid.New().String(),
|
||||
Flashes: []string{},
|
||||
Errors: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
// Load the session from the browser session_id token and Redis or creates a new session.
|
||||
func LoadOrNew(r *http.Request) *Session {
|
||||
var sess = New()
|
||||
|
||||
// Read the session cookie value.
|
||||
cookie, err := r.Cookie(config.SessionCookieName)
|
||||
if err != nil {
|
||||
log.Debug("session.LoadOrNew: cookie error, new sess: %s", err)
|
||||
return sess
|
||||
}
|
||||
|
||||
// Look up this UUID in Redis.
|
||||
sess.UUID = cookie.Value
|
||||
key := fmt.Sprintf(config.SessionRedisKeyFormat, sess.UUID)
|
||||
|
||||
err = redis.Get(key, sess)
|
||||
log.Error("LoadOrNew: raw from Redis: %+v", sess)
|
||||
if err != nil {
|
||||
log.Error("session.LoadOrNew: didn't find %s in Redis: %s", err)
|
||||
}
|
||||
|
||||
return sess
|
||||
}
|
||||
|
||||
// Save the session and send a cookie header.
|
||||
func (s *Session) Save(w http.ResponseWriter) {
|
||||
// Roll a UUID session_id value.
|
||||
if s.UUID == "" {
|
||||
s.UUID = uuid.New().String()
|
||||
}
|
||||
|
||||
// Ensure it is a valid UUID.
|
||||
if _, err := uuid.Parse(s.UUID); err != nil {
|
||||
log.Error("Session.Save: got an invalid UUID session_id: %s", err)
|
||||
s.UUID = uuid.New().String()
|
||||
}
|
||||
|
||||
// Ping last seen.
|
||||
s.LastSeen = time.Now()
|
||||
|
||||
// Save their session object in Redis.
|
||||
key := fmt.Sprintf(config.SessionRedisKeyFormat, s.UUID)
|
||||
if err := redis.Set(key, s, config.SessionCookieMaxAge*time.Second); err != nil {
|
||||
log.Error("Session.Save: couldn't write to Redis: %s", err)
|
||||
}
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: config.SessionCookieName,
|
||||
Value: s.UUID,
|
||||
MaxAge: config.SessionCookieMaxAge,
|
||||
HttpOnly: true,
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
|
||||
// Get the session from the current HTTP request context.
|
||||
func Get(r *http.Request) *Session {
|
||||
if r == nil {
|
||||
panic("session.Get: http.Request is required")
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if sess, ok := ctx.Value(ContextKey).(*Session); ok {
|
||||
return sess
|
||||
}
|
||||
|
||||
// If the session isn't on the request, it means I broke something.
|
||||
log.Error("session.Get(): didn't find session in request context!")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadFlashes returns and clears the Flashes and Errors for this session.
|
||||
func (s *Session) ReadFlashes(w http.ResponseWriter) (flashes, errors []string) {
|
||||
flashes = s.Flashes
|
||||
errors = s.Errors
|
||||
s.Flashes = []string{}
|
||||
s.Errors = []string{}
|
||||
if len(flashes)+len(errors) > 0 {
|
||||
s.Save(w)
|
||||
}
|
||||
return flashes, errors
|
||||
}
|
||||
|
||||
// Flash adds a transient message to the user's session to show on next page load.
|
||||
func Flash(w http.ResponseWriter, r *http.Request, msg string, args ...interface{}) {
|
||||
sess := Get(r)
|
||||
sess.Flashes = append(sess.Flashes, fmt.Sprintf(msg, args...))
|
||||
sess.Save(w)
|
||||
}
|
||||
|
||||
// FlashError adds a transient error message to the session.
|
||||
func FlashError(w http.ResponseWriter, r *http.Request, msg string, args ...interface{}) {
|
||||
sess := Get(r)
|
||||
sess.Errors = append(sess.Flashes, fmt.Sprintf(msg, args...))
|
||||
sess.Save(w)
|
||||
}
|
||||
|
||||
// LoginUser marks a session as logged in to an account.
|
||||
func LoginUser(w http.ResponseWriter, r *http.Request, u *models.User) error {
|
||||
if u == nil || u.ID == 0 {
|
||||
return errors.New("not a valid user account")
|
||||
}
|
||||
|
||||
sess := Get(r)
|
||||
sess.LoggedIn = true
|
||||
sess.UserID = u.ID
|
||||
sess.Save(w)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogoutUser signs a user out.
|
||||
func LogoutUser(w http.ResponseWriter, r *http.Request) {
|
||||
sess := Get(r)
|
||||
sess.LoggedIn = false
|
||||
sess.UserID = 0
|
||||
sess.Save(w)
|
||||
}
|
24
pkg/templates/error_pages.go
Normal file
24
pkg/templates/error_pages.go
Normal file
|
@ -0,0 +1,24 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// NotFoundPage is an HTTP handler for 404 pages.
|
||||
var NotFoundPage = func() http.HandlerFunc {
|
||||
return MakeErrorPage("Not Found", "The page you requested was not here.", http.StatusNotFound)
|
||||
}()
|
||||
|
||||
func MakeErrorPage(header string, message string, statusCode int) http.HandlerFunc {
|
||||
tmpl := Must("errors/error.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(statusCode)
|
||||
if err := tmpl.Execute(w, r, map[string]interface{}{
|
||||
"Header": header,
|
||||
"Message": message,
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
9
pkg/templates/redirect.go
Normal file
9
pkg/templates/redirect.go
Normal file
|
@ -0,0 +1,9 @@
|
|||
package templates
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Redirect sends an HTTP header to the browser.
|
||||
func Redirect(w http.ResponseWriter, url string) {
|
||||
w.Header().Set("Location", url)
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}
|
33
pkg/templates/template_funcs.go
Normal file
33
pkg/templates/template_funcs.go
Normal file
|
@ -0,0 +1,33 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||
)
|
||||
|
||||
// TemplateFuncs available to all pages.
|
||||
func TemplateFuncs(r *http.Request) template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"InputCSRF": InputCSRF(r),
|
||||
}
|
||||
}
|
||||
|
||||
// InputCSRF returns the HTML snippet for a CSRF token hidden input field.
|
||||
func InputCSRF(r *http.Request) func() template.HTML {
|
||||
return func() template.HTML {
|
||||
ctx := r.Context()
|
||||
if token, ok := ctx.Value(session.CSRFKey).(string); ok {
|
||||
return template.HTML(fmt.Sprintf(
|
||||
`<input type="hidden" name="%s" value="%s">`,
|
||||
config.CSRFInputName,
|
||||
token,
|
||||
))
|
||||
} else {
|
||||
return template.HTML(`[CSRF middleware error]`)
|
||||
}
|
||||
}
|
||||
}
|
36
pkg/templates/template_vars.go
Normal file
36
pkg/templates/template_vars.go
Normal file
|
@ -0,0 +1,36 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||
)
|
||||
|
||||
// MergeVars mixes in globally available template variables. The http.Request is optional.
|
||||
func MergeVars(r *http.Request, m map[string]interface{}) {
|
||||
m["Title"] = config.Title
|
||||
m["Subtitle"] = config.Subtitle
|
||||
m["YYYY"] = time.Now().Year()
|
||||
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// MergeUserVars mixes in global template variables: LoggedIn and CurrentUser. The http.Request is optional.
|
||||
func MergeUserVars(r *http.Request, m map[string]interface{}) {
|
||||
// Defaults
|
||||
m["LoggedIn"] = false
|
||||
m["CurrentUser"] = nil
|
||||
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if user, err := session.CurrentUser(r); err == nil {
|
||||
m["LoggedIn"] = true
|
||||
m["CurrentUser"] = user
|
||||
}
|
||||
}
|
151
pkg/templates/templates.go
Normal file
151
pkg/templates/templates.go
Normal file
|
@ -0,0 +1,151 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/config"
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"git.kirsle.net/apps/gosocial/pkg/session"
|
||||
)
|
||||
|
||||
// Template is a logical HTML template for the app with ability to wrap around an html/template
|
||||
// and provide middlewares, hooks or live reloading capability in debug mode.
|
||||
type Template struct {
|
||||
filename string // Filename on disk (index.html)
|
||||
filepath string // Full path on disk (./web/templates/index.html)
|
||||
modified time.Time // Modification date of the file at init time
|
||||
tmpl *template.Template
|
||||
}
|
||||
|
||||
// LoadTemplate processes and returns a template. Filename is relative
|
||||
// to the template directory, e.g. "index.html". Call this at the initialization
|
||||
// of your endpoint controller; in debug mode the template HTML from disk may be
|
||||
// reloaded if modified after initial load.
|
||||
func LoadTemplate(filename string) (*Template, error) {
|
||||
filepath := config.TemplatePath + "/" + filename
|
||||
stat, err := os.Stat(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadTemplate(%s): %s", err)
|
||||
}
|
||||
|
||||
files := templates(config.TemplatePath + "/" + filename)
|
||||
tmpl := template.New("page")
|
||||
tmpl.Funcs(TemplateFuncs(nil))
|
||||
tmpl.ParseFiles(files...)
|
||||
|
||||
return &Template{
|
||||
filename: filename,
|
||||
filepath: filepath,
|
||||
modified: stat.ModTime(),
|
||||
tmpl: tmpl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Must LoadTemplate or panic.
|
||||
func Must(filename string) *Template {
|
||||
tmpl, err := LoadTemplate(filename)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return tmpl
|
||||
}
|
||||
|
||||
// Execute a loaded template. In debug mode, the template file may be reloaded
|
||||
// from disk if the file on disk has been modified.
|
||||
func (t *Template) Execute(w http.ResponseWriter, r *http.Request, vars map[string]interface{}) error {
|
||||
if vars == nil {
|
||||
vars = map[string]interface{}{}
|
||||
}
|
||||
|
||||
// Merge in global variables.
|
||||
MergeVars(r, vars)
|
||||
MergeUserVars(r, vars)
|
||||
|
||||
// Merge the flashed messsage variables in.
|
||||
if r != nil {
|
||||
sess := session.Get(r)
|
||||
flashes, errors := sess.ReadFlashes(w)
|
||||
vars["Flashes"] = flashes
|
||||
vars["Errors"] = errors
|
||||
}
|
||||
|
||||
// Reload the template from disk?
|
||||
if stat, err := os.Stat(t.filepath); err == nil {
|
||||
if stat.ModTime().After(t.modified) {
|
||||
log.Info("Template(%s).Execute: file updated on disk, reloading", t.filename)
|
||||
err = t.Reload()
|
||||
if err != nil {
|
||||
log.Error("Reloading error: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Install the function map.
|
||||
tmpl := t.tmpl
|
||||
if r != nil {
|
||||
tmpl = t.tmpl.Funcs(TemplateFuncs(r))
|
||||
}
|
||||
|
||||
if err := tmpl.ExecuteTemplate(w, "base", vars); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload the template from disk.
|
||||
func (t *Template) Reload() error {
|
||||
stat, err := os.Stat(t.filepath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Reload(%s): %s", t.filename, err)
|
||||
}
|
||||
|
||||
files := templates(t.filepath)
|
||||
tmpl := template.New("page")
|
||||
tmpl.Funcs(TemplateFuncs(nil))
|
||||
tmpl.ParseFiles(files...)
|
||||
|
||||
t.tmpl = tmpl
|
||||
t.modified = stat.ModTime()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Base template layout.
|
||||
var baseTemplates = []string{
|
||||
config.TemplatePath + "/base.html",
|
||||
}
|
||||
|
||||
// templates returns a template chain with the base templates preceding yours.
|
||||
// Files given are expected to be full paths (config.TemplatePath + file)
|
||||
func templates(files ...string) []string {
|
||||
return append(baseTemplates, files...)
|
||||
}
|
||||
|
||||
// RenderTemplate executes a template. Filename is relative to the templates
|
||||
// root, e.g. "index.html"
|
||||
func RenderTemplate(w io.Writer, r *http.Request, filename string, vars map[string]interface{}) error {
|
||||
if vars == nil {
|
||||
vars = map[string]interface{}{}
|
||||
}
|
||||
|
||||
// Merge in user vars.
|
||||
MergeVars(r, vars)
|
||||
MergeUserVars(r, vars)
|
||||
|
||||
files := templates(config.TemplatePath + "/" + filename)
|
||||
tmpl := template.Must(
|
||||
template.New("index").ParseFiles(files...),
|
||||
)
|
||||
|
||||
err := tmpl.ExecuteTemplate(w, "base", vars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
3
pkg/version.go
Normal file
3
pkg/version.go
Normal file
|
@ -0,0 +1,3 @@
|
|||
package gosocial
|
||||
|
||||
const Version = "0.0.1"
|
35
pkg/webserver.go
Normal file
35
pkg/webserver.go
Normal file
|
@ -0,0 +1,35 @@
|
|||
package gosocial
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.kirsle.net/apps/gosocial/pkg/log"
|
||||
"git.kirsle.net/apps/gosocial/pkg/router"
|
||||
)
|
||||
|
||||
// WebServer is the main entry point for the `gosocial web` command.
|
||||
type WebServer struct {
|
||||
// Configuration
|
||||
Host string // host interface, default "0.0.0.0"
|
||||
Port int // default 8080
|
||||
}
|
||||
|
||||
// Run the server.
|
||||
func (ws *WebServer) Run() error {
|
||||
// Defaults
|
||||
if ws.Host == "" {
|
||||
ws.Host = "0.0.0.0"
|
||||
}
|
||||
if ws.Port == 0 {
|
||||
ws.Port = 8080
|
||||
}
|
||||
|
||||
s := http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", ws.Host, ws.Port),
|
||||
Handler: router.New(),
|
||||
}
|
||||
|
||||
log.Info("Listening at http://%s:%d", ws.Host, ws.Port)
|
||||
return s.ListenAndServe()
|
||||
}
|
BIN
web/static/img/shy.png
Normal file
BIN
web/static/img/shy.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.3 KiB |
22
web/static/js/bulma.js
Normal file
22
web/static/js/bulma.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
// Hamburger menu script for mobile.
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Get all "navbar-burger" elements
|
||||
const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
|
||||
|
||||
// Add a click event on each of them
|
||||
$navbarBurgers.forEach( el => {
|
||||
el.addEventListener('click', () => {
|
||||
|
||||
// Get the target from the "data-target" attribute
|
||||
const target = el.dataset.target;
|
||||
const $target = document.getElementById(target);
|
||||
|
||||
// Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
|
||||
el.classList.toggle('is-active');
|
||||
$target.classList.toggle('is-active');
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
});
|
1
web/static/test.txt
Normal file
1
web/static/test.txt
Normal file
|
@ -0,0 +1 @@
|
|||
it's here
|
45
web/templates/account/dashboard.html
Normal file
45
web/templates/account/dashboard.html
Normal file
|
@ -0,0 +1,45 @@
|
|||
{{define "content"}}
|
||||
<div class="container">
|
||||
<section class="hero is-info is-bold">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h1 class="title">User Dashboard</h1>
|
||||
<h2 class="subtitle">to your account</h2>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="block p-4">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<div class="card">
|
||||
<header class="card-header has-background-link">
|
||||
<p class="card-header-title has-text-light">My Account</p>
|
||||
</header>
|
||||
|
||||
<div class="card-content">
|
||||
<ul class="menu-list">
|
||||
<li><a href="/u/{{.CurrentUser.Username}}">My Profile</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
<li><a href="/logout">Log out</a></li>
|
||||
<li><a href="/account/delete">Delete account</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<div class="card">
|
||||
<header class="card-header has-background-warning">
|
||||
<p class="card-header-title">Notifications</p>
|
||||
</header>
|
||||
|
||||
<div class="card-content">
|
||||
TBD.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
26
web/templates/account/login.html
Normal file
26
web/templates/account/login.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
{{define "content"}}
|
||||
<div class="container">
|
||||
<section class="hero is-info is-bold">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h1 class="title">Sign in</h1>
|
||||
<h2 class="subtitle">to your account</h2>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="block p-2">
|
||||
<form action="/login" method="POST">
|
||||
{{ InputCSRF }}
|
||||
|
||||
<label for="username">Username or email:</label>
|
||||
<input type="text" class="input" name="username" placeholder="username" autocomplete="off">
|
||||
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" class="input" name="password" placeholder="password">
|
||||
|
||||
<button type="submit" class="button is-primary">Log in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
139
web/templates/account/signup.html
Normal file
139
web/templates/account/signup.html
Normal file
|
@ -0,0 +1,139 @@
|
|||
{{define "content"}}
|
||||
<div class="container">
|
||||
<section class="hero is-info is-bold">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h1 class="title">Sign up</h1>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="block content p-4">
|
||||
|
||||
<pre>{{.}}</pre>
|
||||
|
||||
{{if or .SkipEmailVerification (not .SignupToken)}}
|
||||
<p>
|
||||
I'm glad you're thinking about joining us here!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Before we get started, I want you to confirm you've read the rules. Before you can interact with
|
||||
the community here, you will need to <strong>upload a face picture</strong> to your profile (it
|
||||
doesn't have to be a nude, but does have to show your face!) and you will need to
|
||||
<strong>submit a verification selfie</strong> to prove that the person in that picture is you.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The <strong>verification selfie</strong> will involve you writing a message on a sheet of paper
|
||||
and taking a selfie showing your face and clearly holding the sheet of paper. But we'll get to that a little later!
|
||||
</p>
|
||||
|
||||
<h1>Site Rules</h1>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
🧑 Only <strong>real people</strong> are allowed to join. You must be comfortable showing your
|
||||
face on your profile page. You don't need to include your face in your nudes but a profile picture
|
||||
of your face is required.
|
||||
</li>
|
||||
<li>
|
||||
✅ <strong>Verification is mandatory.</strong> Along with the face picture on your profile page,
|
||||
you will need to take a selfie with a hand-written note on paper to verify that you're a real
|
||||
person.
|
||||
</li>
|
||||
<li>
|
||||
🤳 <strong>Self pictures only.</strong> You are expected to post only pictures that you're in.
|
||||
No "porn blogs" of random content you found online!
|
||||
</li>
|
||||
<li>
|
||||
😈 <strong><span class="has-text-danger">Explicit content</span> is permitted in designated areas only.</strong>
|
||||
Not all nudists want to see "sexual" content, but exhibitionists are welcome here too. If you want to upload
|
||||
sexually charged content, mark those pictures as 'explicit' when uploading them or post them only to the
|
||||
designated explicit forums so nudists who prefer not to see don't have to.
|
||||
</li>
|
||||
<li>
|
||||
🔞 You must be <strong class="has-text-danger">18 years or older</strong> to sign up for this website.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h1>Onboarding</h1>
|
||||
|
||||
<p>
|
||||
Here is what you can expect from the sign-up process:
|
||||
</p>
|
||||
|
||||
<ol>
|
||||
<li>Email address: you will be emailed a link to verify control of that email inbox.</li>
|
||||
<li>Account creation: you will create a username, password, and upload a face pic for your profile page.</li>
|
||||
<li>Verification: you will take a verification selfie to prove you're the person in that profile pic.</li>
|
||||
<li>Approval: an admin will review your verification selfie and you will become a full member of this site!</li>
|
||||
</ol>
|
||||
{{end}}
|
||||
|
||||
<h1>Sign Up</h1>
|
||||
|
||||
<p>
|
||||
To start the process, enter your e-mail address below. You will be sent an e-mail to verify you
|
||||
control that address and then you can create a username and password.
|
||||
</p>
|
||||
|
||||
<form action="/signup" method="POST">
|
||||
{{ InputCSRF }}
|
||||
{{if .SignupToken}}
|
||||
<input type="hidden" name="token" value="{{.SignupToken}}">
|
||||
{{end}}
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="email">Your email address:</label>
|
||||
<input type="email" class="input"
|
||||
placeholder="name@domain.com"
|
||||
name="email"
|
||||
id="email"
|
||||
value="{{.Email}}"
|
||||
required {{if .SignupToken }}readonly{{end}}>
|
||||
</div>
|
||||
|
||||
{{if or .SignupToken .SkipEmailVerification}}
|
||||
<div class="field">
|
||||
<label class="label" for="username">Enter a username:</label>
|
||||
<input type="text" class="input"
|
||||
placeholder="username"
|
||||
name="username"
|
||||
id="username"
|
||||
value="{{.Username}}"
|
||||
required>
|
||||
<small class="has-text-grey">Usernames are 3 to 32 characters a-z 0-9 . -</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="password">Enter a passphrase:</label>
|
||||
<input type="password" class="input"
|
||||
placeholder="password"
|
||||
name="password"
|
||||
id="password"
|
||||
required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="password2">Confirm passphrase:</label>
|
||||
<input type="password" class="input"
|
||||
placeholder="password"
|
||||
name="password2"
|
||||
id="password2"
|
||||
required>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="confirm" value="true" required>
|
||||
I understand the site rules and assert that I am 18 years or older.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button type="submit" class="button is-primary">Continue and verify email</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
162
web/templates/base.html
Normal file
162
web/templates/base.html
Normal file
|
@ -0,0 +1,162 @@
|
|||
{{define "base"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
|
||||
<title>{{ .Title }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container is-fullhd">
|
||||
<nav class="navbar" role="navigation" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item" href="/">
|
||||
{{ .Title }}
|
||||
</a>
|
||||
|
||||
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="navbarBasicExample" class="navbar-menu">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="/">
|
||||
Home
|
||||
</a>
|
||||
|
||||
<a class="navbar-item" href="/about">
|
||||
About
|
||||
</a>
|
||||
|
||||
{{if .LoggedIn}}
|
||||
<a class="navbar-item" href="/forums">
|
||||
Forums
|
||||
</a>
|
||||
|
||||
<a class="navbar-item" href="/messages">
|
||||
Messages
|
||||
<span class="tag is-warning">42</span>
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link">
|
||||
More
|
||||
</a>
|
||||
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item">
|
||||
About
|
||||
</a>
|
||||
<a class="navbar-item">
|
||||
Jobs
|
||||
</a>
|
||||
<a class="navbar-item">
|
||||
Contact
|
||||
</a>
|
||||
<hr class="navbar-divider">
|
||||
<a class="navbar-item">
|
||||
Report an issue
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
{{if .LoggedIn }}
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link" href="/me">
|
||||
<figure class="image is-24x24 mr-2">
|
||||
<img src="/static/img/shy.png" class="is-rounded has-background-warning">
|
||||
</figure>
|
||||
{{.CurrentUser.Username}}
|
||||
</a>
|
||||
|
||||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="/me">Dashboard</a>
|
||||
<a class="navbar-item" href="/u/{{.CurrentUser.Username}}">My Profile</a>
|
||||
<a class="navbar-item" href="/settings">Settings</a>
|
||||
<a class="navbar-item" href="/logout">Log out</a>
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="navbar-item">
|
||||
<div class="buttons">
|
||||
<a class="button is-primary" href="/signup">
|
||||
<strong>Sign up</strong>
|
||||
</a>
|
||||
<a class="button is-light" href="/login">
|
||||
Log in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{{if .Flashes}}
|
||||
<div class="notification block is-success">
|
||||
<!-- <button class="delete"></button> -->
|
||||
|
||||
{{range .Flashes}}
|
||||
<div class="block">{{.}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Errors}}
|
||||
<div class="notification block is-danger">
|
||||
<!-- <button class="delete"></button> -->
|
||||
|
||||
{{range .Errors}}
|
||||
<div class="block">{{.}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{template "content" .}}
|
||||
|
||||
<div class="block has-text-centered has-text-grey">
|
||||
© {{.YYYY}} {{.Title}}
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<a href="/">Home</a>
|
||||
</div>
|
||||
<div class="column">
|
||||
<a href="/about">About</a>
|
||||
</div>
|
||||
{{if .LoggedIn}}
|
||||
<div class="column">
|
||||
<a href="/me">User Dashboard</a>
|
||||
</div>
|
||||
<div class="column">
|
||||
<a href="/u/{{.CurrentUser.Username}}">My Profile</a>
|
||||
</div>
|
||||
<div class="column">
|
||||
<a href="/settings">Settings</a>
|
||||
</div>
|
||||
<div class="column">
|
||||
<a href="/logout">Log out</a>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="column">
|
||||
<a href="/login">Log in</a>
|
||||
</div>
|
||||
<div class="column">
|
||||
<a href="/signup">Sign up</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript" src="/static/js/bulma.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
8
web/templates/email/base.html
Normal file
8
web/templates/email/base.html
Normal file
|
@ -0,0 +1,8 @@
|
|||
{{define "base"}}
|
||||
<html>
|
||||
<body bakground="#ffffff" color="#000000" link="#0000FF" vlink="#990099" alink="#FF0000">
|
||||
<basefont face="Arial,Helvetica,sans-serif" size="3" color="#000000"></basefont>
|
||||
{{template "content" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
22
web/templates/email/verify_email.html
Normal file
22
web/templates/email/verify_email.html
Normal file
|
@ -0,0 +1,22 @@
|
|||
{{define "content"}}
|
||||
<html>
|
||||
<body bakground="#ffffff" color="#000000" link="#0000FF" vlink="#990099" alink="#FF0000">
|
||||
<basefont face="Arial,Helvetica,sans-serif" size="3" color="#000000"></basefont>
|
||||
|
||||
<h1>Verify your email</h1>
|
||||
|
||||
<p>
|
||||
Welcome to {{.Data.Title}}! To get started creating your account, verify your e-mail address
|
||||
by clicking on the link below:
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="{{.Data.URL}}" target="_blank">{{.Data.URL}}</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This is an automated e-mail; do not reply to this message.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
15
web/templates/errors/error.html
Normal file
15
web/templates/errors/error.html
Normal file
|
@ -0,0 +1,15 @@
|
|||
{{define "content"}}
|
||||
<div class="container">
|
||||
<section class="hero block is-danger is-bold">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h1 class="title">{{.Header}}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="block">
|
||||
{{.Message}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
150
web/templates/index.html
Normal file
150
web/templates/index.html
Normal file
|
@ -0,0 +1,150 @@
|
|||
{{define "content"}}
|
||||
<div class="block">
|
||||
<section class="hero is-info is-bold">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h1 class="title">{{ .Title }}</h1>
|
||||
<h2 class="subtitle">{{ .Subtitle }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
<div class="columns">
|
||||
<div class="column content is-three-quarters p-4">
|
||||
<p>
|
||||
Welcome to <strong>{{.Title}}</strong>, a social network designed for <strong>real</strong>
|
||||
nudists and exhibitionists!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This website was designed by a life-long nudist, exhibitionist and software engineer to create
|
||||
a safe space for like-minded individuals online, especially in the modern online political
|
||||
climate and after Tumblr, Pornhub and other social networks had begun clamping down and kicking
|
||||
off all the nudists from their platforms.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This website is open to <em>all</em> nudists and exhibitionists, but I understand that not all
|
||||
nudists want to see any sexual content, so this site provides some controls to support
|
||||
both camps:
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
For <strong>nudists:</strong> a default setting on your profile will hide 'explicit content'
|
||||
from users' profile pages and you will not see the explicit web forums either.
|
||||
</li>
|
||||
<li>
|
||||
For <strong>exhibitionists:</strong> you can toggle that setting to view explicit content
|
||||
and mark your own explicit pictures as such so that nudists who don't want to see them
|
||||
don't have to.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h1>Site Rules</h1>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
🧑 Only <strong>real people</strong> are allowed to join. You must be comfortable showing your
|
||||
face on your profile page. You don't need to include your face in your nudes but a profile picture
|
||||
of your face is required.
|
||||
</li>
|
||||
<li>
|
||||
✅ <strong>Verification is mandatory.</strong> Along with the face picture on your profile page,
|
||||
you will need to take a selfie with a hand-written note on paper to verify that you're a real
|
||||
person.
|
||||
</li>
|
||||
<li>
|
||||
🤳 <strong>Self pictures only.</strong> You are expected to post only pictures that you're in.
|
||||
No "porn blogs" of random content you found online!
|
||||
</li>
|
||||
<li>
|
||||
😈 <strong><span class="has-text-danger">Explicit content</span> is permitted in designated areas only.</strong>
|
||||
Not all nudists want to see "sexual" content, but exhibitionists are welcome here too. If you want to upload
|
||||
sexually charged content, mark those pictures as 'explicit' when uploading them or post them only to the
|
||||
designated explicit forums so nudists who prefer not to see don't have to.
|
||||
</li>
|
||||
<li>
|
||||
🔞 You must be <strong class="has-text-danger">18 years or older</strong> to sign up for this website.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h1>Site Features</h1>
|
||||
|
||||
<p>
|
||||
This website is still a work in progress, but <em>eventually</em> it will have at least
|
||||
the following features and functions:
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Web forums</strong> where you can write posts and meet your fellow members in the comments.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Profile pages</strong> where you can write a bit about yourself and upload some of your
|
||||
nudist or exhibitionist pictures.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Direct messages</strong> where you can chat with other members on the site.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="column is-one-quarter">
|
||||
|
||||
{{if .LoggedIn}}
|
||||
<div class="card">
|
||||
<header class="card-header has-background-link">
|
||||
<p class="card-header-title has-text-light">Welcome back, {{.CurrentUser.Username}}!</p>
|
||||
</header>
|
||||
|
||||
<div class="card-content">
|
||||
Content to come here soon.
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card">
|
||||
<header class="card-header has-background-link">
|
||||
<p class="card-header-title has-text-light">Log In</p>
|
||||
</header>
|
||||
|
||||
<div class="card-content">
|
||||
<form action="/login" method="POST">
|
||||
{{ InputCSRF }}
|
||||
<div class="field">
|
||||
<label class="label" for="idx_username">Username</label>
|
||||
<input type="text" class="input"
|
||||
name="username"
|
||||
placeholder="username"
|
||||
id="idx_username"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="idx_password">Password</label>
|
||||
<input type="password" class="input"
|
||||
name="password"
|
||||
placeholder="password"
|
||||
id="idx_password"
|
||||
autocomplete="off">
|
||||
<a href="/forgot-password">Forgot?</a>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<button type="submit" class="button is-link is-fullwidth">Log in</button>
|
||||
</div>
|
||||
<div class="column">
|
||||
<a href="/signup" class="button is-secondary is-fullwidth">Sign up</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
Reference in New Issue
Block a user