Added user authentication

This commit is contained in:
Leon Mika 2025-02-01 09:42:32 +11:00
parent d7e7af5a10
commit cb54057305
40 changed files with 710 additions and 218 deletions

View file

@ -3,9 +3,7 @@ testdata_dir = "testdata"
tmp_dir = "tmp" tmp_dir = "tmp"
[build] [build]
args_bin = [ args_bin = []
"-no-auth"
]
bin = "./build/hugo-cms" bin = "./build/hugo-cms"
cmd = "make compile" cmd = "make compile"
delay = 1000 delay = 1000

View file

@ -10,4 +10,9 @@ prep:
.Phony: compile .Phony: compile
compile: prep compile: prep
go build -o ./build/hugo-cms go build -o ./build/hugo-cms
.Phony: init-db
init-db:
export $(cat .env | xargs)
./build/hugo-cms -user test@example.com -password test123

View file

@ -40,6 +40,11 @@ body.role-site main {
} }
div.post {
border-bottom: solid thin grey;
}
form.post-form { form.post-form {
display: flex; display: flex;

View file

@ -6,11 +6,13 @@ import (
) )
type Config struct { type Config struct {
DatabaseURL string `env:"DATABASE_URL"` DatabaseURL string `env:"DATABASE_URL"`
NetlifyAuthToken string `env:"NETLIFY_AUTH_TOKEN"` NetlifyAuthToken string `env:"NETLIFY_AUTH_TOKEN"`
DataDir string `env:"DATA_DIR"` DataDir string `env:"DATA_DIR"`
DataStagingDir string `env:"DATA_STAGING_DIR,default=staging"` EncryptedCookieKey string `env:"ENCRYPTED_COOKIE_KEY"`
DataScratchDir string `env:"DATA_SCRATCH_DIR,default=scratch"`
DataStagingDir string `env:"DATA_STAGING_DIR,default=staging"`
DataScratchDir string `env:"DATA_SCRATCH_DIR,default=scratch"`
} }
func Load() (cfg Config, err error) { func Load() (cfg Config, err error) {

View file

@ -156,10 +156,17 @@ type PublishTarget struct {
} }
type Site struct { type Site struct {
ID int64 ID int64
Name string OwnerUserID int64
Title string Name string
Url string Title string
Theme string Url string
Props []byte Theme string
Props []byte
}
type User struct {
ID int64
Email string
Password string
} }

View file

@ -11,8 +11,17 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
const deletePost = `-- name: DeletePost :exec
DELETE FROM posts WHERE id = $1
`
func (q *Queries) DeletePost(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, deletePost, id)
return err
}
const getPostWithID = `-- name: GetPostWithID :one const getPostWithID = `-- name: GetPostWithID :one
SELECT id, site_id, title, body, state, props, post_date, created_at FROM post WHERE id = $1 LIMIT 1 SELECT id, site_id, title, body, state, props, post_date, created_at FROM posts WHERE id = $1 LIMIT 1
` `
func (q *Queries) GetPostWithID(ctx context.Context, id int64) (Post, error) { func (q *Queries) GetPostWithID(ctx context.Context, id int64) (Post, error) {
@ -32,7 +41,7 @@ func (q *Queries) GetPostWithID(ctx context.Context, id int64) (Post, error) {
} }
const insertPost = `-- name: InsertPost :one const insertPost = `-- name: InsertPost :one
INSERT INTO post ( INSERT INTO posts (
site_id, site_id,
title, title,
body, body,
@ -70,7 +79,7 @@ func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64,
} }
const listPosts = `-- name: ListPosts :many const listPosts = `-- name: ListPosts :many
SELECT id, site_id, title, body, state, props, post_date, created_at FROM post WHERE site_id = $1 ORDER BY post_date DESC LIMIT 25 SELECT id, site_id, title, body, state, props, post_date, created_at FROM posts WHERE site_id = $1 ORDER BY post_date DESC LIMIT 25
` `
func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) { func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) {
@ -104,7 +113,7 @@ func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) {
const listPublishablePosts = `-- name: ListPublishablePosts :many const listPublishablePosts = `-- name: ListPublishablePosts :many
SELECT id, site_id, title, body, state, props, post_date, created_at SELECT id, site_id, title, body, state, props, post_date, created_at
FROM post FROM posts
WHERE id > $1 AND site_id = $2 AND state = 'published' AND post_date <= $3 WHERE id > $1 AND site_id = $2 AND state = 'published' AND post_date <= $3
ORDER BY id LIMIT 100 ORDER BY id LIMIT 100
` `
@ -145,7 +154,7 @@ func (q *Queries) ListPublishablePosts(ctx context.Context, arg ListPublishableP
} }
const updatePost = `-- name: UpdatePost :exec const updatePost = `-- name: UpdatePost :exec
UPDATE post SET UPDATE posts SET
site_id = $2, site_id = $2,
title = $3, title = $3,
body = $4, body = $4,

View file

@ -10,7 +10,7 @@ import (
) )
const getSiteWithID = `-- name: GetSiteWithID :one const getSiteWithID = `-- name: GetSiteWithID :one
SELECT id, name, title, url, theme, props FROM site WHERE id = $1 LIMIT 1 SELECT id, owner_user_id, name, title, url, theme, props FROM sites WHERE id = $1 LIMIT 1
` `
func (q *Queries) GetSiteWithID(ctx context.Context, id int64) (Site, error) { func (q *Queries) GetSiteWithID(ctx context.Context, id int64) (Site, error) {
@ -18,6 +18,7 @@ func (q *Queries) GetSiteWithID(ctx context.Context, id int64) (Site, error) {
var i Site var i Site
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.OwnerUserID,
&i.Name, &i.Name,
&i.Title, &i.Title,
&i.Url, &i.Url,
@ -28,7 +29,7 @@ func (q *Queries) GetSiteWithID(ctx context.Context, id int64) (Site, error) {
} }
const listSites = `-- name: ListSites :one const listSites = `-- name: ListSites :one
SELECT id, name, title, url, theme, props FROM site SELECT id, owner_user_id, name, title, url, theme, props FROM sites
` `
func (q *Queries) ListSites(ctx context.Context) (Site, error) { func (q *Queries) ListSites(ctx context.Context) (Site, error) {
@ -36,6 +37,7 @@ func (q *Queries) ListSites(ctx context.Context) (Site, error) {
var i Site var i Site
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.OwnerUserID,
&i.Name, &i.Name,
&i.Title, &i.Title,
&i.Url, &i.Url,
@ -46,27 +48,30 @@ func (q *Queries) ListSites(ctx context.Context) (Site, error) {
} }
const newSite = `-- name: NewSite :one const newSite = `-- name: NewSite :one
INSERT INTO site ( INSERT INTO sites (
name, name,
owner_user_id,
title, title,
url, url,
theme, theme,
props props
) VALUES ($1, $2, $3, $4, $5) ) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id RETURNING id
` `
type NewSiteParams struct { type NewSiteParams struct {
Name string Name string
Title string OwnerUserID int64
Url string Title string
Theme string Url string
Props []byte Theme string
Props []byte
} }
func (q *Queries) NewSite(ctx context.Context, arg NewSiteParams) (int64, error) { func (q *Queries) NewSite(ctx context.Context, arg NewSiteParams) (int64, error) {
row := q.db.QueryRow(ctx, newSite, row := q.db.QueryRow(ctx, newSite,
arg.Name, arg.Name,
arg.OwnerUserID,
arg.Title, arg.Title,
arg.Url, arg.Url,
arg.Theme, arg.Theme,

View file

@ -10,7 +10,7 @@ import (
) )
const insertPublishTarget = `-- name: InsertPublishTarget :one const insertPublishTarget = `-- name: InsertPublishTarget :one
INSERT INTO publish_target ( INSERT INTO publish_targets (
site_id, site_id,
role, role,
target_type, target_type,
@ -42,7 +42,7 @@ func (q *Queries) InsertPublishTarget(ctx context.Context, arg InsertPublishTarg
} }
const listPublishTargetsOfRole = `-- name: ListPublishTargetsOfRole :many const listPublishTargetsOfRole = `-- name: ListPublishTargetsOfRole :many
SELECT id, site_id, role, target_type, url, target_ref FROM publish_target WHERE site_id = $1 AND role = 'production' SELECT id, site_id, role, target_type, url, target_ref FROM publish_targets WHERE site_id = $1 AND role = 'production'
` `
func (q *Queries) ListPublishTargetsOfRole(ctx context.Context, siteID int64) ([]PublishTarget, error) { func (q *Queries) ListPublishTargetsOfRole(ctx context.Context, siteID int64) ([]PublishTarget, error) {

53
gen/sqlc/dbq/users.sql.go Normal file
View file

@ -0,0 +1,53 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: users.sql
package dbq
import (
"context"
)
const addUser = `-- name: AddUser :one
INSERT INTO users (
email,
password
) VALUES ($1, $2)
ON CONFLICT (email) DO UPDATE SET password = $2
RETURNING id
`
type AddUserParams struct {
Email string
Password string
}
func (q *Queries) AddUser(ctx context.Context, arg AddUserParams) (int64, error) {
row := q.db.QueryRow(ctx, addUser, arg.Email, arg.Password)
var id int64
err := row.Scan(&id)
return id, err
}
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, password FROM users WHERE email = $1 LIMIT 1
`
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
row := q.db.QueryRow(ctx, getUserByEmail, email)
var i User
err := row.Scan(&i.ID, &i.Email, &i.Password)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, email, password FROM users WHERE id = $1 LIMIT 1
`
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
row := q.db.QueryRow(ctx, getUserByID, id)
var i User
err := row.Scan(&i.ID, &i.Email, &i.Password)
return i, err
}

4
go.mod
View file

@ -10,7 +10,9 @@ require (
require ( require (
github.com/Netflix/go-env v0.1.2 // indirect github.com/Netflix/go-env v0.1.2 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect github.com/andybalholm/brotli v1.1.1 // indirect
github.com/coreos/go-oidc/v3 v3.12.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
github.com/gofiber/fiber/v2 v2.52.6 // indirect github.com/gofiber/fiber/v2 v2.52.6 // indirect
github.com/gofiber/fiber/v3 v3.0.0-beta.4 // indirect github.com/gofiber/fiber/v3 v3.0.0-beta.4 // indirect
github.com/gofiber/schema v1.2.0 // indirect github.com/gofiber/schema v1.2.0 // indirect
@ -18,6 +20,7 @@ require (
github.com/gofiber/template/html/v2 v2.1.3 // indirect github.com/gofiber/template/html/v2 v2.1.3 // indirect
github.com/gofiber/utils v1.1.0 // indirect github.com/gofiber/utils v1.1.0 // indirect
github.com/gofiber/utils/v2 v2.0.0-beta.7 // indirect github.com/gofiber/utils/v2 v2.0.0-beta.7 // indirect
github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
@ -40,6 +43,7 @@ require (
go.uber.org/atomic v1.7.0 // indirect go.uber.org/atomic v1.7.0 // indirect
golang.org/x/crypto v0.32.0 // indirect golang.org/x/crypto v0.32.0 // indirect
golang.org/x/net v0.34.0 // indirect golang.org/x/net v0.34.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sync v0.10.0 // indirect golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.21.0 // indirect

8
go.sum
View file

@ -2,10 +2,14 @@ github.com/Netflix/go-env v0.1.2 h1:0DRoLR9lECQ9Zqvkswuebm3jJ/2enaDX6Ei8/Z+EnK0=
github.com/Netflix/go-env v0.1.2/go.mod h1:WlIhYi++8FlKNJtrop1mjXYAJMzv1f43K4MqCoh0yGE= github.com/Netflix/go-env v0.1.2/go.mod h1:WlIhYi++8FlKNJtrop1mjXYAJMzv1f43K4MqCoh0yGE=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo=
github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/fiber/v3 v3.0.0-beta.4 h1:KzDSavvhG7m81NIsmnu5l3ZDbVS4feCidl4xlIfu6V0= github.com/gofiber/fiber/v3 v3.0.0-beta.4 h1:KzDSavvhG7m81NIsmnu5l3ZDbVS4feCidl4xlIfu6V0=
@ -20,6 +24,8 @@ github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0= github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
github.com/gofiber/utils/v2 v2.0.0-beta.7 h1:NnHFrRHvhrufPABdWajcKZejz9HnCWmT/asoxRsiEbQ= github.com/gofiber/utils/v2 v2.0.0-beta.7 h1:NnHFrRHvhrufPABdWajcKZejz9HnCWmT/asoxRsiEbQ=
github.com/gofiber/utils/v2 v2.0.0-beta.7/go.mod h1:J/M03s+HMdZdvhAeyh76xT72IfVqBzuz/OJkrMa7cwU= github.com/gofiber/utils/v2 v2.0.0-beta.7/go.mod h1:J/M03s+HMdZdvhAeyh76xT72IfVqBzuz/OJkrMa7cwU=
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y= github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -76,6 +82,8 @@ golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

69
handlers/auth.go Normal file
View file

@ -0,0 +1,69 @@
package handlers
import (
"encoding/json"
"errors"
"github.com/gofiber/fiber/v2"
"lmika.dev/lmika/hugo-cms/models"
"lmika.dev/lmika/hugo-cms/services/users"
"net/http"
)
type AuthHandler struct {
UserService *users.Service
}
func (h *AuthHandler) ShowLogin(c *fiber.Ctx) error {
return c.Render("auth/login", fiber.Map{}, "layouts/login")
}
func (h *AuthHandler) Login(c *fiber.Ctx) error {
var req struct {
Email string `form:"email"`
Password string `form:"password"`
}
if err := c.BodyParser(&req); err != nil {
return errors.New("invalid email or password")
}
user, err := h.UserService.VerifyLogin(c.UserContext(), req.Email, req.Password)
if err != nil {
return errors.New("invalid email or password")
}
bts, err := json.Marshal(models.AuthCookie{UserID: user.ID})
if err != nil {
return err
}
c.Cookie(&fiber.Cookie{
Name: models.AuthCookieName,
Value: string(bts),
})
return c.Redirect("/", http.StatusFound)
}
func (h *AuthHandler) RequireAuth(c *fiber.Ctx) error {
user, err := h.readAuthCookie(c)
if err != nil {
return c.Redirect("/auth/login", http.StatusFound)
}
c.Locals("user", user)
return c.Next()
}
func (h *AuthHandler) readAuthCookie(c *fiber.Ctx) (user models.User, err error) {
authData := c.Cookies(models.AuthCookieName)
if authData == "" {
return models.User{}, errors.New("no auth cookie")
}
var ac models.AuthCookie
if err := json.Unmarshal([]byte(authData), &ac); err != nil {
return models.User{}, err
}
return h.UserService.GetUserByID(c.UserContext(), ac.UserID)
}

View file

@ -1,14 +1,23 @@
package handlers package handlers
import ( import (
"errors"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"lmika.dev/lmika/hugo-cms/models" "lmika.dev/lmika/hugo-cms/models"
) )
type siteKeyType struct{} func GetUser(c *fiber.Ctx) models.User {
u, ok := c.Locals("user").(models.User)
var siteKey siteKeyType if !ok {
panic(errors.New("user not found in context"))
}
return u
}
func GetSite(c *fiber.Ctx) models.Site { func GetSite(c *fiber.Ctx) models.Site {
return c.UserContext().Value(siteKey).(models.Site) s, ok := c.Locals("site").(models.Site)
if !ok {
panic(errors.New("no site in context"))
}
return s
} }

37
handlers/mimeselectors.go Normal file
View file

@ -0,0 +1,37 @@
package handlers
import "github.com/gofiber/fiber/v2"
type mimeTypeHandler interface {
CanHandle(c *fiber.Ctx) bool
Handle(c *fiber.Ctx) error
}
type HTMX func(c *fiber.Ctx) error
func (h HTMX) CanHandle(c *fiber.Ctx) bool {
return c.Get("Hx-request") == "true"
}
func (h HTMX) Handle(c *fiber.Ctx) error {
return h(c)
}
type Otherwise func(c *fiber.Ctx) error
func (h Otherwise) CanHandle(c *fiber.Ctx) bool {
return true
}
func (h Otherwise) Handle(c *fiber.Ctx) error {
return h(c)
}
func Select(c *fiber.Ctx, mimeTypes ...mimeTypeHandler) error {
for _, mt := range mimeTypes {
if mt.CanHandle(c) {
return mt.Handle(c)
}
}
return c.Status(fiber.StatusInternalServerError).SendString("cant handle response")
}

View file

@ -5,121 +5,125 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"lmika.dev/lmika/hugo-cms/models" "lmika.dev/lmika/hugo-cms/models"
"lmika.dev/lmika/hugo-cms/services/posts" "lmika.dev/lmika/hugo-cms/services/posts"
"net/http"
) )
type Post struct { type Post struct {
Post *posts.Service Post *posts.Service
} }
func (h *Post) Posts() fiber.Handler { func (h *Post) Posts(c *fiber.Ctx) error {
return func(c *fiber.Ctx) error { site := GetSite(c)
site := GetSite(c)
posts, err := h.Post.ListPostOfSite(c.UserContext(), site) posts, err := h.Post.ListPostOfSite(c.UserContext(), site)
if err != nil { if err != nil {
return err return err
}
return c.Render("posts/index", fiber.Map{
"site": site,
"posts": posts,
}, "layouts/site")
} }
return c.Render("posts/index", fiber.Map{
"posts": posts,
}, "layouts/site")
} }
func (h *Post) New() fiber.Handler { func (h *Post) New(c *fiber.Ctx) error {
return func(c *fiber.Ctx) error { return c.Render("posts/new", fiber.Map{
site := GetSite(c) "post": models.Post{},
}, "layouts/site")
return c.Render("posts/new", fiber.Map{
"site": site,
"post": models.Post{},
}, "layouts/site")
}
} }
func (h *Post) Create() fiber.Handler { func (h *Post) Create(c *fiber.Ctx) error {
type Req struct { site := GetSite(c)
var req struct {
Title string `json:"title" form:"title"` Title string `json:"title" form:"title"`
Body string `json:"body" form:"body"` Body string `json:"body" form:"body"`
} }
if err := c.BodyParser(&req); err != nil {
return func(c *fiber.Ctx) error { return err
site := GetSite(c)
var req Req
if err := c.BodyParser(&req); err != nil {
return err
}
_, err := h.Post.Create(c.UserContext(), site, posts.NewPost{
Title: req.Title,
Body: req.Body,
})
if err != nil {
return err
}
return c.Redirect(fmt.Sprintf("/sites/%v/posts", site.ID))
} }
_, err := h.Post.Create(c.UserContext(), site, posts.NewPost{
Title: req.Title,
Body: req.Body,
})
if err != nil {
return err
}
return c.Redirect(fmt.Sprintf("/sites/%v/posts", site.ID))
} }
func (h *Post) Edit() fiber.Handler { func (h *Post) Edit(c *fiber.Ctx) error {
return func(c *fiber.Ctx) error { site := GetSite(c)
site := GetSite(c)
postID, err := c.ParamsInt("postId") postID, err := c.ParamsInt("postId")
if err != nil { if err != nil {
return err return err
}
post, err := h.Post.GetPost(c.UserContext(), postID)
if err != nil {
return err
} else if post.SiteID != site.ID {
return fmt.Errorf("post id %v not equal to site id %v", postID, site.ID)
}
return c.Render("posts/new", fiber.Map{
"site": site,
"post": post,
}, "layouts/site")
} }
post, err := h.Post.GetPost(c.UserContext(), postID)
if err != nil {
return err
} else if post.SiteID != site.ID {
return fmt.Errorf("post id %v not equal to site id %v", postID, site.ID)
}
return c.Render("posts/new", fiber.Map{
"post": post,
}, "layouts/site")
} }
func (h *Post) Update() fiber.Handler { func (h *Post) Update(c *fiber.Ctx) error {
type Req struct { site := GetSite(c)
postID, err := c.ParamsInt("postId")
if err != nil {
return err
}
var req struct {
Title string `json:"title" form:"title"` Title string `json:"title" form:"title"`
Body string `json:"body" form:"body"` Body string `json:"body" form:"body"`
} }
if err := c.BodyParser(&req); err != nil {
return func(c *fiber.Ctx) error { return err
site := GetSite(c)
postID, err := c.ParamsInt("postId")
if err != nil {
return err
}
var req Req
if err := c.BodyParser(&req); err != nil {
return err
}
post, err := h.Post.GetPost(c.UserContext(), postID)
if err != nil {
return err
} else if post.SiteID != site.ID {
return fmt.Errorf("post id %v not equal to site id %v", postID, site.ID)
}
post.Title = req.Title
post.Body = req.Body
if err := h.Post.Save(c.UserContext(), site, &post); err != nil {
return err
}
return c.Redirect(fmt.Sprintf("/sites/%v/posts", site.ID))
} }
post, err := h.Post.GetPost(c.UserContext(), postID)
if err != nil {
return err
} else if post.SiteID != site.ID {
return fmt.Errorf("post id %v not equal to site id %v", postID, site.ID)
}
post.Title = req.Title
post.Body = req.Body
if err := h.Post.Save(c.UserContext(), site, &post); err != nil {
return err
}
return c.Redirect(fmt.Sprintf("/sites/%v/posts", site.ID))
}
func (h *Post) Delete(c *fiber.Ctx) error {
site := GetSite(c)
postID, err := c.ParamsInt("postId")
if err != nil {
return err
}
if err := h.Post.DeletePost(c.UserContext(), site, postID); err != nil {
return err
}
return Select(c,
HTMX(func(c *fiber.Ctx) error {
return c.Status(http.StatusOK).SendString("")
}),
Otherwise(func(c *fiber.Ctx) error {
return c.Redirect(fmt.Sprintf("/sites/%v/posts", site.ID), http.StatusSeeOther)
}),
)
} }

View file

@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"context"
"fmt" "fmt"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"lmika.dev/lmika/hugo-cms/services/sites" "lmika.dev/lmika/hugo-cms/services/sites"
@ -12,43 +11,39 @@ type Site struct {
Site *sites.Service Site *sites.Service
} }
func (s *Site) Create() fiber.Handler { func (s *Site) Create(c *fiber.Ctx) error {
return func(c *fiber.Ctx) error { user := GetUser(c)
site, err := s.Site.CreateSite(c.UserContext(), "New Site "+time.Now().Format("2006-01-02 15:04:05"))
if err != nil {
return err
}
return c.Redirect(fmt.Sprintf("/sites/%v/posts", site.ID)) site, err := s.Site.CreateSite(c.UserContext(), user, "New Site "+time.Now().Format("2006-01-02 15:04:05"))
if err != nil {
return err
} }
return c.Redirect(fmt.Sprintf("/sites/%v/posts", site.ID))
} }
func (s *Site) Show() fiber.Handler { func (s *Site) Show(c *fiber.Ctx) error {
return func(c *fiber.Ctx) error { id, err := c.ParamsInt("siteId")
id, err := c.ParamsInt("siteId") if err != nil {
if err != nil { return err
return err
}
site, err := s.Site.GetSite(c.UserContext(), id)
if err != nil {
return err
}
return c.Render("sites/index", fiber.Map{
"site": site,
}, "layouts/main")
} }
site, err := s.Site.GetSite(c.UserContext(), id)
if err != nil {
return err
}
return c.Render("sites/index", fiber.Map{
"site": site,
}, "layouts/main")
} }
func (s *Site) Rebuild() fiber.Handler { func (s *Site) Rebuild(c *fiber.Ctx) error {
return func(c *fiber.Ctx) error { if err := s.Site.Rebuild(c.UserContext(), GetSite(c)); err != nil {
if err := s.Site.Rebuild(c.UserContext(), GetSite(c)); err != nil { return err
return err
}
return c.Redirect(fmt.Sprintf("/sites/%v/posts", GetSite(c).ID))
} }
return c.Redirect(fmt.Sprintf("/sites/%v/posts", GetSite(c).ID))
} }
func (s *Site) WithSite() fiber.Handler { func (s *Site) WithSite() fiber.Handler {
@ -63,7 +58,7 @@ func (s *Site) WithSite() fiber.Handler {
return err return err
} }
c.SetUserContext(context.WithValue(c.UserContext(), siteKey, site)) c.Locals("site", site)
return c.Next() return c.Next()
} }
} }

66
main.go
View file

@ -3,7 +3,10 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"flag"
"fmt"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/encryptcookie"
"github.com/gofiber/fiber/v2/middleware/filesystem" "github.com/gofiber/fiber/v2/middleware/filesystem"
"github.com/gofiber/template/html/v2" "github.com/gofiber/template/html/v2"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
@ -20,12 +23,23 @@ import (
"lmika.dev/lmika/hugo-cms/services/posts" "lmika.dev/lmika/hugo-cms/services/posts"
"lmika.dev/lmika/hugo-cms/services/sitebuilder" "lmika.dev/lmika/hugo-cms/services/sitebuilder"
"lmika.dev/lmika/hugo-cms/services/sites" "lmika.dev/lmika/hugo-cms/services/sites"
"lmika.dev/lmika/hugo-cms/services/users"
"lmika.dev/lmika/hugo-cms/templates" "lmika.dev/lmika/hugo-cms/templates"
"log" "log"
"net/http" "net/http"
) )
func main() { func main() {
flagGenKey := flag.Bool("gen-key", false, "Generate a new key")
flagUser := flag.String("user", "", "add new user")
flagPassword := flag.String("password", "", "add new password")
flag.Parse()
if *flagGenKey {
fmt.Println(encryptcookie.GenerateKey())
return
}
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@ -37,6 +51,19 @@ func main() {
} }
defer dbp.Close() defer dbp.Close()
userService := users.NewService(dbp)
if *flagUser != "" {
if _, err := userService.AddUser(context.Background(), users.NewUser{
Email: *flagUser,
Password: *flagPassword,
}); err != nil {
log.Fatal(err)
}
log.Println("User added")
return
}
hugoProvider, err := hugo.New(cfg.StagingDir(), cfg.ScratchDir()) hugoProvider, err := hugo.New(cfg.StagingDir(), cfg.ScratchDir())
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@ -53,6 +80,7 @@ func main() {
siteHandlers := handlers.Site{Site: siteService} siteHandlers := handlers.Site{Site: siteService}
postHandlers := handlers.Post{Post: postService} postHandlers := handlers.Post{Post: postService}
authHandlers := handlers.AuthHandler{UserService: userService}
log.Println("Connected to database") log.Println("Connected to database")
if err := dbp.Migrate(context.Background()); err != nil { if err := dbp.Migrate(context.Background()); err != nil {
@ -70,33 +98,41 @@ func main() {
return template.HTML(buf.String()), nil return template.HTML(buf.String()), nil
} }
if cfg.EncryptedCookieKey == "" {
log.Println("No encrypt cookie key defined. Generating random key")
cfg.EncryptedCookieKey = encryptcookie.GenerateKey()
}
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
Views: tmplEngine, Views: tmplEngine,
PassLocalsToViews: true,
}) })
app.Use(encryptcookie.New(encryptcookie.Config{Key: cfg.EncryptedCookieKey}))
app.Use("/assets", filesystem.New(filesystem.Config{Root: http.FS(assets.FS)}))
app.Get("/auth/login", authHandlers.ShowLogin)
app.Post("/auth/login", authHandlers.Login)
app.Use(authHandlers.RequireAuth)
app.Get("/", func(c *fiber.Ctx) error { app.Get("/", func(c *fiber.Ctx) error {
return c.Render("index", fiber.Map{}, "layouts/main") return c.Render("index", fiber.Map{}, "layouts/main")
}) })
app.Post("/sites", siteHandlers.Create)
app.Post("/sites", siteHandlers.Create()) app.Get("/sites/:siteId", siteHandlers.Show)
app.Get("/sites/:siteId", siteHandlers.Show())
app.Route("/sites/:siteId", func(r fiber.Router) { app.Route("/sites/:siteId", func(r fiber.Router) {
r.Use(siteHandlers.WithSite()) r.Use(siteHandlers.WithSite())
r.Post("/rebuild", siteHandlers.Rebuild()) r.Post("/rebuild", siteHandlers.Rebuild)
r.Get("/posts", postHandlers.Posts()) r.Get("/posts", postHandlers.Posts)
r.Get("/posts/:postId", postHandlers.Edit()) r.Get("/posts/new", postHandlers.New)
r.Get("/posts/new", postHandlers.New()) r.Post("/posts", postHandlers.Create)
r.Post("/posts", postHandlers.Create()) r.Get("/posts/:postId", postHandlers.Edit)
r.Post("/posts/:postId", postHandlers.Update()) r.Post("/posts/:postId", postHandlers.Update)
r.Delete("/posts/:postId", postHandlers.Delete)
}) })
app.Use("/assets", filesystem.New(filesystem.Config{
Root: http.FS(assets.FS),
}))
app.Static("/assets", "./assets")
jobService.Start() jobService.Start()
defer jobService.Stop() defer jobService.Stop()

9
models/cookie.go Normal file
View file

@ -0,0 +1,9 @@
package models
const (
AuthCookieName = "hugocrm_auth"
)
type AuthCookie struct {
UserID int64 `json:"uid"`
}

View file

@ -12,6 +12,7 @@ const (
type Post struct { type Post struct {
ID int64 ID int64
SiteID int64 SiteID int64
OwnerID int64
Title string Title string
Body string Body string
State PostState State PostState

View file

@ -1,9 +1,10 @@
package models package models
type Site struct { type Site struct {
ID int64 ID int64
Name string OwnerUserID int64
Title string Name string
URL string Title string
Theme string URL string
Theme string
} }

7
models/user.go Normal file
View file

@ -0,0 +1,7 @@
package models
type User struct {
ID int64
Email string
PasswordHash string
}

View file

@ -27,6 +27,10 @@ func (db *DB) GetPost(ctx context.Context, postID int64) (models.Post, error) {
return dbPostToPost(res), nil return dbPostToPost(res), nil
} }
func (db *DB) DeletePost(ctx context.Context, postID int64) error {
return db.q.DeletePost(ctx, postID)
}
func (db *DB) ListPublishablePosts(ctx context.Context, fromID, siteID int64, now time.Time) ([]models.Post, error) { func (db *DB) ListPublishablePosts(ctx context.Context, fromID, siteID int64, now time.Time) ([]models.Post, error) {
res, err := db.q.ListPublishablePosts(ctx, dbq.ListPublishablePostsParams{ res, err := db.q.ListPublishablePosts(ctx, dbq.ListPublishablePostsParams{
ID: fromID, ID: fromID,

View file

@ -8,11 +8,12 @@ import (
func (db *DB) InsertSite(ctx context.Context, site *models.Site) error { func (db *DB) InsertSite(ctx context.Context, site *models.Site) error {
id, err := db.q.NewSite(ctx, dbq.NewSiteParams{ id, err := db.q.NewSite(ctx, dbq.NewSiteParams{
Name: site.Name, Name: site.Name,
Title: site.Title, Title: site.Title,
Url: site.URL, OwnerUserID: site.OwnerUserID,
Theme: site.Theme, Url: site.URL,
Props: []byte("{}"), Theme: site.Theme,
Props: []byte("{}"),
}) })
if err != nil { if err != nil {
return err return err
@ -31,6 +32,7 @@ func (db *DB) GetSite(ctx context.Context, id int64) (models.Site, error) {
ID: site.ID, ID: site.ID,
Name: site.Name, Name: site.Name,
Title: site.Title, Title: site.Title,
URL: site.Url,
Theme: site.Theme, Theme: site.Theme,
}, nil }, nil
} }

45
providers/db/user.go Normal file
View file

@ -0,0 +1,45 @@
package db
import (
"context"
"lmika.dev/lmika/hugo-cms/gen/sqlc/dbq"
"lmika.dev/lmika/hugo-cms/models"
)
func (db *DB) AddUser(ctx context.Context, user *models.User) error {
id, err := db.q.AddUser(ctx, dbq.AddUserParams{
Email: user.Email,
Password: user.PasswordHash,
})
if err != nil {
return err
}
user.ID = id
return nil
}
func (db *DB) GetUserByID(ctx context.Context, id int64) (models.User, error) {
res, err := db.q.GetUserByID(ctx, id)
if err != nil {
return models.User{}, err
}
return dbUserToUser(res), nil
}
func (db *DB) GetUserByEmail(ctx context.Context, email string) (models.User, error) {
res, err := db.q.GetUserByEmail(ctx, email)
if err != nil {
return models.User{}, err
}
return dbUserToUser(res), nil
}
func dbUserToUser(u dbq.User) models.User {
return models.User{
ID: u.ID,
Email: u.Email,
PasswordHash: u.Password,
}
}

View file

@ -40,6 +40,19 @@ func (s *Service) GetPost(ctx context.Context, id int) (models.Post, error) {
return post, nil return post, nil
} }
func (s *Service) DeletePost(ctx context.Context, site models.Site, id int) error {
post, err := s.db.GetPost(ctx, int64(id))
if err != nil {
return err
}
if err := s.db.DeletePost(ctx, int64(id)); err != nil {
return err
}
return s.jobs.Queue(ctx, s.sb.DeletePost(site, post))
}
func (s *Service) Create(ctx context.Context, site models.Site, req NewPost) (models.Post, error) { func (s *Service) Create(ctx context.Context, site models.Site, req NewPost) (models.Post, error) {
post := models.Post{ post := models.Post{
SiteID: site.ID, SiteID: site.ID,

View file

@ -47,13 +47,41 @@ func (s *Service) WriteAllPosts(site models.Site) models.Job {
} }
} }
func (s *Service) DeletePost(site models.Site, post models.Post) models.Job {
return models.Jobs(
models.Job{
Do: func(ctx context.Context) error {
themeMeta, ok := s.themes.Lookup(site.Theme)
if !ok {
return errors.New("theme not found")
}
postFilename := s.postFilename(site, themeMeta, post)
if _, err := os.Stat(postFilename); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if os.Remove(postFilename) != nil {
return nil
}
return nil
},
},
s.Publish(site),
)
}
func (s *Service) writePost(site models.Site, post models.Post) error { func (s *Service) writePost(site models.Site, post models.Post) error {
themeMeta, ok := s.themes.Lookup(site.Theme) themeMeta, ok := s.themes.Lookup(site.Theme)
if !ok { if !ok {
return errors.New("theme not found") return errors.New("theme not found")
} }
postFilename := filepath.Join(s.hugo.SiteStagingDir(site, hugo.ContentSiteDir), themeMeta.PostDir, post.CreatedAt.Format("2006-01-02-150405.md")) postFilename := s.postFilename(site, themeMeta, post)
log.Printf(" .. post %v", postFilename) log.Printf(" .. post %v", postFilename)
@ -96,3 +124,7 @@ func (s *Service) writePost(site models.Site, post models.Post) error {
return nil return nil
} }
func (s *Service) postFilename(site models.Site, themeMeta models.ThemeMeta, post models.Post) string {
return filepath.Join(s.hugo.SiteStagingDir(site, hugo.ContentSiteDir), themeMeta.PostDir, post.CreatedAt.Format("2006-01-02-150405.md"))
}

View file

@ -41,11 +41,13 @@ func (s *Service) GetSite(ctx context.Context, id int) (models.Site, error) {
return s.db.GetSite(ctx, int64(id)) return s.db.GetSite(ctx, int64(id))
} }
func (s *Service) CreateSite(ctx context.Context, name string) (models.Site, error) { func (s *Service) CreateSite(ctx context.Context, user models.User, name string) (models.Site, error) {
newSite := models.Site{ newSite := models.Site{
Name: normaliseName(name), Name: normaliseName(name),
Title: name, OwnerUserID: user.ID,
Theme: "bear", Title: name,
Theme: "bear",
URL: "https://meek-meringue-060cfc.netlify.app/",
} }
_, ok := s.themes.Lookup(newSite.Theme) _, ok := s.themes.Lookup(newSite.Theme)
@ -62,7 +64,7 @@ func (s *Service) CreateSite(ctx context.Context, name string) (models.Site, err
SiteID: newSite.ID, SiteID: newSite.ID,
Role: models.TargetRoleProduction, Role: models.TargetRoleProduction,
Type: models.TargetTypeNetlify, Type: models.TargetTypeNetlify,
URL: "https://meek-meringue-060cfc.netlify.app", URL: "https://meek-meringue-060cfc.netlify.app/",
TargetRef: "e628dc6e-e6e1-45a9-847a-982adef940a8", TargetRef: "e628dc6e-e6e1-45a9-847a-982adef940a8",
}); err != nil { }); err != nil {
return models.Site{}, err return models.Site{}, err

76
services/users/service.go Normal file
View file

@ -0,0 +1,76 @@
package users
import (
"context"
"encoding/base64"
"golang.org/x/crypto/bcrypt"
"lmika.dev/lmika/hugo-cms/models"
"lmika.dev/lmika/hugo-cms/providers/db"
"log"
)
type Service struct {
dbp *db.DB
}
func NewService(dbp *db.DB) *Service {
return &Service{
dbp: dbp,
}
}
func (s *Service) AddUser(ctx context.Context, newUser NewUser) (models.User, error) {
passwd, err := bcrypt.GenerateFromPassword([]byte(newUser.Password), bcrypt.DefaultCost)
if err != nil {
return models.User{}, err
}
user := models.User{
Email: newUser.Email,
PasswordHash: base64.StdEncoding.EncodeToString(passwd),
}
if err := s.dbp.AddUser(ctx, &user); err != nil {
return models.User{}, err
}
return user, nil
}
func (s *Service) GetUserByID(ctx context.Context, id int64) (models.User, error) {
return s.dbp.GetUserByID(ctx, id)
}
func (s *Service) VerifyLogin(ctx context.Context, email string, password string) (models.User, error) {
user, err := s.dbp.GetUserByEmail(ctx, email)
if err != nil {
log.Println("User not found")
return models.User{}, err
}
pwdHash, err := base64.StdEncoding.DecodeString(user.PasswordHash)
if err != nil {
return models.User{}, err
}
err = bcrypt.CompareHashAndPassword(pwdHash, []byte(password))
if err != nil {
log.Println("Password incorrect")
return models.User{}, err
}
return user, nil
}
func (s *Service) GetUserByEmail(ctx context.Context, email string) (models.User, error) {
user, err := s.dbp.GetUserByEmail(ctx, email)
if err != nil {
return models.User{}, err
}
return user, nil
}
type NewUser struct {
Email string `json:"email"`
Password string `json:"password"`
}

View file

@ -1,17 +1,17 @@
-- name: ListPosts :many -- name: ListPosts :many
SELECT * FROM post WHERE site_id = $1 ORDER BY post_date DESC LIMIT 25; SELECT * FROM posts WHERE site_id = $1 ORDER BY post_date DESC LIMIT 25;
-- name: GetPostWithID :one -- name: GetPostWithID :one
SELECT * FROM post WHERE id = $1 LIMIT 1; SELECT * FROM posts WHERE id = $1 LIMIT 1;
-- name: ListPublishablePosts :many -- name: ListPublishablePosts :many
SELECT * SELECT *
FROM post FROM posts
WHERE id > $1 AND site_id = $2 AND state = 'published' AND post_date <= $3 WHERE id > $1 AND site_id = $2 AND state = 'published' AND post_date <= $3
ORDER BY id LIMIT 100; ORDER BY id LIMIT 100;
-- name: InsertPost :one -- name: InsertPost :one
INSERT INTO post ( INSERT INTO posts (
site_id, site_id,
title, title,
body, body,
@ -23,7 +23,7 @@ INSERT INTO post (
RETURNING id; RETURNING id;
-- name: UpdatePost :exec -- name: UpdatePost :exec
UPDATE post SET UPDATE posts SET
site_id = $2, site_id = $2,
title = $3, title = $3,
body = $4, body = $4,
@ -31,4 +31,7 @@ UPDATE post SET
props = $6, props = $6,
post_date = $7 post_date = $7
-- updated_at = $7 -- updated_at = $7
WHERE id = $1; WHERE id = $1;
-- name: DeletePost :exec
DELETE FROM posts WHERE id = $1;

View file

@ -1,15 +1,16 @@
-- name: ListSites :one -- name: ListSites :one
SELECT * FROM site; SELECT * FROM sites;
-- name: GetSiteWithID :one -- name: GetSiteWithID :one
SELECT * FROM site WHERE id = $1 LIMIT 1; SELECT * FROM sites WHERE id = $1 LIMIT 1;
-- name: NewSite :one -- name: NewSite :one
INSERT INTO site ( INSERT INTO sites (
name, name,
owner_user_id,
title, title,
url, url,
theme, theme,
props props
) VALUES ($1, $2, $3, $4, $5) ) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id; RETURNING id;

View file

@ -1,8 +1,8 @@
-- name: ListPublishTargetsOfRole :many -- name: ListPublishTargetsOfRole :many
SELECT * FROM publish_target WHERE site_id = $1 AND role = 'production'; SELECT * FROM publish_targets WHERE site_id = $1 AND role = 'production';
-- name: InsertPublishTarget :one -- name: InsertPublishTarget :one
INSERT INTO publish_target ( INSERT INTO publish_targets (
site_id, site_id,
role, role,
target_type, target_type,

13
sql/queries/users.sql Normal file
View file

@ -0,0 +1,13 @@
-- name: AddUser :one
INSERT INTO users (
email,
password
) VALUES ($1, $2)
ON CONFLICT (email) DO UPDATE SET password = $2
RETURNING id;
-- name: GetUserByID :one
SELECT * FROM users WHERE id = $1 LIMIT 1;
-- name: GetUserByEmail :one
SELECT * FROM users WHERE email = $1 LIMIT 1;

View file

@ -11,16 +11,25 @@ CREATE TYPE target_type AS ENUM (
'netlify' 'netlify'
); );
CREATE TABLE site ( CREATE TABLE users (
id BIGSERIAL NOT NULL PRIMARY KEY, id BIGSERIAL NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
title TEXT NOT NULL, password TEXT NOT NULL
url TEXT NOT NULL,
theme TEXT NOT NULL,
props JSON NOT NULL
); );
CREATE TABLE post ( CREATE TABLE sites (
id BIGSERIAL NOT NULL PRIMARY KEY,
owner_user_id BIGINT NOT NULL,
name TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
url TEXT NOT NULL,
theme TEXT NOT NULL,
props JSON NOT NULL,
FOREIGN KEY (owner_user_id) REFERENCES users (id)
);
CREATE TABLE posts (
id BIGSERIAL NOT NULL PRIMARY KEY, id BIGSERIAL NOT NULL PRIMARY KEY,
site_id BIGINT NOT NULL, site_id BIGINT NOT NULL,
title TEXT, title TEXT,
@ -30,10 +39,10 @@ CREATE TABLE post (
post_date TIMESTAMP WITH TIME ZONE, post_date TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL,
FOREIGN KEY (site_id) REFERENCES site (id) FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE
); );
CREATE TABLE publish_target ( CREATE TABLE publish_targets (
id BIGSERIAL NOT NULL PRIMARY KEY, id BIGSERIAL NOT NULL PRIMARY KEY,
site_id BIGINT NOT NULL, site_id BIGINT NOT NULL,
role target_role NOT NULL, role target_role NOT NULL,
@ -41,5 +50,5 @@ CREATE TABLE publish_target (
url TEXT NOT NULL, url TEXT NOT NULL,
target_ref TEXT NOT NULL, target_ref TEXT NOT NULL,
FOREIGN KEY (site_id) REFERENCES site (id) FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE
); );

11
templates/auth/login.html Normal file
View file

@ -0,0 +1,11 @@
<form method="post" action="/auth/login" class="post-form">
<p>
<input name="email" value="" placeholder="Email">
</p>
<p>
<input name="password" type="password" value="" placeholder="Password">
</p>
<p>
<input type="submit" value="Login">
</p>
</form>

View file

@ -3,6 +3,7 @@ package templates
import "embed" import "embed"
//go:embed *.html //go:embed *.html
//go:embed auth/*.html
//go:embed layouts/*.html //go:embed layouts/*.html
//go:embed posts/*.html //go:embed posts/*.html
var FS embed.FS var FS embed.FS

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/assets/css/reset.css">
<link rel="stylesheet" href="/assets/css/main.css">
<title>Hugo CRM</title>
</head>
<body>
{{embed}}
</body>
</html>

View file

@ -6,6 +6,7 @@
<link rel="stylesheet" href="/assets/css/reset.css"> <link rel="stylesheet" href="/assets/css/reset.css">
<link rel="stylesheet" href="/assets/css/main.css"> <link rel="stylesheet" href="/assets/css/main.css">
<title>Hugo CRM</title> <title>Hugo CRM</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head> </head>
<body> <body>
{{embed}} {{embed}}

View file

@ -6,12 +6,14 @@
<link rel="stylesheet" href="/assets/css/reset.css"> <link rel="stylesheet" href="/assets/css/reset.css">
<link rel="stylesheet" href="/assets/css/main.css"> <link rel="stylesheet" href="/assets/css/main.css">
<title>Hugo CMS</title> <title>Hugo CMS</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head> </head>
<body class="role-site"> <body class="role-site">
<header> <header>
<h1>Hugo CMS</h1> <h1>Hugo CMS</h1>
<nav> <nav>
<span>{{.site.Name}}</span> <span>{{.site.Name}}</span>
<a href="{{.site.URL}}">Visit</a>
<a href="#">User</a> <a href="#">User</a>
</nav> </nav>
</header> </header>

View file

@ -3,18 +3,18 @@
</div> </div>
{{range .posts}} {{range .posts}}
{{if .Title}}
<h3>{{.Title}}</h3>
{{end}}
<div class="post"> <div class="post">
{{if .Title}}
<h3>{{.Title}}</h3>
{{end}}
{{.Body | markdown}} {{.Body | markdown}}
<div> <div>
<a href="/sites/{{$.site.ID}}/posts/{{.ID}}">Edit</a> <a href="/sites/{{$.site.ID}}/posts/{{.ID}}">Edit</a> |
<a hx-delete="/sites/{{$.site.ID}}/posts/{{.ID}}" hx-confirm="Delete post?" hx-target="closest .post" href="#">Delete</a>
</div> </div>
</div> </div>
<hr>
{{else}} {{else}}
<p>No posts yet</p> <p>No posts yet</p>
{{end}} {{end}}

View file

@ -1,5 +1,5 @@
{{- $postTarget := printf "/sites/%v/posts" .site.ID -}} {{- $postTarget := printf "/sites/%v/posts" .site.ID -}}
{{- if .post.ID -}} {{- if (ne .post.ID 0) -}}
{{- $postTarget = printf "/sites/%v/posts/%v" .site.ID .post.ID -}} {{- $postTarget = printf "/sites/%v/posts/%v" .site.ID .post.ID -}}
{{- end -}} {{- end -}}
<form method="post" action="{{$postTarget}}" class="post-form"> <form method="post" action="{{$postTarget}}" class="post-form">