Initial commit
This commit is contained in:
commit
d2eba2e5a8
32 changed files with 1195 additions and 0 deletions
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
|
@ -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
|
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
.env
|
1
assets/.gitignore
vendored
Normal file
1
assets/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
*_vfsdata.go
|
32
assets/dev.go
Normal file
32
assets/dev.go
Normal file
|
@ -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
|
||||||
|
}
|
6
assets/doc.go
Normal file
6
assets/doc.go
Normal file
|
@ -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
|
1
assets/migrations/0_empty.up.sql
Normal file
1
assets/migrations/0_empty.up.sql
Normal file
|
@ -0,0 +1 @@
|
||||||
|
-- this file is only here so the database can track a completely empty state.
|
0
assets/static/css/index.html
Normal file
0
assets/static/css/index.html
Normal file
0
assets/static/images/index.html
Normal file
0
assets/static/images/index.html
Normal file
0
assets/static/index.html
Normal file
0
assets/static/index.html
Normal file
3
assets/templates/includes/smiley.tmpl
Normal file
3
assets/templates/includes/smiley.tmpl
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{{ define "smiley" }}
|
||||||
|
:)
|
||||||
|
{{- end }}
|
17
assets/templates/layouts/landing.tmpl
Normal file
17
assets/templates/layouts/landing.tmpl
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||||
|
<title>Landing Page</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1>Skeleton Project</h1>
|
||||||
|
|
||||||
|
If you are an admin, you can <a href="/login">Log in</a>.
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
55
cmd/cmd.go
Normal file
55
cmd/cmd.go
Normal file
|
@ -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)
|
||||||
|
}
|
101
internal/app/app.go
Normal file
101
internal/app/app.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
55
internal/database/db.go
Normal file
55
internal/database/db.go
Normal file
|
@ -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
|
||||||
|
}
|
92
internal/database/migrate.go
Normal file
92
internal/database/migrate.go
Normal file
|
@ -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
|
||||||
|
}
|
62
internal/database/templates/postgres.enum.go.tpl
Normal file
62
internal/database/templates/postgres.enum.go.tpl
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
|
8
internal/database/templates/postgres.foreignkey.go.tpl
Normal file
8
internal/database/templates/postgres.foreignkey.go.tpl
Normal file
|
@ -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 }})
|
||||||
|
}
|
||||||
|
|
58
internal/database/templates/postgres.index.go.tpl
Normal file
58
internal/database/templates/postgres.index.go.tpl
Normal file
|
@ -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 }}
|
||||||
|
}
|
||||||
|
|
28
internal/database/templates/postgres.proc.go.tpl
Normal file
28
internal/database/templates/postgres.proc.go.tpl
Normal file
|
@ -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 }}
|
||||||
|
|
49
internal/database/templates/postgres.query.go.tpl
Normal file
49
internal/database/templates/postgres.query.go.tpl
Normal file
|
@ -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 }}
|
||||||
|
}
|
||||||
|
|
12
internal/database/templates/postgres.querytype.go.tpl
Normal file
12
internal/database/templates/postgres.querytype.go.tpl
Normal file
|
@ -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 }}
|
||||||
|
}
|
||||||
|
|
206
internal/database/templates/postgres.type.go.tpl
Normal file
206
internal/database/templates/postgres.type.go.tpl
Normal file
|
@ -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 }}
|
||||||
|
|
71
internal/database/templates/xo_db.go.tpl
Normal file
71
internal/database/templates/xo_db.go.tpl
Normal file
|
@ -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
|
||||||
|
|
16
internal/database/templates/xo_package.go.tpl
Normal file
16
internal/database/templates/xo_package.go.tpl
Normal file
|
@ -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"
|
||||||
|
)
|
||||||
|
|
53
internal/storage/storage.go
Normal file
53
internal/storage/storage.go
Normal file
|
@ -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)
|
||||||
|
}
|
108
internal/templates/templates.go
Normal file
108
internal/templates/templates.go
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
29
internal/web/handlers.go
Normal file
29
internal/web/handlers.go
Normal file
|
@ -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)
|
||||||
|
}
|
42
internal/web/middleware.go
Normal file
42
internal/web/middleware.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
44
internal/web/routes.go
Normal file
44
internal/web/routes.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
24
internal/web/server.go
Normal file
24
internal/web/server.go
Normal file
|
@ -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)
|
||||||
|
}
|
8
internal/web/sessionkeys.go
Normal file
8
internal/web/sessionkeys.go
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
// Constant keys for use in session storage
|
||||||
|
const (
|
||||||
|
SessKeyNext = "next"
|
||||||
|
SessKeyUserID = "uid"
|
||||||
|
SessKeyUserName = "u"
|
||||||
|
)
|
10
main.go
Normal file
10
main.go
Normal file
|
@ -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()
|
||||||
|
}
|
Loading…
Reference in a new issue