storage layer
This commit is contained in:
parent
27170540d0
commit
d6c9511d66
22 changed files with 328 additions and 24 deletions
33
README.md
33
README.md
|
@ -2,23 +2,18 @@
|
||||||
**Fog** is a **F**orum mixed with a Bl**og**, intended to encourage fruitful
|
**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
|
To get started:
|
||||||
These are your typical blog posts, written in Markdown. They can include video,
|
1. Install [Go](https://go.dev).
|
||||||
images and (one day) custom JS for interactivity.
|
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
|
Frontend has a seperate setup. See [`web/README.md`](web/README.md) for more
|
||||||
main post. You can reply to multiple posts at once, and you will be linked in
|
info.
|
||||||
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.
|
|
|
@ -8,15 +8,27 @@ import (
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"midnadimple.com/fog/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type application struct {
|
type application struct {
|
||||||
config config
|
config config
|
||||||
v1 *v1Handler
|
store store.Storage
|
||||||
|
|
||||||
|
v1 *v1Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
addr string
|
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) {
|
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 {
|
func (app *application) run() error {
|
||||||
|
app.v1 = &v1Handler{
|
||||||
|
healthCheck: &healthCheckHandler{},
|
||||||
|
}
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: app.config.addr,
|
Addr: app.config.addr,
|
||||||
Handler: app,
|
Handler: app,
|
||||||
|
|
|
@ -2,22 +2,39 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"midnadimple.com/fog/internal/db"
|
||||||
"midnadimple.com/fog/internal/env"
|
"midnadimple.com/fog/internal/env"
|
||||||
|
"midnadimple.com/fog/internal/store/postgres"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg := config{
|
cfg := config{
|
||||||
addr: env.GetString("FOG_ADDR", ":8080"),
|
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{
|
db, err := db.New(
|
||||||
healthCheck: &healthCheckHandler{},
|
cfg.db.addr,
|
||||||
|
cfg.db.maxOpenConns,
|
||||||
|
cfg.db.maxIdleConns,
|
||||||
|
cfg.db.maxIdleTime,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
store := postgres.NewPostgresStorage(db)
|
||||||
|
|
||||||
app := &application{
|
app := &application{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
v1: v1,
|
store: store,
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Fatal(app.run())
|
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
|
module midnadimple.com/fog
|
||||||
|
|
||||||
go 1.23.4
|
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"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetString(key, fallback string) string {
|
func GetString(key, fallback string) string {
|
||||||
val, ok := os.LookupEnv(key)
|
val, ok := os.LookupEnv(key)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
log.Printf("Failed to lookup enviornment variable with key %s. Using fallback value '%s'", key, fallback)
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,6 +20,7 @@ func GetString(key, fallback string) string {
|
||||||
func GetInt(key string, fallback int) int {
|
func GetInt(key string, fallback int) int {
|
||||||
val, ok := os.LookupEnv(key)
|
val, ok := os.LookupEnv(key)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
log.Printf("Failed to lookup enviornment variable with key %s. Using fallback value '%d'", key, fallback)
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,3 +32,19 @@ func GetInt(key string, fallback int) int {
|
||||||
|
|
||||||
return valInt
|
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