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"
[build]
args_bin = [
"-no-auth"
]
args_bin = []
bin = "./build/hugo-cms"
cmd = "make compile"
delay = 1000

View file

@ -11,3 +11,8 @@ prep:
.Phony: compile
compile: prep
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 {
display: flex;

View file

@ -9,6 +9,8 @@ type Config struct {
DatabaseURL string `env:"DATABASE_URL"`
NetlifyAuthToken string `env:"NETLIFY_AUTH_TOKEN"`
DataDir string `env:"DATA_DIR"`
EncryptedCookieKey string `env:"ENCRYPTED_COOKIE_KEY"`
DataStagingDir string `env:"DATA_STAGING_DIR,default=staging"`
DataScratchDir string `env:"DATA_SCRATCH_DIR,default=scratch"`
}

View file

@ -157,9 +157,16 @@ type PublishTarget struct {
type Site struct {
ID int64
OwnerUserID int64
Name string
Title string
Url string
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"
)
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
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) {
@ -32,7 +41,7 @@ func (q *Queries) GetPostWithID(ctx context.Context, id int64) (Post, error) {
}
const insertPost = `-- name: InsertPost :one
INSERT INTO post (
INSERT INTO posts (
site_id,
title,
body,
@ -70,7 +79,7 @@ func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64,
}
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) {
@ -104,7 +113,7 @@ func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) {
const listPublishablePosts = `-- name: ListPublishablePosts :many
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
ORDER BY id LIMIT 100
`
@ -145,7 +154,7 @@ func (q *Queries) ListPublishablePosts(ctx context.Context, arg ListPublishableP
}
const updatePost = `-- name: UpdatePost :exec
UPDATE post SET
UPDATE posts SET
site_id = $2,
title = $3,
body = $4,

View file

@ -10,7 +10,7 @@ import (
)
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) {
@ -18,6 +18,7 @@ func (q *Queries) GetSiteWithID(ctx context.Context, id int64) (Site, error) {
var i Site
err := row.Scan(
&i.ID,
&i.OwnerUserID,
&i.Name,
&i.Title,
&i.Url,
@ -28,7 +29,7 @@ func (q *Queries) GetSiteWithID(ctx context.Context, id int64) (Site, error) {
}
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) {
@ -36,6 +37,7 @@ func (q *Queries) ListSites(ctx context.Context) (Site, error) {
var i Site
err := row.Scan(
&i.ID,
&i.OwnerUserID,
&i.Name,
&i.Title,
&i.Url,
@ -46,18 +48,20 @@ func (q *Queries) ListSites(ctx context.Context) (Site, error) {
}
const newSite = `-- name: NewSite :one
INSERT INTO site (
INSERT INTO sites (
name,
owner_user_id,
title,
url,
theme,
props
) VALUES ($1, $2, $3, $4, $5)
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`
type NewSiteParams struct {
Name string
OwnerUserID int64
Title string
Url string
Theme string
@ -67,6 +71,7 @@ type NewSiteParams struct {
func (q *Queries) NewSite(ctx context.Context, arg NewSiteParams) (int64, error) {
row := q.db.QueryRow(ctx, newSite,
arg.Name,
arg.OwnerUserID,
arg.Title,
arg.Url,
arg.Theme,

View file

@ -10,7 +10,7 @@ import (
)
const insertPublishTarget = `-- name: InsertPublishTarget :one
INSERT INTO publish_target (
INSERT INTO publish_targets (
site_id,
role,
target_type,
@ -42,7 +42,7 @@ func (q *Queries) InsertPublishTarget(ctx context.Context, arg InsertPublishTarg
}
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) {

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 (
github.com/Netflix/go-env v0.1.2 // 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/go-jose/go-jose/v4 v4.0.2 // indirect
github.com/gofiber/fiber/v2 v2.52.6 // indirect
github.com/gofiber/fiber/v3 v3.0.0-beta.4 // 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/utils v1.1.0 // 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/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
@ -40,6 +43,7 @@ require (
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/crypto v0.32.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/sys v0.29.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/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
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.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/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/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
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/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/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/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
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/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
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/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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
import (
"errors"
"github.com/gofiber/fiber/v2"
"lmika.dev/lmika/hugo-cms/models"
)
type siteKeyType struct{}
var siteKey siteKeyType
func GetUser(c *fiber.Ctx) models.User {
u, ok := c.Locals("user").(models.User)
if !ok {
panic(errors.New("user not found in context"))
}
return u
}
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,14 +5,14 @@ import (
"github.com/gofiber/fiber/v2"
"lmika.dev/lmika/hugo-cms/models"
"lmika.dev/lmika/hugo-cms/services/posts"
"net/http"
)
type Post struct {
Post *posts.Service
}
func (h *Post) Posts() fiber.Handler {
return func(c *fiber.Ctx) error {
func (h *Post) Posts(c *fiber.Ctx) error {
site := GetSite(c)
posts, err := h.Post.ListPostOfSite(c.UserContext(), site)
@ -21,33 +21,23 @@ func (h *Post) Posts() fiber.Handler {
}
return c.Render("posts/index", fiber.Map{
"site": site,
"posts": posts,
}, "layouts/site")
}
}
func (h *Post) New() fiber.Handler {
return func(c *fiber.Ctx) error {
site := GetSite(c)
func (h *Post) New(c *fiber.Ctx) error {
return c.Render("posts/new", fiber.Map{
"site": site,
"post": models.Post{},
}, "layouts/site")
}
}
func (h *Post) Create() fiber.Handler {
type Req struct {
func (h *Post) Create(c *fiber.Ctx) error {
site := GetSite(c)
var req struct {
Title string `json:"title" form:"title"`
Body string `json:"body" form:"body"`
}
return func(c *fiber.Ctx) error {
site := GetSite(c)
var req Req
if err := c.BodyParser(&req); err != nil {
return err
}
@ -61,11 +51,9 @@ func (h *Post) Create() fiber.Handler {
}
return c.Redirect(fmt.Sprintf("/sites/%v/posts", site.ID))
}
}
func (h *Post) Edit() fiber.Handler {
return func(c *fiber.Ctx) error {
func (h *Post) Edit(c *fiber.Ctx) error {
site := GetSite(c)
postID, err := c.ParamsInt("postId")
@ -81,19 +69,11 @@ func (h *Post) Edit() fiber.Handler {
}
return c.Render("posts/new", fiber.Map{
"site": site,
"post": post,
}, "layouts/site")
}
}
func (h *Post) Update() fiber.Handler {
type Req struct {
Title string `json:"title" form:"title"`
Body string `json:"body" form:"body"`
}
return func(c *fiber.Ctx) error {
func (h *Post) Update(c *fiber.Ctx) error {
site := GetSite(c)
postID, err := c.ParamsInt("postId")
@ -101,7 +81,10 @@ func (h *Post) Update() fiber.Handler {
return err
}
var req Req
var req struct {
Title string `json:"title" form:"title"`
Body string `json:"body" form:"body"`
}
if err := c.BodyParser(&req); err != nil {
return err
}
@ -121,5 +104,26 @@ func (h *Post) Update() fiber.Handler {
}
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
import (
"context"
"fmt"
"github.com/gofiber/fiber/v2"
"lmika.dev/lmika/hugo-cms/services/sites"
@ -12,19 +11,18 @@ type Site struct {
Site *sites.Service
}
func (s *Site) Create() fiber.Handler {
return func(c *fiber.Ctx) error {
site, err := s.Site.CreateSite(c.UserContext(), "New Site "+time.Now().Format("2006-01-02 15:04:05"))
func (s *Site) Create(c *fiber.Ctx) error {
user := GetUser(c)
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 {
return func(c *fiber.Ctx) error {
func (s *Site) Show(c *fiber.Ctx) error {
id, err := c.ParamsInt("siteId")
if err != nil {
return err
@ -38,17 +36,14 @@ func (s *Site) Show() fiber.Handler {
return c.Render("sites/index", fiber.Map{
"site": site,
}, "layouts/main")
}
}
func (s *Site) Rebuild() fiber.Handler {
return func(c *fiber.Ctx) error {
func (s *Site) Rebuild(c *fiber.Ctx) error {
if err := s.Site.Rebuild(c.UserContext(), GetSite(c)); err != nil {
return err
}
return c.Redirect(fmt.Sprintf("/sites/%v/posts", GetSite(c).ID))
}
}
func (s *Site) WithSite() fiber.Handler {
@ -63,7 +58,7 @@ func (s *Site) WithSite() fiber.Handler {
return err
}
c.SetUserContext(context.WithValue(c.UserContext(), siteKey, site))
c.Locals("site", site)
return c.Next()
}
}

64
main.go
View file

@ -3,7 +3,10 @@ package main
import (
"bytes"
"context"
"flag"
"fmt"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/encryptcookie"
"github.com/gofiber/fiber/v2/middleware/filesystem"
"github.com/gofiber/template/html/v2"
"github.com/yuin/goldmark"
@ -20,12 +23,23 @@ import (
"lmika.dev/lmika/hugo-cms/services/posts"
"lmika.dev/lmika/hugo-cms/services/sitebuilder"
"lmika.dev/lmika/hugo-cms/services/sites"
"lmika.dev/lmika/hugo-cms/services/users"
"lmika.dev/lmika/hugo-cms/templates"
"log"
"net/http"
)
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()
if err != nil {
log.Fatal(err)
@ -37,6 +51,19 @@ func main() {
}
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())
if err != nil {
log.Fatal(err)
@ -53,6 +80,7 @@ func main() {
siteHandlers := handlers.Site{Site: siteService}
postHandlers := handlers.Post{Post: postService}
authHandlers := handlers.AuthHandler{UserService: userService}
log.Println("Connected to database")
if err := dbp.Migrate(context.Background()); err != nil {
@ -70,33 +98,41 @@ func main() {
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{
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 {
return c.Render("index", fiber.Map{}, "layouts/main")
})
app.Post("/sites", siteHandlers.Create())
app.Get("/sites/:siteId", siteHandlers.Show())
app.Post("/sites", siteHandlers.Create)
app.Get("/sites/:siteId", siteHandlers.Show)
app.Route("/sites/:siteId", func(r fiber.Router) {
r.Use(siteHandlers.WithSite())
r.Post("/rebuild", siteHandlers.Rebuild())
r.Post("/rebuild", siteHandlers.Rebuild)
r.Get("/posts", postHandlers.Posts())
r.Get("/posts/:postId", postHandlers.Edit())
r.Get("/posts/new", postHandlers.New())
r.Post("/posts", postHandlers.Create())
r.Post("/posts/:postId", postHandlers.Update())
r.Get("/posts", postHandlers.Posts)
r.Get("/posts/new", postHandlers.New)
r.Post("/posts", postHandlers.Create)
r.Get("/posts/:postId", postHandlers.Edit)
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()
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 {
ID int64
SiteID int64
OwnerID int64
Title string
Body string
State PostState

View file

@ -2,6 +2,7 @@ package models
type Site struct {
ID int64
OwnerUserID int64
Name string
Title string
URL 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
}
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) {
res, err := db.q.ListPublishablePosts(ctx, dbq.ListPublishablePostsParams{
ID: fromID,

View file

@ -10,6 +10,7 @@ func (db *DB) InsertSite(ctx context.Context, site *models.Site) error {
id, err := db.q.NewSite(ctx, dbq.NewSiteParams{
Name: site.Name,
Title: site.Title,
OwnerUserID: site.OwnerUserID,
Url: site.URL,
Theme: site.Theme,
Props: []byte("{}"),
@ -31,6 +32,7 @@ func (db *DB) GetSite(ctx context.Context, id int64) (models.Site, error) {
ID: site.ID,
Name: site.Name,
Title: site.Title,
URL: site.Url,
Theme: site.Theme,
}, 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
}
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) {
post := models.Post{
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 {
themeMeta, ok := s.themes.Lookup(site.Theme)
if !ok {
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)
@ -96,3 +124,7 @@ func (s *Service) writePost(site models.Site, post models.Post) error {
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))
}
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{
Name: normaliseName(name),
OwnerUserID: user.ID,
Title: name,
Theme: "bear",
URL: "https://meek-meringue-060cfc.netlify.app/",
}
_, 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,
Role: models.TargetRoleProduction,
Type: models.TargetTypeNetlify,
URL: "https://meek-meringue-060cfc.netlify.app",
URL: "https://meek-meringue-060cfc.netlify.app/",
TargetRef: "e628dc6e-e6e1-45a9-847a-982adef940a8",
}); err != nil {
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
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
SELECT * FROM post WHERE id = $1 LIMIT 1;
SELECT * FROM posts WHERE id = $1 LIMIT 1;
-- name: ListPublishablePosts :many
SELECT *
FROM post
FROM posts
WHERE id > $1 AND site_id = $2 AND state = 'published' AND post_date <= $3
ORDER BY id LIMIT 100;
-- name: InsertPost :one
INSERT INTO post (
INSERT INTO posts (
site_id,
title,
body,
@ -23,7 +23,7 @@ INSERT INTO post (
RETURNING id;
-- name: UpdatePost :exec
UPDATE post SET
UPDATE posts SET
site_id = $2,
title = $3,
body = $4,
@ -32,3 +32,6 @@ UPDATE post SET
post_date = $7
-- updated_at = $7
WHERE id = $1;
-- name: DeletePost :exec
DELETE FROM posts WHERE id = $1;

View file

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

View file

@ -1,8 +1,8 @@
-- 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
INSERT INTO publish_target (
INSERT INTO publish_targets (
site_id,
role,
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'
);
CREATE TABLE site (
CREATE TABLE users (
id BIGSERIAL NOT NULL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL
);
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
props JSON NOT NULL,
FOREIGN KEY (owner_user_id) REFERENCES users (id)
);
CREATE TABLE post (
CREATE TABLE posts (
id BIGSERIAL NOT NULL PRIMARY KEY,
site_id BIGINT NOT NULL,
title TEXT,
@ -30,10 +39,10 @@ CREATE TABLE post (
post_date TIMESTAMP WITH TIME ZONE,
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,
site_id BIGINT NOT NULL,
role target_role NOT NULL,
@ -41,5 +50,5 @@ CREATE TABLE publish_target (
url 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"
//go:embed *.html
//go:embed auth/*.html
//go:embed layouts/*.html
//go:embed posts/*.html
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/main.css">
<title>Hugo CRM</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
{{embed}}

View file

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

View file

@ -3,18 +3,18 @@
</div>
{{range .posts}}
<div class="post">
{{if .Title}}
<h3>{{.Title}}</h3>
{{end}}
<div class="post">
{{.Body | markdown}}
<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>
<hr>
{{else}}
<p>No posts yet</p>
{{end}}

View file

@ -1,5 +1,5 @@
{{- $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 -}}
{{- end -}}
<form method="post" action="{{$postTarget}}" class="post-form">