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:
Noah 2022-08-09 22:10:47 -07:00
commit dd1e6c2918
47 changed files with 2685 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/gosocial
database.sqlite
settings.json

23
Makefile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

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

View 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, "/")
})
}

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

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

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

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

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

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

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

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

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

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

View 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]`)
}
}
}

View 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
View 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
View File

@ -0,0 +1,3 @@
package gosocial
const Version = "0.0.1"

35
pkg/webserver.go Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

22
web/static/js/bulma.js Normal file
View 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
View File

@ -0,0 +1 @@
it's here

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

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

View 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
View 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">
&copy; {{.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}}

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

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

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