Initial commit

This commit is contained in:
paul 2019-05-14 14:11:03 +02:00
commit d2eba2e5a8
32 changed files with 1195 additions and 0 deletions

3
.gitattributes vendored Normal file
View 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
View file

@ -0,0 +1 @@
.env

1
assets/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
*_vfsdata.go

32
assets/dev.go Normal file
View 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
View 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

View file

@ -0,0 +1 @@
-- this file is only here so the database can track a completely empty state.

View file

View file

0
assets/static/index.html Normal file
View file

View file

@ -0,0 +1,3 @@
{{ define "smiley" }}
:)
{{- end }}

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

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

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

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

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

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

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

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

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

View 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

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

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

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

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

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