From 7cd973f0a63ed6c85a79c858457a6c4e48e92d9b Mon Sep 17 00:00:00 2001 From: m15o Date: Mon, 22 Nov 2021 09:05:57 +0100 Subject: [PATCH] add storage and model --- .idea/.gitignore | 8 +++ .idea/dataSources.xml | 12 ++++ .idea/modules.xml | 8 +++ .idea/status.iml | 9 +++ config/cfg.go | 35 +++++++++ generate.go | 85 ++++++++++++++++++++++ go.mod | 8 +++ go.sum | 11 +++ main.go | 21 ++++++ model/status.go | 24 +++++++ model/user.go | 37 ++++++++++ model/user_test.go | 43 +++++++++++ storage/db.go | 16 +++++ storage/migration.go | 51 +++++++++++++ storage/sql.go | 28 ++++++++ storage/sql/schema_version_1.sql | 21 ++++++ storage/status.go | 119 +++++++++++++++++++++++++++++++ storage/storage.go | 13 ++++ storage/user.go | 69 ++++++++++++++++++ 19 files changed, 618 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/dataSources.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/status.iml create mode 100644 config/cfg.go create mode 100644 generate.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 model/status.go create mode 100644 model/user.go create mode 100644 model/user_test.go create mode 100644 storage/db.go create mode 100644 storage/migration.go create mode 100644 storage/sql.go create mode 100644 storage/sql/schema_version_1.sql create mode 100644 storage/status.go create mode 100644 storage/storage.go create mode 100644 storage/user.go diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..6ed3ca8 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,12 @@ + + + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:5432/status + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..ae61690 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/status.iml b/.idea/status.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/status.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/config/cfg.go b/config/cfg.go new file mode 100644 index 0000000..78a57c1 --- /dev/null +++ b/config/cfg.go @@ -0,0 +1,35 @@ +package config + +import "os" + +type ( + DBCfg struct { + DatabaseURL string + } + + ServerCfg struct { + SessionKey string + Env string + CertFile string + CertKeyFile string + } + + Config struct { + DB DBCfg + Server ServerCfg + } +) + +func New() *Config { + return &Config{ + DB: DBCfg{ + DatabaseURL: os.Getenv("DATABASE_URL"), + }, + Server: ServerCfg{ + SessionKey: os.Getenv("SESSION_KEY"), + Env: os.Getenv("ENV"), + CertFile: os.Getenv("CERT_FILE"), + CertKeyFile: os.Getenv("CERT_KEY_FILE"), + }, + } +} diff --git a/generate.go b/generate.go new file mode 100644 index 0000000..5185c35 --- /dev/null +++ b/generate.go @@ -0,0 +1,85 @@ +// +build ignore + +package main + +import ( + "io/ioutil" + "os" + "path" + "path/filepath" + "strings" + "text/template" +) + +const tpl = `// Code generated by go generate; DO NOT EDIT. + +package {{ .Package }} + +var {{ .Map }} = map[string]string{ +{{ range $constant, $content := .Files }}` + "\t" + `"{{ $constant }}": ` + "`{{ $content }}`" + `, +{{ end }}} +` + +var bundleTpl = template.Must(template.New("").Parse(tpl)) + +type Bundle struct { + Package string + Map string + Files map[string]string +} + +func (b *Bundle) Write(filename string) { + f, err := os.Create(filename) + if err != nil { + panic(err) + } + defer f.Close() + + bundleTpl.Execute(f, b) +} + +func NewBundle(pkg, mapName string) *Bundle { + return &Bundle{ + Package: pkg, + Map: mapName, + Files: make(map[string]string), + } +} + +func stripExtension(filename string) string { + filename = strings.TrimSuffix(filename, path.Ext(filename)) + return strings.Replace(filename, " ", "_", -1) +} + +func readFile(filename string) []byte { + data, err := ioutil.ReadFile(filename) + if err != nil { + panic(err) + } + return data +} + +func glob(pattern string) []string { + files, _ := filepath.Glob(pattern) + for i := range files { + if strings.Contains(files[i], "\\") { + files[i] = filepath.ToSlash(files[i]) + } + } + return files +} + +func generateMap(target string, pkg string, mapName string, srcFiles []string) { + bundle := NewBundle(pkg, mapName) + for _, srcFile := range srcFiles { + data := readFile(srcFile) + filename := stripExtension(path.Base(srcFile)) + bundle.Files[filename] = string(data) + } + bundle.Write(target) +} + +func main() { + generateMap(path.Join("storage", "sql.go"), "storage", "SqlMap", glob("storage/sql/*.sql")) + //generateMap(path.Join("template", "html.go"), "template", "TplMap", glob("template/html/*.html")) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..76f93a0 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module status + +go 1.16 + +require ( + github.com/lib/pq v1.10.4 + golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..233fb43 --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk= +github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 h1:/pEO3GD/ABYAjuakUS6xSEmmlyVS4kxBNkeA9tLJiTI= +golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/main.go b/main.go new file mode 100644 index 0000000..8b60401 --- /dev/null +++ b/main.go @@ -0,0 +1,21 @@ +//go:generate go run generate.go +package main + +import ( + "log" + "status/config" + "status/storage" +) + +func main() { + cfg := config.New() + db, err := storage.InitDB(cfg.DB) + if err != nil { + log.Fatal(err) + } + storage.New(db) + //data := storage.New(db) + //log.Fatal( + // server.Serve(data, cfg), + //) +} diff --git a/model/status.go b/model/status.go new file mode 100644 index 0000000..079426a --- /dev/null +++ b/model/status.go @@ -0,0 +1,24 @@ +package model + +import ( + "errors" + "time" +) + +type Status struct { + Id int64 + User string + Content string + CreatedAt time.Time +} + +func (s Status) Validate() error { + if len(s.Content) == 0 { + return errors.New("content is empty") + } + return nil +} + +func (s Status) Date() string { + return s.CreatedAt.Format("2006-01-02") +} diff --git a/model/user.go b/model/user.go new file mode 100644 index 0000000..9374aab --- /dev/null +++ b/model/user.go @@ -0,0 +1,37 @@ +package model + +import ( + "errors" + "golang.org/x/crypto/bcrypt" + "regexp" + "time" +) + +type User struct { + Name string + Password string + Hash []byte + CreatedAt time.Time +} + +func (u User) Validate() error { + if u.Name == "" { + return errors.New("username is mandatory") + } + if u.Password == "" { + return errors.New("password is mandatory") + } + match, _ := regexp.MatchString("^[a-z0-9-_]+$", u.Name) + if !match { + return errors.New("username should match [a-z0-9-_]") + } + return nil +} + +func (u User) HashPassword() ([]byte, error) { + return bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.MinCost) +} + +func (u User) CompareHashToPassword(hash []byte) error { + return bcrypt.CompareHashAndPassword(hash, []byte(u.Password)) +} diff --git a/model/user_test.go b/model/user_test.go new file mode 100644 index 0000000..474a0cc --- /dev/null +++ b/model/user_test.go @@ -0,0 +1,43 @@ +package model + +import "testing" + +func TestValidateUser(t *testing.T) { + user := User{ + Name: "", + Password: "password", + } + + user.Name = "" + if err := user.Validate(); err == nil { + t.Fatal("Empty username not allowed") + } + + user.Name = "miso" + if err := user.Validate(); err != nil { + t.Fatal("Regular characters allowed") + } + + user.Name = "m15o" + if err := user.Validate(); err != nil { + t.Fatal("Digits allowed") + } + + user.Name = "has space" + if err := user.Validate(); err == nil { + t.Fatal("Space is not allowed") + } + + user.Name = "M15O" + if err := user.Validate(); err == nil { + t.Fatal("Capital letters aren't allowed") + } + + characters := []string{"#", ":", "/", "@", "?"} + for _, c := range characters { + user.Name = c + if err := user.Validate(); err == nil { + t.Fatal("Special characters not allowed") + } + } +} diff --git a/storage/db.go b/storage/db.go new file mode 100644 index 0000000..a3a11a1 --- /dev/null +++ b/storage/db.go @@ -0,0 +1,16 @@ +package storage + +import ( + "database/sql" + _ "github.com/lib/pq" + "status/config" +) + +func InitDB(cfg config.DBCfg) (*sql.DB, error) { + db, err := sql.Open("postgres", cfg.DatabaseURL) + if err != nil { + return db, err + } + Migrate(db) + return db, err +} diff --git a/storage/migration.go b/storage/migration.go new file mode 100644 index 0000000..efe581d --- /dev/null +++ b/storage/migration.go @@ -0,0 +1,51 @@ +package storage + +import ( + "database/sql" + "fmt" + "log" + "strconv" +) + +const schemaVersion = 1 + +func Migrate(db *sql.DB) { + var currentVersion int + db.QueryRow(`SELECT version FROM schema_version`).Scan(¤tVersion) + + fmt.Println("Current schema version:", currentVersion) + fmt.Println("Latest schema version:", schemaVersion) + + for version := currentVersion + 1; version <= schemaVersion; version++ { + fmt.Println("Migrating to version:", version) + + tx, err := db.Begin() + if err != nil { + log.Fatal("[Migrate] ", err) + } + + rawSQL := SqlMap["schema_version_"+strconv.Itoa(version)] + if rawSQL == "" { + log.Fatalf("[Migrate] missing migration %d", version) + } + _, err = tx.Exec(string(rawSQL)) + if err != nil { + tx.Rollback() + log.Fatal("[Migrate] ", err) + } + + if _, err := tx.Exec(`delete from schema_version`); err != nil { + tx.Rollback() + log.Fatal("[Migrate] ", err) + } + + if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES ($1)`, version); err != nil { + tx.Rollback() + log.Fatal("[Migrate] ", err) + } + + if err := tx.Commit(); err != nil { + log.Fatal("[Migrate] ", err) + } + } +} diff --git a/storage/sql.go b/storage/sql.go new file mode 100644 index 0000000..55f70c2 --- /dev/null +++ b/storage/sql.go @@ -0,0 +1,28 @@ +// Code generated by go generate; DO NOT EDIT. + +package storage + +var SqlMap = map[string]string{ + "schema_version_1": `-- create schema version table +create table schema_version ( + version text not null +); + +-- create users table +create table users +( + name text primary key CHECK (name <> ''), + hash text not null CHECK (hash <> ''), + created_at timestamp with time zone DEFAULT now() +); + +-- create posts status +create table statuses +( + id serial primary key, + author TEXT references users(name) NOT NULL, + content VARCHAR(500) NOT NULL CHECK (content <> ''), + created_at timestamp with time zone DEFAULT now() +); +`, +} diff --git a/storage/sql/schema_version_1.sql b/storage/sql/schema_version_1.sql new file mode 100644 index 0000000..0754654 --- /dev/null +++ b/storage/sql/schema_version_1.sql @@ -0,0 +1,21 @@ +-- create schema version table +create table schema_version ( + version text not null +); + +-- create users table +create table users +( + name text primary key CHECK (name <> ''), + hash text not null CHECK (hash <> ''), + created_at timestamp with time zone DEFAULT now() +); + +-- create posts status +create table statuses +( + id serial primary key, + author TEXT references users(name) NOT NULL, + content VARCHAR(500) NOT NULL CHECK (content <> ''), + created_at timestamp with time zone DEFAULT now() +); diff --git a/storage/status.go b/storage/status.go new file mode 100644 index 0000000..7e2d21a --- /dev/null +++ b/storage/status.go @@ -0,0 +1,119 @@ +package storage + +import ( + "database/sql" + "status/model" + "strconv" + "strings" +) + +type statusQueryBuilder struct { + where string + limit string + offset string +} + +func (p statusQueryBuilder) build() string { + query := []string{`SELECT id, author, content, created_at from statuses`} + if p.where != "" { + query = append(query, `WHERE`, p.where) + } + query = append(query, `ORDER BY created_at desc`) + if p.limit != "" { + query = append(query, `LIMIT`, p.limit) + } + if p.offset != "" { + query = append(query, `OFFSET`, p.offset) + } + return strings.Join(query, " ") +} + +func (s *Storage) populateStatus(rows *sql.Rows) (model.Status, error) { + var status model.Status + err := rows.Scan(&status.Id, &status.User, &status.Content, &status.CreatedAt) + if err != nil { + return status, err + } + return status, nil +} + +func (s *Storage) CreatePost(status model.Status) (int64, error) { + var lid int64 + err := s.db.QueryRow(`INSERT INTO statuses (author, content) VALUES ($1, $2) RETURNING id`, + status.User, status.Content).Scan(&lid) + return lid, err +} + +func (s *Storage) StatusById(id int64) (model.Status, error) { + var status model.Status + err := s.db.QueryRow( + `SELECT id, author, content from statuses WHERE id=$1`, id).Scan( + &status.Id, + &status.User, + &status.Content, + ) + return status, err +} + +func (s *Storage) StatusByUsername(user string, perPage int, page int64) ([]model.Status, bool, error) { + rows, err := s.db.Query(statusQueryBuilder{ + where: `author = $1`, + limit: strconv.Itoa(perPage + 1), + offset: `$2`, + }.build(), user, page*int64(perPage)) + if err != nil { + return nil, false, err + } + var statuses []model.Status + for rows.Next() { + post, err := s.populateStatus(rows) + if err != nil { + return statuses, false, err + } + statuses = append(statuses, post) + } + if len(statuses) > perPage { + return statuses[0:perPage], true, err + } + return statuses, false, err +} + +func (s *Storage) Statuses(page int64, perPage int) ([]model.Status, bool, error) { + rows, err := s.db.Query(statusQueryBuilder{ + limit: strconv.Itoa(perPage + 1), + offset: `$1`, + }.build(), page*int64(perPage)) + if err != nil { + return nil, false, err + } + var statuses []model.Status + for rows.Next() { + post, err := s.populateStatus(rows) + if err != nil { + return statuses, false, err + } + statuses = append(statuses, post) + } + if len(statuses) > perPage { + return statuses[0:perPage], true, err + } + return statuses, false, err +} + +func (s *Storage) UpdateStatus(status model.Status) error { + stmt, err := s.db.Prepare(`UPDATE statuses SET content = $1 WHERE id = $2 and author = $3;`) + if err != nil { + return err + } + _, err = stmt.Exec(status.Content, status.Id, status.User) + return err +} + +func (s *Storage) DeleteStatus(id int64, author string) error { + stmt, err := s.db.Prepare(`DELETE from statuses WHERE id = $1 and author = $2;`) + if err != nil { + return err + } + _, err = stmt.Exec(id, author) + return err +} diff --git a/storage/storage.go b/storage/storage.go new file mode 100644 index 0000000..008513f --- /dev/null +++ b/storage/storage.go @@ -0,0 +1,13 @@ +package storage + +import ( + "database/sql" +) + +type Storage struct { + db *sql.DB +} + +func New(db *sql.DB) *Storage { + return &Storage{db: db} +} diff --git a/storage/user.go b/storage/user.go new file mode 100644 index 0000000..70865de --- /dev/null +++ b/storage/user.go @@ -0,0 +1,69 @@ +package storage + +import ( + "status/model" +) + +const queryFindName = `SELECT name, hash, created_at FROM users WHERE name=lower($1);` +const queryFindDomain = `SELECT name, hash, created_at FROM users WHERE domain=$1;` + +func (s *Storage) queryUser(q string, params ...interface{}) (user model.User, err error) { + err = s.db.QueryRow(q, params...).Scan(&user.Name, &user.Hash, &user.CreatedAt) + return +} + +func (s *Storage) UserExists(name string) bool { + var rv bool + s.db.QueryRow(`SELECT true FROM users WHERE name=lower($1)`, name).Scan(&rv) + return rv +} + +func (s *Storage) UserByName(name string) (model.User, error) { + return s.queryUser(queryFindName, name) +} + +func (s *Storage) CreateUser(user model.User) error { + hash, err := user.HashPassword() + if err != nil { + return err + } + insertUser := `INSERT INTO users (name, hash) VALUES (lower($1), $2)` + statement, err := s.db.Prepare(insertUser) + if err != nil { + return err + } + _, err = statement.Exec(user.Name, hash) + return err +} + +func (s *Storage) Users() ([]string, error) { + rows, err := s.db.Query("select name from users") + if err != nil { + return nil, err + } + var users []string + for rows.Next() { + var user string + err := rows.Scan(&user) + if err != nil { + return users, err + } + users = append(users, user) + } + return users, nil +} + +func (s *Storage) DeleteUser(username string) error { + stmt, err := s.db.Prepare(`DELETE from status WHERE author = $1;`) + if err != nil { + return err + } + _, err = stmt.Exec(username) + stmt, err = s.db.Prepare(`DELETE from users WHERE name = $1;`) + if err != nil { + return err + } + _, err = stmt.Exec(username) + return err +} +