~m15o/status

7cd973f0a63ed6c85a79c858457a6c4e48e92d9b — m15o 3 years ago
add storage and model
A  => .idea/.gitignore +8 -0
@@ 1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

A  => .idea/dataSources.xml +12 -0
@@ 1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="DataSourceManagerImpl" format="xml" multifile-model="true">
    <data-source source="LOCAL" name="status@localhost" uuid="abde68f9-bf9f-49bc-a087-097031ffa257">
      <driver-ref>postgresql</driver-ref>
      <synchronize>true</synchronize>
      <jdbc-driver>org.postgresql.Driver</jdbc-driver>
      <jdbc-url>jdbc:postgresql://localhost:5432/status</jdbc-url>
      <working-dir>$ProjectFileDir$</working-dir>
    </data-source>
  </component>
</project>
\ No newline at end of file

A  => .idea/modules.xml +8 -0
@@ 1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="ProjectModuleManager">
    <modules>
      <module fileurl="file://$PROJECT_DIR$/.idea/status.iml" filepath="$PROJECT_DIR$/.idea/status.iml" />
    </modules>
  </component>
</project>
\ No newline at end of file

A  => .idea/status.iml +9 -0
@@ 1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
  <component name="Go" enabled="true" />
  <component name="NewModuleRootManager">
    <content url="file://$MODULE_DIR$" />
    <orderEntry type="inheritedJdk" />
    <orderEntry type="sourceFolder" forTests="false" />
  </component>
</module>
\ No newline at end of file

A  => config/cfg.go +35 -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"),
		},
	}
}

A  => generate.go +85 -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"))
}

A  => go.mod +8 -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
)

A  => go.sum +11 -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=

A  => main.go +21 -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),
	//)
}

A  => model/status.go +24 -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")
}

A  => model/user.go +37 -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))
}

A  => model/user_test.go +43 -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")
		}
	}
}

A  => storage/db.go +16 -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
}

A  => storage/migration.go +51 -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(&currentVersion)

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

A  => storage/sql.go +28 -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()
);
`,
}

A  => storage/sql/schema_version_1.sql +21 -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()
);

A  => storage/status.go +119 -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
}

A  => storage/storage.go +13 -0
@@ 1,13 @@
package storage

import (
	"database/sql"
)

type Storage struct {
	db *sql.DB
}

func New(db *sql.DB) *Storage {
	return &Storage{db: db}
}

A  => storage/user.go +69 -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
}