commit 2a8a1df6ab9bf516d3887a41cc9442899cd87370 Author: Noah Petherbridge Date: Tue Aug 9 22:10:47 2022 -0700 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e3dfc5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/gosocial +database.sqlite +settings.json \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5635448 --- /dev/null +++ b/Makefile @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5fa3bda --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/cmd/gosocial/main.go b/cmd/gosocial/main.go new file mode 100644 index 0000000..5e25af9 --- /dev/null +++ b/cmd/gosocial/main.go @@ -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")) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e095042 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b35c09b --- /dev/null +++ b/go.sum @@ -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= diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..c97390e --- /dev/null +++ b/pkg/config/config.go @@ -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 +) diff --git a/pkg/config/variable.go b/pkg/config/variable.go new file mode 100644 index 0000000..ebd3d16 --- /dev/null +++ b/pkg/config/variable.go @@ -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 +} diff --git a/pkg/controller/account/dashboard.go b/pkg/controller/account/dashboard.go new file mode 100644 index 0000000..e83ed66 --- /dev/null +++ b/pkg/controller/account/dashboard.go @@ -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 + } + }) +} diff --git a/pkg/controller/account/login.go b/pkg/controller/account/login.go new file mode 100644 index 0000000..969792c --- /dev/null +++ b/pkg/controller/account/login.go @@ -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, "/") + }) +} diff --git a/pkg/controller/account/signup.go b/pkg/controller/account/signup.go new file mode 100644 index 0000000..188e1b5 --- /dev/null +++ b/pkg/controller/account/signup.go @@ -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 + } + }) +} diff --git a/pkg/controller/api/auth.go b/pkg/controller/api/auth.go new file mode 100644 index 0000000..cf4190a --- /dev/null +++ b/pkg/controller/api/auth.go @@ -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) + }) +} diff --git a/pkg/controller/api/version.go b/pkg/controller/api/version.go new file mode 100644 index 0000000..53d74bb --- /dev/null +++ b/pkg/controller/api/version.go @@ -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) + }) +} diff --git a/pkg/controller/index/index.go b/pkg/controller/index/index.go new file mode 100644 index 0000000..ec8e045 --- /dev/null +++ b/pkg/controller/index/index.go @@ -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 + } + }) +} diff --git a/pkg/controller/version/version.go b/pkg/controller/version/version.go new file mode 100644 index 0000000..741f938 --- /dev/null +++ b/pkg/controller/version/version.go @@ -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) + }) +} diff --git a/pkg/log/log.go b/pkg/log/log.go new file mode 100644 index 0000000..6bb7b78 --- /dev/null +++ b/pkg/log/log.go @@ -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) +} diff --git a/pkg/mail/mail.go b/pkg/mail/mail.go new file mode 100644 index 0000000..eb06d04 --- /dev/null +++ b/pkg/mail/mail.go @@ -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 +} diff --git a/pkg/middleware/authentication.go b/pkg/middleware/authentication.go new file mode 100644 index 0000000..1e1c8c2 --- /dev/null +++ b/pkg/middleware/authentication.go @@ -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) + }) +} diff --git a/pkg/middleware/csrf.go b/pkg/middleware/csrf.go new file mode 100644 index 0000000..9763065 --- /dev/null +++ b/pkg/middleware/csrf.go @@ -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 +} diff --git a/pkg/middleware/logging.go b/pkg/middleware/logging.go new file mode 100644 index 0000000..4b791ba --- /dev/null +++ b/pkg/middleware/logging.go @@ -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)) + }) +} diff --git a/pkg/middleware/recovery.go b/pkg/middleware/recovery.go new file mode 100644 index 0000000..84a83fc --- /dev/null +++ b/pkg/middleware/recovery.go @@ -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) + }) +} diff --git a/pkg/middleware/session.go b/pkg/middleware/session.go new file mode 100644 index 0000000..1629840 --- /dev/null +++ b/pkg/middleware/session.go @@ -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)) + }) +} diff --git a/pkg/models/models.go b/pkg/models/models.go new file mode 100644 index 0000000..954ad54 --- /dev/null +++ b/pkg/models/models.go @@ -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{}) +} diff --git a/pkg/models/user.go b/pkg/models/user.go new file mode 100644 index 0000000..6fa98c8 --- /dev/null +++ b/pkg/models/user.go @@ -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 +} diff --git a/pkg/redis/redis.go b/pkg/redis/redis.go new file mode 100644 index 0000000..9d47be9 --- /dev/null +++ b/pkg/redis/redis.go @@ -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() +} diff --git a/pkg/router/router.go b/pkg/router/router.go new file mode 100644 index 0000000..08743bd --- /dev/null +++ b/pkg/router/router.go @@ -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 +} diff --git a/pkg/router/template.go b/pkg/router/template.go new file mode 100644 index 0000000..aeefac8 --- /dev/null +++ b/pkg/router/template.go @@ -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 +} diff --git a/pkg/session/current_user.go b/pkg/session/current_user.go new file mode 100644 index 0000000..5105a0f --- /dev/null +++ b/pkg/session/current_user.go @@ -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") +} diff --git a/pkg/session/session.go b/pkg/session/session.go new file mode 100644 index 0000000..8fb8b4b --- /dev/null +++ b/pkg/session/session.go @@ -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) +} diff --git a/pkg/templates/error_pages.go b/pkg/templates/error_pages.go new file mode 100644 index 0000000..f4c52b5 --- /dev/null +++ b/pkg/templates/error_pages.go @@ -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 + } + }) +} diff --git a/pkg/templates/redirect.go b/pkg/templates/redirect.go new file mode 100644 index 0000000..79e921d --- /dev/null +++ b/pkg/templates/redirect.go @@ -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) +} diff --git a/pkg/templates/template_funcs.go b/pkg/templates/template_funcs.go new file mode 100644 index 0000000..2915991 --- /dev/null +++ b/pkg/templates/template_funcs.go @@ -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( + ``, + config.CSRFInputName, + token, + )) + } else { + return template.HTML(`[CSRF middleware error]`) + } + } +} diff --git a/pkg/templates/template_vars.go b/pkg/templates/template_vars.go new file mode 100644 index 0000000..c779b3e --- /dev/null +++ b/pkg/templates/template_vars.go @@ -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 + } +} diff --git a/pkg/templates/templates.go b/pkg/templates/templates.go new file mode 100644 index 0000000..ba1b852 --- /dev/null +++ b/pkg/templates/templates.go @@ -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 +} diff --git a/pkg/version.go b/pkg/version.go new file mode 100644 index 0000000..5f79bdf --- /dev/null +++ b/pkg/version.go @@ -0,0 +1,3 @@ +package gosocial + +const Version = "0.0.1" diff --git a/pkg/webserver.go b/pkg/webserver.go new file mode 100644 index 0000000..46e02c3 --- /dev/null +++ b/pkg/webserver.go @@ -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() +} diff --git a/web/static/img/shy.png b/web/static/img/shy.png new file mode 100644 index 0000000..a957fb6 Binary files /dev/null and b/web/static/img/shy.png differ diff --git a/web/static/js/bulma.js b/web/static/js/bulma.js new file mode 100644 index 0000000..dae2b12 --- /dev/null +++ b/web/static/js/bulma.js @@ -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'); + + }); + }); + + }); \ No newline at end of file diff --git a/web/static/test.txt b/web/static/test.txt new file mode 100644 index 0000000..fc6cf14 --- /dev/null +++ b/web/static/test.txt @@ -0,0 +1 @@ +it's here diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html new file mode 100644 index 0000000..5e6b119 --- /dev/null +++ b/web/templates/account/dashboard.html @@ -0,0 +1,45 @@ +{{define "content"}} +
+
+
+
+

User Dashboard

+

to your account

+
+
+
+ +
+
+
+
+ + + +
+
+ +
+
+
+

Notifications

+
+ +
+ TBD. +
+
+
+
+
+
+{{end}} \ No newline at end of file diff --git a/web/templates/account/login.html b/web/templates/account/login.html new file mode 100644 index 0000000..39d3ea0 --- /dev/null +++ b/web/templates/account/login.html @@ -0,0 +1,26 @@ +{{define "content"}} +
+
+
+
+

Sign in

+

to your account

+
+
+
+ +
+
+ {{ InputCSRF }} + + + + + + + + +
+
+
+{{end}} \ No newline at end of file diff --git a/web/templates/account/signup.html b/web/templates/account/signup.html new file mode 100644 index 0000000..6e7f346 --- /dev/null +++ b/web/templates/account/signup.html @@ -0,0 +1,139 @@ +{{define "content"}} +
+
+
+
+

Sign up

+
+
+
+ +
+ +
{{.}}
+ + {{if or .SkipEmailVerification (not .SignupToken)}} +

+ I'm glad you're thinking about joining us here! +

+ +

+ 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 upload a face picture to your profile (it + doesn't have to be a nude, but does have to show your face!) and you will need to + submit a verification selfie to prove that the person in that picture is you. +

+ +

+ The verification selfie 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! +

+ +

Site Rules

+ +
    +
  • + 🧑 Only real people 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. +
  • +
  • + ✅ Verification is mandatory. 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. +
  • +
  • + 🤳 Self pictures only. You are expected to post only pictures that you're in. + No "porn blogs" of random content you found online! +
  • +
  • + 😈 Explicit content is permitted in designated areas only. + 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. +
  • +
  • + 🔞 You must be 18 years or older to sign up for this website. +
  • +
+ +

Onboarding

+ +

+ Here is what you can expect from the sign-up process: +

+ +
    +
  1. Email address: you will be emailed a link to verify control of that email inbox.
  2. +
  3. Account creation: you will create a username, password, and upload a face pic for your profile page.
  4. +
  5. Verification: you will take a verification selfie to prove you're the person in that profile pic.
  6. +
  7. Approval: an admin will review your verification selfie and you will become a full member of this site!
  8. +
+ {{end}} + +

Sign Up

+ +

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

+ +
+ {{ InputCSRF }} + {{if .SignupToken}} + + {{end}} + +
+ + +
+ + {{if or .SignupToken .SkipEmailVerification}} +
+ + + Usernames are 3 to 32 characters a-z 0-9 . - +
+
+ + +
+
+ + +
+ {{end}} + +
+ +
+ +
+ +
+
+
+
+{{end}} \ No newline at end of file diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..d66dbc1 --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,162 @@ +{{define "base"}} + + + + + + + {{ .Title }} + + +
+ + + {{if .Flashes}} +
+ + + {{range .Flashes}} +
{{.}}
+ {{end}} +
+ {{end}} + + {{if .Errors}} +
+ + + {{range .Errors}} +
{{.}}
+ {{end}} +
+ {{end}} + + {{template "content" .}} + +
+ © {{.YYYY}} {{.Title}} +
+
+ Home +
+
+ About +
+ {{if .LoggedIn}} + + +
+ Settings +
+
+ Log out +
+ {{else}} +
+ Log in +
+
+ Sign up +
+ {{end}} +
+
+
+ + + + + +{{end}} \ No newline at end of file diff --git a/web/templates/email/base.html b/web/templates/email/base.html new file mode 100644 index 0000000..e3e8c65 --- /dev/null +++ b/web/templates/email/base.html @@ -0,0 +1,8 @@ +{{define "base"}} + + + + {{template "content" .}} + + +{{end}} \ No newline at end of file diff --git a/web/templates/email/verify_email.html b/web/templates/email/verify_email.html new file mode 100644 index 0000000..096115b --- /dev/null +++ b/web/templates/email/verify_email.html @@ -0,0 +1,22 @@ +{{define "content"}} + + + + +

Verify your email

+ +

+ Welcome to {{.Data.Title}}! To get started creating your account, verify your e-mail address + by clicking on the link below: +

+ +

+ {{.Data.URL}} +

+ +

+ This is an automated e-mail; do not reply to this message. +

+ + +{{end}} \ No newline at end of file diff --git a/web/templates/errors/error.html b/web/templates/errors/error.html new file mode 100644 index 0000000..ab23346 --- /dev/null +++ b/web/templates/errors/error.html @@ -0,0 +1,15 @@ +{{define "content"}} +
+
+
+
+

{{.Header}}

+
+
+
+ +
+ {{.Message}} +
+
+{{end}} \ No newline at end of file diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..261b0b7 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,150 @@ +{{define "content"}} +
+
+
+
+

{{ .Title }}

+

{{ .Subtitle }}

+
+
+
+
+ +
+
+
+

+ Welcome to {{.Title}}, a social network designed for real + nudists and exhibitionists! +

+ +

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

+ +

+ This website is open to all 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: +

+ +
    +
  • + For nudists: a default setting on your profile will hide 'explicit content' + from users' profile pages and you will not see the explicit web forums either. +
  • +
  • + For exhibitionists: 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. +
  • +
+ +

Site Rules

+ +
    +
  • + 🧑 Only real people 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. +
  • +
  • + ✅ Verification is mandatory. 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. +
  • +
  • + 🤳 Self pictures only. You are expected to post only pictures that you're in. + No "porn blogs" of random content you found online! +
  • +
  • + 😈 Explicit content is permitted in designated areas only. + 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. +
  • +
  • + 🔞 You must be 18 years or older to sign up for this website. +
  • +
+ +

Site Features

+ +

+ This website is still a work in progress, but eventually it will have at least + the following features and functions: +

+ +
    +
  • + Web forums where you can write posts and meet your fellow members in the comments. +
  • +
  • + Profile pages where you can write a bit about yourself and upload some of your + nudist or exhibitionist pictures. +
  • +
  • + Direct messages where you can chat with other members on the site. +
  • +
+
+ +
+ + {{if .LoggedIn}} +
+ + +
+ Content to come here soon. +
+
+ {{else}} +
+ + +
+
+ {{ InputCSRF }} +
+ + +
+ +
+ + + Forgot? +
+ +
+
+ +
+
+ Sign up +
+
+
+
+
+ {{end}} +
+
+
+{{end}} \ No newline at end of file