commit d2eba2e5a8afd1860f24bebf86c48cabff67b933 Author: Paul Date: Tue May 14 14:11:03 2019 +0200 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e6ce01f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.jpg filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/assets/.gitignore b/assets/.gitignore new file mode 100644 index 0000000..3a86f16 --- /dev/null +++ b/assets/.gitignore @@ -0,0 +1 @@ +*_vfsdata.go \ No newline at end of file diff --git a/assets/dev.go b/assets/dev.go new file mode 100644 index 0000000..b1e2f54 --- /dev/null +++ b/assets/dev.go @@ -0,0 +1,32 @@ +// +build dev + +package assets + +import ( + "go/build" + "log" + "net/http" + + "github.com/shurcooL/httpfs/union" +) + +// Assets contains files that will be included in the binary +// this is a union file system, so to reach the expected file +// the root folder defined in the map should be prepended +// to the file path +var Assets = union.New(map[string]http.FileSystem{ + "/migrations": http.Dir(importPathToDir("bitmask.me/skeleton/assets/migrations")), + "/templates": http.Dir(importPathToDir("bitmask.me/skeleton/assets/templates")), + "/static": http.Dir(importPathToDir("bitmask.me/skeleton/assets/static")), +}) + +// importPathToDir is a helper function that resolves the absolute path of +// modules, so they can be used both in dev mode (`-tags="dev"`) or with a +// generated static asset file (`go generate`). +func importPathToDir(importPath string) string { + p, err := build.Import(importPath, "", build.FindOnly) + if err != nil { + log.Fatalln(err) + } + return p.Dir +} diff --git a/assets/doc.go b/assets/doc.go new file mode 100644 index 0000000..9b86199 --- /dev/null +++ b/assets/doc.go @@ -0,0 +1,6 @@ +//go:generate vfsgendev -source="bitmask.me/skeleton/assets".Assets + +// Package assets contains assets for the service, that will be embedded into +// the binary. +// Generate by running `go generate bitmask.me/skeleton/assets`. +package assets diff --git a/assets/migrations/0_empty.up.sql b/assets/migrations/0_empty.up.sql new file mode 100644 index 0000000..e7ffa25 --- /dev/null +++ b/assets/migrations/0_empty.up.sql @@ -0,0 +1 @@ +-- this file is only here so the database can track a completely empty state. \ No newline at end of file diff --git a/assets/static/css/index.html b/assets/static/css/index.html new file mode 100644 index 0000000..e69de29 diff --git a/assets/static/images/index.html b/assets/static/images/index.html new file mode 100644 index 0000000..e69de29 diff --git a/assets/static/index.html b/assets/static/index.html new file mode 100644 index 0000000..e69de29 diff --git a/assets/templates/includes/smiley.tmpl b/assets/templates/includes/smiley.tmpl new file mode 100644 index 0000000..875fd5b --- /dev/null +++ b/assets/templates/includes/smiley.tmpl @@ -0,0 +1,3 @@ +{{ define "smiley" }} +:) +{{- end }} \ No newline at end of file diff --git a/assets/templates/layouts/landing.tmpl b/assets/templates/layouts/landing.tmpl new file mode 100644 index 0000000..2812abe --- /dev/null +++ b/assets/templates/layouts/landing.tmpl @@ -0,0 +1,17 @@ + + + + + + + + Landing Page + + + +

Skeleton Project

+ + If you are an admin, you can Log in. + + + \ No newline at end of file diff --git a/cmd/cmd.go b/cmd/cmd.go new file mode 100644 index 0000000..2dfefb4 --- /dev/null +++ b/cmd/cmd.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "log" + "os" + + cli "github.com/jawher/mow.cli" + "bitmask.me/skeleton/internal/app" + "bitmask.me/skeleton/internal/database" + "bitmask.me/skeleton/internal/web" +) + +// Execute is the main entrypoint for this program +func Execute() { + var ac = app.New(app.ConfigFromEnv()) + + root := cli.App("skeleton", "") + + root.Command("web server", "run a web server", func(cmd *cli.Cmd) { + cmd.Spec = "[ --http ]" + + var ( + listenAddr = cmd.StringOpt("l addr http", ":8080", "Listen address") + ) + + cmd.Action = func() { + log.Printf("Web Server listening on %s", *listenAddr) + web.RunServer(ac, *listenAddr) + } + }) + + root.Command("migrate", "alter the database schema", func(cmd *cli.Cmd) { + cmd.Spec = "--up | --revision | --drop-all" + + var ( + _ = cmd.BoolOpt("u up", false, "migrate to most recent revision") + drop = cmd.BoolOpt("drop-all", false, "USE WITH CARE: Completely devestate the database, use --revision=0 instead") + rev = cmd.IntOpt("r revision", database.MigrateUp, "revision that should be migrated to, defaults to most recent") + ) + + cmd.Action = func() { + db := ac.Database() + + if *drop { + *rev = database.MigrateDrop + } + + if err := database.Migrate(db, ac.Files, *rev); err != nil { + log.Fatalf("Migration failed due to error: %s", err) + } + } + + }) + root.Run(os.Args) +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..86f3116 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,101 @@ +package app + +import ( + "net/http" + "sync" + + "github.com/caarlos0/env" + "github.com/jmoiron/sqlx" + "bitmask.me/skeleton/assets" + "bitmask.me/skeleton/internal/database" + "bitmask.me/skeleton/internal/storage" + "bitmask.me/skeleton/internal/templates" +) + +// Config contains all neccessary configuration for the App +type Config struct { + DatabaseDSN string `env:"DATABASE_DSN"` + + S3Key string `env:"S3_KEY"` + S3Secret string `env:"S3_SECRET"` + S3Location string `env:"S3_LOCATION"` + S3Endpoint string `env:"S3_ENDPOINT"` + S3SSL bool `env:"S3_SSL"` + S3Bucket string `env:"S3_BUCKET"` +} + +// ConfigFromEnv loads the configuration from environment variables +func ConfigFromEnv() *Config { + config := &Config{} + env.Parse(config) + + return config +} + +// App contains the dependencies for this application. +type App struct { + config *Config + Files http.FileSystem + database *sqlx.DB + + // guard for lazy initialization of s3 client + storageOnce sync.Once + storage *storage.Client + + // guard for lazy template loading + templatesOnce sync.Once + templates templates.Templates +} + +func (c *App) Templates() templates.Templates { + c.templatesOnce.Do(func() { + c.templates = templates.LoadTemplatesFS(c.Files, "/templates") + }) + return c.templates +} + +func (c *App) Storage() *storage.Client { + c.storageOnce.Do(func() { + // ignore error since we will handle the nonfunctional client + // later down the line. + c.storage, _ = storage.New(&storage.Config{ + Key: c.config.S3Key, + Secret: c.config.S3Secret, + Location: c.config.S3Location, + Endpoint: c.config.S3Endpoint, + SSL: c.config.S3SSL, + Bucket: c.config.S3Bucket, + }) + }) + return c.storage +} + +func (c *App) Database() *sqlx.DB { + return c.database +} + +// NewContext creates a new App from a config +func New(config *Config) *App { + context := &App{ + config: config, + Files: assets.Assets, + } + + initialize(context) + return context +} + +func initialize(app *App) { + var err error + + app.database, err = database.New(app.config.DatabaseDSN) + if err != nil { + // Since we are not sending any data yet, any error occuring here + // is likely result of a missing driver or wrong parameters. + panic(err) + } + + if err != nil { + panic(err) + } +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..9ae3ab5 --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,55 @@ +package database + +//go:generate sh -c "rm -f *.xo.go" +//go:generate xo postgres://paul:password@localhost/paul?sslmode=disable --escape-all --template-path templates/ --package database -o . +//go:generate sh -c "rm -f schemamigration.xo.go" +//go:generate + +import ( + "database/sql" + + "github.com/jmoiron/sqlx" + "github.com/lib/pq" +) + +// New initializes a new postgres database connection pool +func New(dsn string) (*sqlx.DB, error) { + conn, err := sqlx.Open("postgres", dsn) + return conn, err +} + +func IsErrNoRows(err error) bool { + return err == sql.ErrNoRows +} + +func IsErrUniqueViolation(err error) bool { + // see if we can cast the error to a Database error type + pqErr, ok := err.(*pq.Error) + if !ok { + // Wrong error type + return false + } + + // check if error is "unique constraint violation" + if pqErr.Code != "23505" { + // if thats NOT the case, it's another error + return false + } + return true +} + +func IsErrForeignKeyViolation(err error) bool { + // see if we can cast the error to a Database error type + pqErr, ok := err.(*pq.Error) + if !ok { + // Wrong error type + return false + } + + // check if error is "foreign key violation" + if pqErr.Code != "23503" { + // if thats NOT the case, it's another error + return false + } + return true +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go new file mode 100644 index 0000000..3d57dad --- /dev/null +++ b/internal/database/migrate.go @@ -0,0 +1,92 @@ +package database + +import ( + "database/sql" + "log" + "net/http" + "os" + + vfs "github.com/ailox/migrate-vfs" + "github.com/golang-migrate/migrate" + "github.com/golang-migrate/migrate/database/postgres" + "github.com/jmoiron/sqlx" +) + +// GetMigrator returns a Database Migrator for PostgreSQL. +func getMigrator(db *sql.DB, fs http.FileSystem, path string) (*migrate.Migrate, error) { + vfsSource, err := vfs.WithInstance(fs, path) + if err != nil { + return nil, err + } + driver, err := postgres.WithInstance(db, &postgres.Config{}) + if err != nil { + return nil, err + } + + // the strings are only for logging purpose + migrator, err := migrate.NewWithInstance( + // if the linter throws an error here because "source" doesnt + // match the correct type, this error can be safely ignored. + "vfs-dir", vfsSource, + "paul", driver, + ) + if err != nil { + return nil, err + } + + return migrator, err +} + +type wrappedLogger struct { + *log.Logger +} + +func (l wrappedLogger) Verbose() bool { + return true +} + +const ( + // MigrateDrop is the revision number that will cause all tables to be + // dropped from the database. + MigrateDrop = -3 + // MigrateUp is the revision number that will cause the database to be + // migrated to the very latest revision. + MigrateUp = -1 +) + +// Migrate Migrates the schema of the supplied Database. Supported +// methods are: +// * database.MigrateUp – Migrate to the latest version +// * database.MigrateDrop – empty everything +// * `(integer)` – Migrate to specific version +func Migrate(db *sqlx.DB, fs http.FileSystem, revision int) error { + // migrate database + var migrationPathInFs string + + migrationPathInFs = "/migrations" + + migrator, err := getMigrator(db.DB, fs, migrationPathInFs) + if err != nil { + return err + } + + wl := wrappedLogger{log.New(os.Stdout, "[MIGRATIONS] ", log.LstdFlags)} + migrator.Log = wl + + switch revision { + case MigrateUp: + err = migrator.Up() + case MigrateDrop: + err = migrator.Drop() + default: + err = migrator.Migrate(uint(revision)) + } + + if err == migrate.ErrNoChange { + wl.Println("no change") + } else if err != nil { + return err + } + + return nil +} diff --git a/internal/database/templates/postgres.enum.go.tpl b/internal/database/templates/postgres.enum.go.tpl new file mode 100644 index 0000000..48e98ed --- /dev/null +++ b/internal/database/templates/postgres.enum.go.tpl @@ -0,0 +1,62 @@ +{{- $type := .Name -}} +{{- $short := (shortname $type "enumVal" "text" "buf" "ok" "src") -}} +{{- $reverseNames := .ReverseConstNames -}} +// {{ $type }} is the '{{ .Enum.EnumName }}' enum type from schema '{{ .Schema }}'. +type {{ $type }} uint16 + +const ( +{{- range .Values }} + // {{ if $reverseNames }}{{ .Name }}{{ $type }}{{ else }}{{ $type }}{{ .Name }}{{ end }} is the '{{ .Val.EnumValue }}' {{ $type }}. + {{ if $reverseNames }}{{ .Name }}{{ $type }}{{ else }}{{ $type }}{{ .Name }}{{ end }} = {{ $type }}({{ .Val.ConstValue }}) +{{ end -}} +) + +// String returns the string value of the {{ $type }}. +func ({{ $short }} {{ $type }}) String() string { + var enumVal string + + switch {{ $short }} { +{{- range .Values }} + case {{ if $reverseNames }}{{ .Name }}{{ $type }}{{ else }}{{ $type }}{{ .Name }}{{ end }}: + enumVal = "{{ .Val.EnumValue }}" +{{ end -}} + } + + return enumVal +} + +// MarshalText marshals {{ $type }} into text. +func ({{ $short }} {{ $type }}) MarshalText() ([]byte, error) { + return []byte({{ $short }}.String()), nil +} + +// UnmarshalText unmarshals {{ $type }} from text. +func ({{ $short }} *{{ $type }}) UnmarshalText(text []byte) error { + switch string(text) { +{{- range .Values }} + case "{{ .Val.EnumValue }}": + *{{ $short }} = {{ if $reverseNames }}{{ .Name }}{{ $type }}{{ else }}{{ $type }}{{ .Name }}{{ end }} +{{ end }} + + default: + return errors.New("invalid {{ $type }}") + } + + return nil +} + +// Value satisfies the sql/driver.Valuer interface for {{ $type }}. +func ({{ $short }} {{ $type }}) Value() (driver.Value, error) { + return {{ $short }}.String(), nil +} + +// Scan satisfies the database/sql.Scanner interface for {{ $type }}. +func ({{ $short }} *{{ $type }}) Scan(src interface{}) error { + buf, ok := src.([]byte) + if !ok { + return errors.New("invalid {{ $type }}") + } + + return {{ $short }}.UnmarshalText(buf) +} + diff --git a/internal/database/templates/postgres.foreignkey.go.tpl b/internal/database/templates/postgres.foreignkey.go.tpl new file mode 100644 index 0000000..e8677b0 --- /dev/null +++ b/internal/database/templates/postgres.foreignkey.go.tpl @@ -0,0 +1,8 @@ +{{- $short := (shortname .Type.Name) -}} +// {{ .Name }} returns the {{ .RefType.Name }} associated with the {{ .Type.Name }}'s {{ .Field.Name }} ({{ .Field.Col.ColumnName }}). +// +// Generated from foreign key '{{ .ForeignKey.ForeignKeyName }}'. +func ({{ $short }} *{{ .Type.Name }}) {{ .Name }}(db XODB) (*{{ .RefType.Name }}, error) { + return {{ .RefType.Name }}By{{ .RefField.Name }}(db, {{ convext $short .Field .RefField }}) +} + diff --git a/internal/database/templates/postgres.index.go.tpl b/internal/database/templates/postgres.index.go.tpl new file mode 100644 index 0000000..88b3ab6 --- /dev/null +++ b/internal/database/templates/postgres.index.go.tpl @@ -0,0 +1,58 @@ +{{- $short := (shortname .Type.Name "err" "sqlstr" "db" "q" "res" "XOLog" .Fields) -}} +{{- $table := (schema .Schema .Type.Table.TableName) -}} +// {{ .FuncName }} retrieves a row from '{{ $table }}' as a {{ .Type.Name }}. +// +// Generated from index '{{ .Index.IndexName }}'. +func {{ .FuncName }}(db XODB{{ goparamlist .Fields true true }}) ({{ if not .Index.IsUnique }}[]{{ end }}*{{ .Type.Name }}, error) { + var err error + + // sql query + const sqlstr = `SELECT ` + + `{{ colnames .Type.Fields }} ` + + `FROM {{ $table }} ` + + `WHERE {{ colnamesquery .Fields " AND " }}` + + // run query + XOLog(sqlstr{{ goparamlist .Fields true false }}) +{{- if .Index.IsUnique }} + {{ $short }} := {{ .Type.Name }}{ + {{- if .Type.PrimaryKey }} + _exists: true, + {{ end -}} + } + + err = db.QueryRow(sqlstr{{ goparamlist .Fields true false }}).Scan({{ fieldnames .Type.Fields (print "&" $short) }}) + if err != nil { + return nil, err + } + + return &{{ $short }}, nil +{{- else }} + q, err := db.Query(sqlstr{{ goparamlist .Fields true false }}) + if err != nil { + return nil, err + } + defer q.Close() + + // load results + res := []*{{ .Type.Name }}{} + for q.Next() { + {{ $short }} := {{ .Type.Name }}{ + {{- if .Type.PrimaryKey }} + _exists: true, + {{ end -}} + } + + // scan + err = q.Scan({{ fieldnames .Type.Fields (print "&" $short) }}) + if err != nil { + return nil, err + } + + res = append(res, &{{ $short }}) + } + + return res, nil +{{- end }} +} + diff --git a/internal/database/templates/postgres.proc.go.tpl b/internal/database/templates/postgres.proc.go.tpl new file mode 100644 index 0000000..da215aa --- /dev/null +++ b/internal/database/templates/postgres.proc.go.tpl @@ -0,0 +1,28 @@ +{{- $notVoid := (ne .Proc.ReturnType "void") -}} +{{- $proc := (schema .Schema .Proc.ProcName) -}} +{{- if ne .Proc.ReturnType "trigger" -}} +// {{ .Name }} calls the stored procedure '{{ $proc }}({{ .ProcParams }}) {{ .Proc.ReturnType }}' on db. +func {{ .Name }}(db XODB{{ goparamlist .Params true true }}) ({{ if $notVoid }}{{ retype .Return.Type }}, {{ end }}error) { + var err error + + // sql query + const sqlstr = `SELECT {{ $proc }}({{ colvals .Params }})` + + // run query +{{- if $notVoid }} + var ret {{ retype .Return.Type }} + XOLog(sqlstr{{ goparamlist .Params true false }}) + err = db.QueryRow(sqlstr{{ goparamlist .Params true false }}).Scan(&ret) + if err != nil { + return {{ reniltype .Return.NilType }}, err + } + + return ret, nil +{{- else }} + XOLog(sqlstr) + _, err = db.Exec(sqlstr) + return err +{{- end }} +} +{{- end }} + diff --git a/internal/database/templates/postgres.query.go.tpl b/internal/database/templates/postgres.query.go.tpl new file mode 100644 index 0000000..de4e839 --- /dev/null +++ b/internal/database/templates/postgres.query.go.tpl @@ -0,0 +1,49 @@ +{{- $short := (shortname .Type.Name "err" "sqlstr" "db" "q" "res" "XOLog" .QueryParams) -}} +{{- $queryComments := .QueryComments -}} +{{- if .Comment -}} +// {{ .Comment }} +{{- else -}} +// {{ .Name }} runs a custom query, returning results as {{ .Type.Name }}. +{{- end }} +func {{ .Name }} (db XODB{{ range .QueryParams }}, {{ .Name }} {{ .Type }}{{ end }}) ({{ if not .OnlyOne }}[]{{ end }}*{{ .Type.Name }}, error) { + var err error + + // sql query + {{ if .Interpolate }}var{{ else }}const{{ end }} sqlstr = {{ range $i, $l := .Query }}{{ if $i }} +{{ end }}{{ if (index $queryComments $i) }} // {{ index $queryComments $i }}{{ end }}{{ if $i }} + {{end -}}`{{ $l }}`{{ end }} + + // run query + XOLog(sqlstr{{ range .QueryParams }}{{ if not .Interpolate }}, {{ .Name }}{{ end }}{{ end }}) +{{- if .OnlyOne }} + var {{ $short }} {{ .Type.Name }} + err = db.QueryRow(sqlstr{{ range .QueryParams }}, {{ .Name }}{{ end }}).Scan({{ fieldnames .Type.Fields (print "&" $short) }}) + if err != nil { + return nil, err + } + + return &{{ $short }}, nil +{{- else }} + q, err := db.Query(sqlstr{{ range .QueryParams }}, {{ .Name }}{{ end }}) + if err != nil { + return nil, err + } + defer q.Close() + + // load results + res := []*{{ .Type.Name }}{} + for q.Next() { + {{ $short }} := {{ .Type.Name }}{} + + // scan + err = q.Scan({{ fieldnames .Type.Fields (print "&" $short) }}) + if err != nil { + return nil, err + } + + res = append(res, &{{ $short }}) + } + + return res, nil +{{- end }} +} + diff --git a/internal/database/templates/postgres.querytype.go.tpl b/internal/database/templates/postgres.querytype.go.tpl new file mode 100644 index 0000000..f2b37b1 --- /dev/null +++ b/internal/database/templates/postgres.querytype.go.tpl @@ -0,0 +1,12 @@ +{{- $table := (schema .Schema .Table.TableName) -}} +{{- if .Comment -}} +// {{ .Comment }} +{{- else -}} +// {{ .Name }} represents a row from '{{ $table }}'. +{{- end }} +type {{ .Name }} struct { +{{- range .Fields }} + {{ .Name }} {{ retype .Type }} // {{ .Col.ColumnName }} +{{- end }} +} + diff --git a/internal/database/templates/postgres.type.go.tpl b/internal/database/templates/postgres.type.go.tpl new file mode 100644 index 0000000..fe2e66b --- /dev/null +++ b/internal/database/templates/postgres.type.go.tpl @@ -0,0 +1,206 @@ +{{- $short := (shortname .Name "err" "res" "sqlstr" "db" "XOLog") -}} +{{- $table := (schema .Schema .Table.TableName) -}} +{{- if .Comment -}} +// {{ .Comment }} +{{- else -}} +// {{ .Name }} represents a row from '{{ $table }}'. +{{- end }} +type {{ .Name }} struct { +{{- range .Fields }} + {{ .Name }} {{ retype .Type }} `db:"{{ .Col.ColumnName }}"` // {{ .Col.ColumnName }} +{{- end }} +{{- if .PrimaryKey }} + + // xo fields + _exists, _deleted bool +{{ end }} +} + +{{ if .PrimaryKey }} +// Exists determines if the {{ .Name }} exists in the database. +func ({{ $short }} *{{ .Name }}) Exists() bool { + return {{ $short }}._exists +} + +// Deleted provides information if the {{ .Name }} has been deleted from the database. +func ({{ $short }} *{{ .Name }}) Deleted() bool { + return {{ $short }}._deleted +} + +// Insert inserts the {{ .Name }} to the database. +func ({{ $short }} *{{ .Name }}) Insert(db XODB) error { + var err error + + // if already exist, bail + if {{ $short }}._exists { + return errors.New("insert failed: already exists") + } + +{{ if .Table.ManualPk }} + // sql insert query, primary key must be provided + const sqlstr = `INSERT INTO {{ $table }} (` + + `{{ colnames .Fields }}` + + `) VALUES (` + + `{{ colvals .Fields }}` + + `)` + + // run query + XOLog(sqlstr, {{ fieldnames .Fields $short }}) + err = db.QueryRow(sqlstr, {{ fieldnames .Fields $short }}).Scan(&{{ $short }}.{{ .PrimaryKey.Name }}) + if err != nil { + return err + } +{{ else }} + // sql insert query, primary key provided by sequence + const sqlstr = `INSERT INTO {{ $table }} (` + + `{{ colnames .Fields .PrimaryKey.Name }}` + + `) VALUES (` + + `{{ colvals .Fields .PrimaryKey.Name }}` + + `) RETURNING {{ colname .PrimaryKey.Col }}` + + // run query + XOLog(sqlstr, {{ fieldnames .Fields $short .PrimaryKey.Name }}) + err = db.QueryRow(sqlstr, {{ fieldnames .Fields $short .PrimaryKey.Name }}).Scan(&{{ $short }}.{{ .PrimaryKey.Name }}) + if err != nil { + return err + } +{{ end }} + + // set existence + {{ $short }}._exists = true + + return nil +} + +{{ if ne (fieldnamesmulti .Fields $short .PrimaryKeyFields) "" }} + // Update updates the {{ .Name }} in the database. + func ({{ $short }} *{{ .Name }}) Update(db XODB) error { + var err error + + // if doesn't exist, bail + if !{{ $short }}._exists { + return errors.New("update failed: does not exist") + } + + // if deleted, bail + if {{ $short }}._deleted { + return errors.New("update failed: marked for deletion") + } + + {{ if gt ( len .PrimaryKeyFields ) 1 }} + // sql query with composite primary key + const sqlstr = `UPDATE {{ $table }} SET (` + + `{{ colnamesmulti .Fields .PrimaryKeyFields }}` + + `) = ( ` + + `{{ colvalsmulti .Fields .PrimaryKeyFields }}` + + `) WHERE {{ colnamesquerymulti .PrimaryKeyFields " AND " (getstartcount .Fields .PrimaryKeyFields) nil }}` + + // run query + XOLog(sqlstr, {{ fieldnamesmulti .Fields $short .PrimaryKeyFields }}, {{ fieldnames .PrimaryKeyFields $short}}) + _, err = db.Exec(sqlstr, {{ fieldnamesmulti .Fields $short .PrimaryKeyFields }}, {{ fieldnames .PrimaryKeyFields $short}}) + return err + {{- else }} + // sql query + const sqlstr = `UPDATE {{ $table }} SET (` + + `{{ colnames .Fields .PrimaryKey.Name }}` + + `) = ( ` + + `{{ colvals .Fields .PrimaryKey.Name }}` + + `) WHERE {{ colname .PrimaryKey.Col }} = ${{ colcount .Fields .PrimaryKey.Name }}` + + // run query + XOLog(sqlstr, {{ fieldnames .Fields $short .PrimaryKey.Name }}, {{ $short }}.{{ .PrimaryKey.Name }}) + _, err = db.Exec(sqlstr, {{ fieldnames .Fields $short .PrimaryKey.Name }}, {{ $short }}.{{ .PrimaryKey.Name }}) + return err + {{- end }} + } + + // Save saves the {{ .Name }} to the database. + func ({{ $short }} *{{ .Name }}) Save(db XODB) error { + if {{ $short }}.Exists() { + return {{ $short }}.Update(db) + } + + return {{ $short }}.Insert(db) + } + + // Upsert performs an upsert for {{ .Name }}. + // + // NOTE: PostgreSQL 9.5+ only + func ({{ $short }} *{{ .Name }}) Upsert(db XODB) error { + var err error + + // if already exist, bail + if {{ $short }}._exists { + return errors.New("insert failed: already exists") + } + + // sql query + const sqlstr = `INSERT INTO {{ $table }} (` + + `{{ colnames .Fields }}` + + `) VALUES (` + + `{{ colvals .Fields }}` + + `) ON CONFLICT ({{ colnames .PrimaryKeyFields }}) DO UPDATE SET (` + + `{{ colnames .Fields }}` + + `) = (` + + `{{ colprefixnames .Fields "EXCLUDED" }}` + + `)` + + // run query + XOLog(sqlstr, {{ fieldnames .Fields $short }}) + _, err = db.Exec(sqlstr, {{ fieldnames .Fields $short }}) + if err != nil { + return err + } + + // set existence + {{ $short }}._exists = true + + return nil +} +{{ else }} + // Update statements omitted due to lack of fields other than primary key +{{ end }} + +// Delete deletes the {{ .Name }} from the database. +func ({{ $short }} *{{ .Name }}) Delete(db XODB) error { + var err error + + // if doesn't exist, bail + if !{{ $short }}._exists { + return nil + } + + // if deleted, bail + if {{ $short }}._deleted { + return nil + } + + {{ if gt ( len .PrimaryKeyFields ) 1 }} + // sql query with composite primary key + const sqlstr = `DELETE FROM {{ $table }} WHERE {{ colnamesquery .PrimaryKeyFields " AND " }}` + + // run query + XOLog(sqlstr, {{ fieldnames .PrimaryKeyFields $short }}) + _, err = db.Exec(sqlstr, {{ fieldnames .PrimaryKeyFields $short }}) + if err != nil { + return err + } + {{- else }} + // sql query + const sqlstr = `DELETE FROM {{ $table }} WHERE {{ colname .PrimaryKey.Col }} = $1` + + // run query + XOLog(sqlstr, {{ $short }}.{{ .PrimaryKey.Name }}) + _, err = db.Exec(sqlstr, {{ $short }}.{{ .PrimaryKey.Name }}) + if err != nil { + return err + } + {{- end }} + + // set deleted + {{ $short }}._deleted = true + + return nil +} +{{- end }} + diff --git a/internal/database/templates/xo_db.go.tpl b/internal/database/templates/xo_db.go.tpl new file mode 100644 index 0000000..cf4aab0 --- /dev/null +++ b/internal/database/templates/xo_db.go.tpl @@ -0,0 +1,71 @@ +// XODB is the common interface for database operations that can be used with +// types from schema '{{ schema .Schema }}'. +// +// This should work with database/sql.DB and database/sql.Tx. +type XODB interface { + Exec(string, ...interface{}) (sql.Result, error) + Query(string, ...interface{}) (*sql.Rows, error) + QueryRow(string, ...interface{}) *sql.Row +} + +// XOLog provides the log func used by generated queries. +var XOLog = func(string, ...interface{}) { } + +// ScannerValuer is the common interface for types that implement both the +// database/sql.Scanner and sql/driver.Valuer interfaces. +type ScannerValuer interface { + sql.Scanner + driver.Valuer +} + +// StringSlice is a slice of strings. +type StringSlice []string + +// quoteEscapeRegex is the regex to match escaped characters in a string. +var quoteEscapeRegex = regexp.MustCompile(`([^\\]([\\]{2})*)\\"`) + +// Scan satisfies the sql.Scanner interface for StringSlice. +func (ss *StringSlice) Scan(src interface{}) error { + buf, ok := src.([]byte) + if !ok { + return errors.New("invalid StringSlice") + } + + // change quote escapes for csv parser + str := quoteEscapeRegex.ReplaceAllString(string(buf), `$1""`) + str = strings.Replace(str, `\\`, `\`, -1) + + // remove braces + str = str[1:len(str)-1] + + // bail if only one + if len(str) == 0 { + *ss = StringSlice([]string{}) + return nil + } + + // parse with csv reader + cr := csv.NewReader(strings.NewReader(str)) + slice, err := cr.Read() + if err != nil { + fmt.Printf("exiting!: %v\n", err) + return err + } + + *ss = StringSlice(slice) + + return nil +} + +// Value satisfies the driver.Valuer interface for StringSlice. +func (ss StringSlice) Value() (driver.Value, error) { + v := make([]string, len(ss)) + for i, s := range ss { + v[i] = `"` + strings.Replace(strings.Replace(s, `\`, `\\\`, -1), `"`, `\"`, -1) + `"` + } + return "{" + strings.Join(v, ",") + "}", nil +} + +// Slice is a slice of ScannerValuers. +type Slice []ScannerValuer + diff --git a/internal/database/templates/xo_package.go.tpl b/internal/database/templates/xo_package.go.tpl new file mode 100644 index 0000000..c4a3ee1 --- /dev/null +++ b/internal/database/templates/xo_package.go.tpl @@ -0,0 +1,16 @@ +// Package {{ .Package }} contains the types for schema '{{ schema .Schema }}'. +package {{ .Package }} + +// Code generated by xo. DO NOT EDIT. + +import ( + "database/sql" + "database/sql/driver" + "encoding/csv" + "errors" + "fmt" + "regexp" + "strings" + "time" +) + diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..d0727fd --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,53 @@ +package storage + +import ( + "fmt" + + "github.com/minio/minio-go" +) + +// Config contains the configuration for an S3 backend. +type Config struct { + Key string + Secret string + Location string + Bucket string + Endpoint string + SSL bool +} + +// Client is a client for an S3 backend. +type Client struct { + config *Config + *minio.Client +} + +// New creates a new Client from config +func New(conf *Config) (*Client, error) { + client := &Client{ + config: conf, + } + + mc, err := minio.New( + conf.Endpoint, + conf.Key, + conf.Secret, + conf.SSL) + if err != nil { + return nil, err + } + + client.Client = mc + + return client, nil +} + +// GetBucketName returns the bucket name stored in the configuration. +func (c *Client) GetBucketName() string { + return c.config.Bucket +} + +// RemoteFilename returns the filename a blob should be stored at. +func (c *Client) RemoteFilename(hash string) string { + return fmt.Sprintf("blob/%s/%s", hash[:2], hash) +} diff --git a/internal/templates/templates.go b/internal/templates/templates.go new file mode 100644 index 0000000..e2a81d9 --- /dev/null +++ b/internal/templates/templates.go @@ -0,0 +1,108 @@ +package templates + +import ( + "errors" + "html/template" + "log" + "net/http" + "path" + "path/filepath" + "strings" + + "github.com/shurcooL/httpfs/html/vfstemplate" + "github.com/shurcooL/httpfs/path/vfspath" +) + +// Templates contains a map of filenames to parsed and prepared templates +type Templates map[string]*template.Template + +// LoadTemplatesFS takes a filesystem and a location, and returns a Templates +// map +func LoadTemplatesFS(fs http.FileSystem, dir string) Templates { + var templates = make(map[string]*template.Template) + + layouts, err := vfspath.Glob(fs, dir+"/layouts/*.tmpl") + if err != nil { + log.Fatal(err) + } + + //log.Printf("Loaded %d layouts", len(layouts)) + + includes, err := vfspath.Glob(fs, dir+"/includes/*.tmpl") + if err != nil { + log.Fatal(err) + } + + //log.Printf("Loaded %d includes", len(includes)) + + funcs := getFuncMap() // generate function map + + // Generate our templates map from our layouts/ and includes/ directories + for _, layout := range layouts { + files := append(includes, layout) + t := template.New(filepath.Base(layout)).Funcs(funcs) + t, err := vfstemplate.ParseFiles(fs, t, files...) + if err != nil { + log.Fatalf("Error parsing template %s: %s", + filepath.Base(layout), err) + } + templates[filepath.Base(layout)] = t + } + + return templates +} + +func (templates Templates) Get(name string) *template.Template { + t, ok := templates[name] + if !ok { + log.Printf("Error loading template %s", name) + return template.Must(template.New("empty").Parse("ERROR: Template missing")) + } + return t +} + +func getFuncMap() template.FuncMap { + return template.FuncMap{ + "istrue": func(value bool) bool { + return value + }, + "join": func(sep string, a []string) string { + return strings.Join(a, sep) + }, + "dict": func(values ...interface{}) (map[string]interface{}, error) { + if len(values)%2 != 0 { + return nil, errors.New("invalid dict call") + } + dict := make(map[string]interface{}, len(values)/2) + for i := 0; i < len(values); i += 2 { + key, ok := values[i].(string) + if !ok { + return nil, errors.New("dict keys must be strings") + } + dict[key] = values[i+1] + } + return dict, nil + }, + "url": func(rel ...string) string { + params := []string{"/"} + params = append(params, rel...) + return path.Join(params...) + }, + "static": func(rel ...string) string { + params := []string{"/static"} + params = append(params, rel...) + return path.Join(params...) + }, + "i18n": func(locale, key string) string { + switch locale { + case "de": + return "Hallo Welt!" + case "en": + return "Hello World!" + default: + log.Printf("I18n: '%s' has no key '%s'", locale, key) + return key + } + }, + } +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go new file mode 100644 index 0000000..ae60f5d --- /dev/null +++ b/internal/web/handlers.go @@ -0,0 +1,29 @@ +package web + +import ( + "net/http" + + "github.com/alexedwards/scs" + "bitmask.me/skeleton/internal/app" +) + +type Handlers struct { + *app.App + session *scs.Session +} + +func NewHandlers(app *app.App) *Handlers { + h := &Handlers{App: app} + h.session = scs.NewSession() + h.session.Cookie.Persist = false + h.session.Cookie.Secure = false + return h +} + +func (h *Handlers) Session() *scs.Session { + return h.session +} + +func (h *Handlers) LandingPageHandler(w http.ResponseWriter, r *http.Request) { + h.Templates().Get("landing.tmpl").Execute(w, nil) +} diff --git a/internal/web/middleware.go b/internal/web/middleware.go new file mode 100644 index 0000000..e32686a --- /dev/null +++ b/internal/web/middleware.go @@ -0,0 +1,42 @@ +package web + +import ( + "net/http" + "net/rpc" + "strings" + + "github.com/alexedwards/scs" +) + +// GRPCMiddleware allows a HTTP2 Server to also serve GRPC at the same port. +// Note that a valid certificate is needed, as HTTP2 requires TLS. +func GRPCMiddleware(rpcServer *rpc.Server) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ct := r.Header.Get("Content-Type") + if r.ProtoMajor == 2 && strings.Contains(ct, "application/grpc") { + rpcServer.ServeHTTP(w, r) + } else { + next.ServeHTTP(w, r) + } + }) + } +} + +// requireLogin makes sure a user is logged in, prior to access +// to a certain ressource. +func requireLogin(sess *scs.Session) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + handler := func(w http.ResponseWriter, r *http.Request) { + if sess.GetString(r.Context(), SessKeyUserID) == "" { + sess.Put(r.Context(), SessKeyNext, r.RequestURI) + http.Redirect(w, r, "/login", http.StatusFound) + return + } + next.ServeHTTP(w, r) + return + } + + return http.HandlerFunc(handler) + } +} diff --git a/internal/web/routes.go b/internal/web/routes.go new file mode 100644 index 0000000..53d2c10 --- /dev/null +++ b/internal/web/routes.go @@ -0,0 +1,44 @@ +package web + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "bitmask.me/skeleton/internal/app" +) + +func registerRoutes(ac *app.App, r chi.Router) { + h := NewHandlers(ac) + + r.Use(middleware.Recoverer) + + r.Route("/", func(r chi.Router) { + r.Use( + middleware.RedirectSlashes, + h.Session().LoadAndSave, + ) + + r.Get("/", h.LandingPageHandler) + + r.Route("/app", func(r chi.Router) { + // authenticated routes + r.Use(requireLogin(h.Session())) + + r.Get("/", h.LandingPageHandler) + }) + }) + + r.Handle("/static/*", staticHandler(ac.Files, "/")) +} + +// staticHandler handles the static assets path. +func staticHandler(fs http.FileSystem, prefix string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if prefix != "/" { + r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix) + } + http.FileServer(fs).ServeHTTP(w, r) + } +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..660810a --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,24 @@ +package web + +import ( + "net/http" + + "github.com/go-chi/chi" + "bitmask.me/skeleton/internal/app" +) + +func runHTTP(listenAddr string, h http.Handler) error { + server := &http.Server{ + Addr: listenAddr, + Handler: h, + } + return server.ListenAndServe() +} + +// RunServer starts the web server +func RunServer(ac *app.App, listenAddr string) { + r := chi.NewMux() + registerRoutes(ac, r) + + runHTTP(listenAddr, r) +} diff --git a/internal/web/sessionkeys.go b/internal/web/sessionkeys.go new file mode 100644 index 0000000..032b308 --- /dev/null +++ b/internal/web/sessionkeys.go @@ -0,0 +1,8 @@ +package web + +// Constant keys for use in session storage +const ( + SessKeyNext = "next" + SessKeyUserID = "uid" + SessKeyUserName = "u" +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..078bbc1 --- /dev/null +++ b/main.go @@ -0,0 +1,10 @@ +//go:generate go generate bitmask.me/skeleton/assets +//go:generate go generate bitmask.me/skeleton/internal/database + +package main + +import "bitmask.me/skeleton/cmd" + +func main() { + cmd.Execute() +}