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(¤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)
+ }
+ }
+}
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
+}
+