storage layer
This commit is contained in:
parent
27170540d0
commit
d6c9511d66
35
README.md
35
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.
|
||||
Frontend has a seperate setup. See [`web/README.md`](web/README.md) for more
|
||||
info.
|
|
@ -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,
|
||||
|
|
|
@ -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())
|
||||
|
|
1
cmd/migrate/migrations/000001_create_users.down.sql
Normal file
1
cmd/migrate/migrations/000001_create_users.down.sql
Normal file
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS users;
|
9
cmd/migrate/migrations/000001_create_users.up.sql
Normal file
9
cmd/migrate/migrations/000001_create_users.up.sql
Normal file
|
@ -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()
|
||||
);
|
1
cmd/migrate/migrations/000002_create_posts.down.sql
Normal file
1
cmd/migrate/migrations/000002_create_posts.down.sql
Normal file
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS posts
|
10
cmd/migrate/migrations/000002_create_posts.up.sql
Normal file
10
cmd/migrate/migrations/000002_create_posts.up.sql
Normal file
|
@ -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()
|
||||
);
|
3
cmd/migrate/migrations/000003_alter_posts.down.sql
Normal file
3
cmd/migrate/migrations/000003_alter_posts.down.sql
Normal file
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE
|
||||
posts
|
||||
DROP CONSTRAINT fk_user;
|
3
cmd/migrate/migrations/000003_alter_posts.up.sql
Normal file
3
cmd/migrate/migrations/000003_alter_posts.up.sql
Normal file
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE
|
||||
posts
|
||||
ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users (id);
|
22
docs/design.md
Normal file
22
docs/design.md
Normal file
|
@ -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.
|
20
docs/envvars.md
Normal file
20
docs/envvars.md
Normal file
|
@ -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"
|
2
go.mod
2
go.mod
|
@ -1,3 +1,5 @@
|
|||
module midnadimple.com/fog
|
||||
|
||||
go 1.23.4
|
||||
|
||||
require github.com/lib/pq v1.10.9
|
||||
|
|
2
go.sum
Normal file
2
go.sum
Normal file
|
@ -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=
|
31
internal/db/db.go
Normal file
31
internal/db/db.go
Normal file
|
@ -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
|
||||
}
|
19
internal/env/env.go
vendored
19
internal/env/env.go
vendored
|
@ -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
|
||||
}
|
||||
|
|
20
internal/store/postgres/postgres.go
Normal file
20
internal/store/postgres/postgres.go
Normal file
|
@ -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
|
||||
}
|
52
internal/store/postgres/posts.go
Normal file
52
internal/store/postgres/posts.go
Normal file
|
@ -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
|
||||
}
|
44
internal/store/postgres/users.go
Normal file
44
internal/store/postgres/users.go
Normal file
|
@ -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
|
||||
}
|
34
internal/store/storage.go
Normal file
34
internal/store/storage.go
Normal file
|
@ -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
|
||||
}
|
||||
}
|
1
scripts/migratedown.ps1
Normal file
1
scripts/migratedown.ps1
Normal file
|
@ -0,0 +1 @@
|
|||
migrate -path="./cmd/migrate/migrations" -database="$env:FOG_DB_ADDR" down
|
1
scripts/migratenew.ps1
Normal file
1
scripts/migratenew.ps1
Normal file
|
@ -0,0 +1 @@
|
|||
migrate create -seq -ext sql -dir "./cmd/migrate/migrations" $args[0]
|
1
scripts/migrateup.ps1
Normal file
1
scripts/migrateup.ps1
Normal file
|
@ -0,0 +1 @@
|
|||
migrate -path="./cmd/migrate/migrations" -database="$env:FOG_DB_ADDR" up
|
Loading…
Reference in a new issue