add storage and model

This commit is contained in:
m15o 2021-11-22 09:05:57 +01:00
commit 7cd973f0a6
19 changed files with 618 additions and 0 deletions

8
.idea/.gitignore vendored Normal file
View file

@ -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

12
.idea/dataSources.xml Normal file
View file

@ -0,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>

8
.idea/modules.xml Normal file
View file

@ -0,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>

9
.idea/status.iml Normal file
View file

@ -0,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>

35
config/cfg.go Normal file
View file

@ -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"),
},
}
}

85
generate.go Normal file
View file

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

8
go.mod Normal file
View file

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

11
go.sum Normal file
View file

@ -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=

21
main.go Normal file
View file

@ -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),
//)
}

24
model/status.go Normal file
View file

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

37
model/user.go Normal file
View file

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

43
model/user_test.go Normal file
View file

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

16
storage/db.go Normal file
View file

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

51
storage/migration.go Normal file
View file

@ -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(&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)
}
}
}

28
storage/sql.go Normal file
View file

@ -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()
);
`,
}

View file

@ -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()
);

119
storage/status.go Normal file
View file

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

13
storage/storage.go Normal file
View file

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

69
storage/user.go Normal file
View file

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