diff --git a/README.md b/README.md index fb4a102..c2447e8 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,19 @@ # Discourse the right way **Fog** is a **F**orum mixed with a Bl**og**, intended to encourage fruitful -discussion and the spread of knowledge. +discussion and the spread of knowledge. -## Posts -These are your typical blog posts, written in Markdown. They can include video, -images and (one day) custom JS for interactivity. +To get started: +1. Install [Go](https://go.dev). +2. Install [migrate](https://github.com/golang-migrate/migrate): +```sh +$ go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest +``` +3. Install [PostgreSQL](https://postgresql.org). +4. Create a new Postgres user and database. +5. Set your environment variables. See [`docs/envvars.mds`](docs/envvars.md) +for an list of variables that need to be set. +6. Run `scripts/migrateup` to setup the database tables. +7. Run `go run cmd/api` to start the backend. -Posts can be replies to other posts and will be directly linked underneath the -main post. You can reply to multiple posts at once, and you will be linked in -the replies of all posts. Neighbouring posts which reply to the same parent post -will be linked under the reply post. - -After you make a post, you must wait a customizable amount of time before -making another (default is a day). Think before you speak. - -There is no voting system and no comments system. If you have something to say -about a post, say it. Views on a post are also not recorded. - -## Channels -Channels are a way of sorting posts by their general theme. All posts made to a -channel can be sorted by newest (default), oldest and random. - -Moderators can pin posts to the top of channels. \ No newline at end of file +Frontend has a seperate setup. See [`web/README.md`](web/README.md) for more +info. \ No newline at end of file diff --git a/cmd/api/api.go b/cmd/api/api.go index 1ae5c4d..6ceff44 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -8,15 +8,27 @@ import ( "path" "strings" "time" + + "midnadimple.com/fog/internal/store" ) type application struct { config config - v1 *v1Handler + store store.Storage + + v1 *v1Handler } type config struct { addr string + db dbConfig +} + +type dbConfig struct { + addr string + maxOpenConns int + maxIdleConns int + maxIdleTime time.Duration } func (h *application) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -71,6 +83,10 @@ func (h *application) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func (app *application) run() error { + app.v1 = &v1Handler{ + healthCheck: &healthCheckHandler{}, + } + srv := &http.Server{ Addr: app.config.addr, Handler: app, diff --git a/cmd/api/main.go b/cmd/api/main.go index fc41f65..52fc602 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -2,22 +2,39 @@ package main import ( "log" + "time" + "midnadimple.com/fog/internal/db" "midnadimple.com/fog/internal/env" + "midnadimple.com/fog/internal/store/postgres" ) func main() { cfg := config{ addr: env.GetString("FOG_ADDR", ":8080"), + db: dbConfig{ + addr: env.GetString("FOG_DB_ADDR", "postgres://foguser:fogpass@localhost/fog?sslmode=disable"), + maxOpenConns: env.GetInt("FOG_DB_MAX_OPEN_CONNS", 30), + maxIdleConns: env.GetInt("FOG_DB_MAX_IDLE_CONNS", 30), + maxIdleTime: env.GetDuration("FOG_DB_MAX_IDLE_TIME", 15*time.Minute), + }, } - v1 := &v1Handler{ - healthCheck: &healthCheckHandler{}, + db, err := db.New( + cfg.db.addr, + cfg.db.maxOpenConns, + cfg.db.maxIdleConns, + cfg.db.maxIdleTime, + ) + if err != nil { + log.Fatalln(err) } + store := postgres.NewPostgresStorage(db) + app := &application{ config: cfg, - v1: v1, + store: store, } log.Fatal(app.run()) diff --git a/cmd/migrate/migrations/000001_create_users.down.sql b/cmd/migrate/migrations/000001_create_users.down.sql new file mode 100644 index 0000000..365a210 --- /dev/null +++ b/cmd/migrate/migrations/000001_create_users.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/cmd/migrate/migrations/000001_create_users.up.sql b/cmd/migrate/migrations/000001_create_users.up.sql new file mode 100644 index 0000000..a1e075c --- /dev/null +++ b/cmd/migrate/migrations/000001_create_users.up.sql @@ -0,0 +1,9 @@ +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE TABLE IF NOT EXISTS users( + id bigserial PRIMARY KEY, + username varchar(255) UNIQUE NOT NULL, + email citext UNIQUE NOT NULL, + password bytea NOT NULL, + created_at timestamp(0) with time zone NOT NULL DEFAULT NOW() +); \ No newline at end of file diff --git a/cmd/migrate/migrations/000002_create_posts.down.sql b/cmd/migrate/migrations/000002_create_posts.down.sql new file mode 100644 index 0000000..f432251 --- /dev/null +++ b/cmd/migrate/migrations/000002_create_posts.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS posts \ No newline at end of file diff --git a/cmd/migrate/migrations/000002_create_posts.up.sql b/cmd/migrate/migrations/000002_create_posts.up.sql new file mode 100644 index 0000000..a810ca0 --- /dev/null +++ b/cmd/migrate/migrations/000002_create_posts.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS posts( + id bigserial PRIMARY KEY, + content text NOT NULL, + title text NOT NULL, + user_id bigserial NOT NULL, + tags text[], + reply_ids integer[], + created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(), + updated_at timestamp(0) with time zone NOT NULL DEFAULT NOW() +); \ No newline at end of file diff --git a/cmd/migrate/migrations/000003_alter_posts.down.sql b/cmd/migrate/migrations/000003_alter_posts.down.sql new file mode 100644 index 0000000..9bd9d69 --- /dev/null +++ b/cmd/migrate/migrations/000003_alter_posts.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE + posts +DROP CONSTRAINT fk_user; \ No newline at end of file diff --git a/cmd/migrate/migrations/000003_alter_posts.up.sql b/cmd/migrate/migrations/000003_alter_posts.up.sql new file mode 100644 index 0000000..b95755c --- /dev/null +++ b/cmd/migrate/migrations/000003_alter_posts.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE + posts +ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users (id); \ No newline at end of file diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..7530f8f --- /dev/null +++ b/docs/design.md @@ -0,0 +1,22 @@ +# Posts +These are your typical blog posts, written in Markdown. They can include video, +images and (one day) custom JS for interactivity. + +Posts can be replies to other posts and will be directly linked underneath the +main post. You can reply to multiple posts at once, and you will be linked in +the replies of all posts. Neighbouring posts which reply to the same parent post +will be linked under the reply post. + +After you make a post, you must wait a customizable amount of time before +making another (default is a day). Think before you speak. + +There is no voting system and no comments system. If you have something to say +about a post, say it. Views on a post are also not recorded. + +# Channels +Channels are a way of sorting posts by their general theme. All posts in a given +channel can be sorted by newest (default), oldest and random. + +Moderators can pin posts to the top of channels. + +A post must belong to one or more channels. \ No newline at end of file diff --git a/docs/envvars.md b/docs/envvars.md new file mode 100644 index 0000000..2b6202e --- /dev/null +++ b/docs/envvars.md @@ -0,0 +1,20 @@ +# List of all Environment Variables +You should set these in your `.profile`. Even though there are defaults, the +scripts still expect to have these variables in order to perform migrations, +so you should set them. + +## FOG_ADDR +Address the backend server listens on. Default is ":8080" + +## FOG_DB_ADDR +Address of the Postgres database. Default is: +"postgres://foguser:fogpass@localhost/fog?sslmode=disable" + +## FOG_DB_MAX_OPEN_CONNS +How many database connections can be open at once. Default is 30 + +## FOG_DB_MAX_IDLE_CONNS +How many database connections can be idling at once. Default is 30 + +## FOG_DB_MAX_IDLE_TIME +How long a database connection should idle before being closed Default is "15m" \ No newline at end of file diff --git a/go.mod b/go.mod index e8deb9f..36018d9 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module midnadimple.com/fog go 1.23.4 + +require github.com/lib/pq v1.10.9 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..aeddeae --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..4b4eff7 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,31 @@ +package db + +import ( + "context" + "database/sql" + "log" + "time" +) + +func New(addr string, maxOpenConns, maxIdleConns int, maxIdleTime time.Duration) (*sql.DB, error) { + db, err := sql.Open("postgres", addr) + if err != nil { + return nil, err + } + + defer db.Close() + log.Println("database connection pool established") + + db.SetMaxOpenConns(maxOpenConns) + db.SetMaxIdleConns(maxIdleConns) + db.SetConnMaxIdleTime(maxIdleTime) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := db.PingContext(ctx); err != nil { + return nil, err + } + + return db, nil +} diff --git a/internal/env/env.go b/internal/env/env.go index ff6d7df..cc15223 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -4,11 +4,13 @@ import ( "log" "os" "strconv" + "time" ) func GetString(key, fallback string) string { val, ok := os.LookupEnv(key) if !ok { + log.Printf("Failed to lookup enviornment variable with key %s. Using fallback value '%s'", key, fallback) return fallback } @@ -18,6 +20,7 @@ func GetString(key, fallback string) string { func GetInt(key string, fallback int) int { val, ok := os.LookupEnv(key) if !ok { + log.Printf("Failed to lookup enviornment variable with key %s. Using fallback value '%d'", key, fallback) return fallback } @@ -29,3 +32,19 @@ func GetInt(key string, fallback int) int { return valInt } + +func GetDuration(key string, fallback time.Duration) time.Duration { + val, ok := os.LookupEnv(key) + if !ok { + log.Printf("Failed to lookup enviornment variable with key %s. Using fallback value '%s'", key, fallback) + return fallback + } + + duration, err := time.ParseDuration(val) + if err != nil { + log.Printf("Failed to convert environment variable with key %s and value %s to duration. Using fallback %d. (err: %s)", key, val, fallback, err) + return fallback + } + + return duration +} diff --git a/internal/store/postgres/postgres.go b/internal/store/postgres/postgres.go new file mode 100644 index 0000000..70b8e04 --- /dev/null +++ b/internal/store/postgres/postgres.go @@ -0,0 +1,20 @@ +package postgres + +import ( + "database/sql" + "time" + + "midnadimple.com/fog/internal/store" +) + +func NewPostgresStorage(db *sql.DB) store.Storage { + return store.Storage{ + Posts: &PostsStore{db}, + Users: &UsersStore{db}, + } +} + +func parseTimestamp(valPtr *time.Time, str string) error { + + return nil +} diff --git a/internal/store/postgres/posts.go b/internal/store/postgres/posts.go new file mode 100644 index 0000000..3f17f9c --- /dev/null +++ b/internal/store/postgres/posts.go @@ -0,0 +1,52 @@ +package postgres + +import ( + "context" + "database/sql" + "time" + + "github.com/lib/pq" + + "midnadimple.com/fog/internal/store" +) + +type PostsStore struct { + DB *sql.DB +} + +func (s *PostsStore) Create(ctx context.Context, post *store.Post) error { + query := ` + INSERT INTO posts (content, title, user_id, tags, reply_ids) + VALUES ($1, $2, $3, $4) RETURNING id, created_at, updated_at +` + var createdAtStr, updatedAtStr string + err := s.DB.QueryRowContext( + ctx, + query, + post.Content, + post.Title, + post.UserID, + pq.Array(post.Tags), + pq.Array(post.ReplyIDs), + ).Scan( + &post.ID, + &createdAtStr, + &updatedAtStr, + ) + + if err != nil { + return err + } + + post.CreatedAt, err = pq.ParseTimestamp(time.UTC, createdAtStr) + if err != nil { + return err + } + + post.UpdatedAt, err = pq.ParseTimestamp(time.UTC, updatedAtStr) + if err != nil { + return err + } + + return nil +} diff --git a/internal/store/postgres/users.go b/internal/store/postgres/users.go new file mode 100644 index 0000000..f123574 --- /dev/null +++ b/internal/store/postgres/users.go @@ -0,0 +1,44 @@ +package postgres + +import ( + "context" + "database/sql" + "time" + + "github.com/lib/pq" + "midnadimple.com/fog/internal/store" +) + +type UsersStore struct { + DB *sql.DB +} + +func (s *UsersStore) Create(ctx context.Context, user *store.User) error { + query := ` + INSERT INTO users (username, password, email) VALUES($1, $2, $3) RETURNING id, created_at + ` + + var createdAtStr string + + err := s.DB.QueryRowContext( + ctx, + query, + user.Username, + user.Password, + user.Email, + ).Scan( + &user.ID, + &createdAtStr, + ) + + if err != nil { + return err + } + + user.CreatedAt, err = pq.ParseTimestamp(time.UTC, createdAtStr) + if err != nil { + return err + } + + return nil +} diff --git a/internal/store/storage.go b/internal/store/storage.go new file mode 100644 index 0000000..706782b --- /dev/null +++ b/internal/store/storage.go @@ -0,0 +1,34 @@ +package store + +import ( + "context" + "time" +) + +type Post struct { + ID int64 `json:"id"` + Content string `json:"content"` + Title string `json:"title"` + UserID int64 `json:"user_id"` + Tags []int64 `json:"tags"` + ReplyIDs []int64 `json:"reply_ids"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + Email int64 `json:"email"` + Password string `json:"-"` + CreatedAt time.Time `json:"created_at"` +} + +type Storage struct { + Posts interface { + Create(context.Context, *Post) error + } + Users interface { + Create(context.Context, *User) error + } +} diff --git a/scripts/migratedown.ps1 b/scripts/migratedown.ps1 new file mode 100644 index 0000000..5840039 --- /dev/null +++ b/scripts/migratedown.ps1 @@ -0,0 +1 @@ +migrate -path="./cmd/migrate/migrations" -database="$env:FOG_DB_ADDR" down \ No newline at end of file diff --git a/scripts/migratenew.ps1 b/scripts/migratenew.ps1 new file mode 100644 index 0000000..32cec83 --- /dev/null +++ b/scripts/migratenew.ps1 @@ -0,0 +1 @@ +migrate create -seq -ext sql -dir "./cmd/migrate/migrations" $args[0] \ No newline at end of file diff --git a/scripts/migrateup.ps1 b/scripts/migrateup.ps1 new file mode 100644 index 0000000..fc1cf7a --- /dev/null +++ b/scripts/migrateup.ps1 @@ -0,0 +1 @@ +migrate -path="./cmd/migrate/migrations" -database="$env:FOG_DB_ADDR" up \ No newline at end of file