Compare commits

...

4 commits

Author SHA1 Message Date
Leon Mika 5f63da0fe8 Set DATA_DIR in the Dockerfile 2025-02-01 11:19:37 +11:00
Leon Mika 1b67fe284d Added Dockerfile 2025-02-01 11:04:32 +11:00
Leon Mika 50f7e9632e Fixed site ownership 2025-02-01 10:56:59 +11:00
Leon Mika cb54057305 Added user authentication 2025-02-01 09:42:32 +11:00
45 changed files with 914 additions and 284 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
@ -14,7 +12,7 @@ exclude_file = []
exclude_regex = ["_test.go", "build/.*"]
exclude_unchanged = false
follow_symlink = false
full_bin = "export $(cat .env | xargs) ; cd build ; ./hugo-cms"
full_bin = "export $(cat .env | xargs) ; make prep-dev init-db ; cd build ; ./hugo-cms"
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html", "gohtml", "css", "js"]
include_file = []

22
Dockerfile Normal file
View file

@ -0,0 +1,22 @@
FROM golang:1.23.3 AS builder
WORKDIR /usr/src/app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
RUN make compile
FROM scratch
COPY --from=builder /usr/src/app/build/ /.
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
WORKDIR /
ENV DATA_DIR=/data
ENV PORT=3000
CMD ["/hugo-cms"]

View file

@ -1,13 +1,20 @@
.Phony: clean
clean:
-docker-compose down -v
-rm -r build
-rm -rf build
.Phony: prep
prep:
-docker-compose up -d
mkdir -p build
.Phony: compile
compile: prep
go build -o ./build/hugo-cms
CGO_ENABLED=0 go build -o ./build/hugo-cms
.Phony: prep-dev
prep-dev:
docker-compose up -d
.Phony: init-db
init-db:
go run . -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,15 @@ 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, theme, props FROM sites WHERE id = $1 LIMIT 1
`
func (q *Queries) GetSiteWithID(ctx context.Context, id int64) (Site, error) {
@ -18,9 +18,9 @@ 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,
&i.Theme,
&i.Props,
)
@ -28,7 +28,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, theme, props FROM sites
`
func (q *Queries) ListSites(ctx context.Context) (Site, error) {
@ -36,9 +36,9 @@ 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,
&i.Theme,
&i.Props,
)
@ -46,10 +46,10 @@ 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)
@ -58,8 +58,8 @@ RETURNING id
type NewSiteParams struct {
Name string
OwnerUserID int64
Title string
Url string
Theme string
Props []byte
}
@ -67,8 +67,8 @@ 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,
arg.Props,
)

View file

@ -9,8 +9,31 @@ import (
"context"
)
const getTargetOfSiteRole = `-- name: GetTargetOfSiteRole :one
SELECT id, site_id, role, target_type, url, target_ref FROM publish_targets WHERE site_id = $1 AND role = $2 LIMIT 1
`
type GetTargetOfSiteRoleParams struct {
SiteID int64
Role TargetRole
}
func (q *Queries) GetTargetOfSiteRole(ctx context.Context, arg GetTargetOfSiteRoleParams) (PublishTarget, error) {
row := q.db.QueryRow(ctx, getTargetOfSiteRole, arg.SiteID, arg.Role)
var i PublishTarget
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Role,
&i.TargetType,
&i.Url,
&i.TargetRef,
)
return i, err
}
const insertPublishTarget = `-- name: InsertPublishTarget :one
INSERT INTO publish_target (
INSERT INTO publish_targets (
site_id,
role,
target_type,
@ -41,12 +64,12 @@ func (q *Queries) InsertPublishTarget(ctx context.Context, arg InsertPublishTarg
return id, err
}
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'
const listTargetsOfSite = `-- name: ListTargetsOfSite :many
SELECT id, site_id, role, target_type, url, target_ref FROM publish_targets WHERE site_id = $1
`
func (q *Queries) ListPublishTargetsOfRole(ctx context.Context, siteID int64) ([]PublishTarget, error) {
rows, err := q.db.Query(ctx, listPublishTargetsOfRole, siteID)
func (q *Queries) ListTargetsOfSite(ctx context.Context, siteID int64) ([]PublishTarget, error) {
rows, err := q.db.Query(ctx, listTargetsOfSite, siteID)
if err != nil {
return nil, err
}

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,57 @@
package handlers
import (
"encoding/json"
"errors"
"github.com/gofiber/fiber/v2"
"lmika.dev/lmika/hugo-cms/models"
"log"
)
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
}
func UpdatePrefCookie(c *fiber.Ctx, update func(prefs *models.PrefCookie)) {
cookie := GetPrefCookie(c)
update(&cookie)
setPrefCookie(c, cookie)
}
func GetPrefCookie(c *fiber.Ctx) models.PrefCookie {
prefCookieValue := c.Cookies(models.PrefCookieName)
if prefCookieValue == "" {
return models.PrefCookie{}
}
var prefCookie models.PrefCookie
err := json.Unmarshal([]byte(prefCookieValue), &prefCookie)
if err != nil {
return models.PrefCookie{}
}
return prefCookie
}
func setPrefCookie(c *fiber.Ctx, prefCookie models.PrefCookie) {
if prefJson, err := json.Marshal(prefCookie); err == nil {
c.Cookie(&fiber.Cookie{
Name: models.PrefCookieName,
Value: string(prefJson),
})
} else {
log.Printf("unable to save pref cookie: %v", err)
}
}

19
handlers/index.go Normal file
View file

@ -0,0 +1,19 @@
package handlers
import (
"fmt"
"github.com/gofiber/fiber/v2"
"net/http"
)
type IndexHandler struct {
}
func (h IndexHandler) Index(c *fiber.Ctx) error {
prefs := GetPrefCookie(c)
if prefs.SiteID > 0 {
return c.Redirect(fmt.Sprintf("/sites/%v/posts", prefs.SiteID), http.StatusFound)
}
return c.Render("index", fiber.Map{}, "layouts/main")
}

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,10 +1,13 @@
package handlers
import (
"context"
"errors"
"fmt"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"lmika.dev/lmika/hugo-cms/models"
"lmika.dev/lmika/hugo-cms/services/sites"
"net/http"
"time"
)
@ -12,19 +15,22 @@ 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
}
UpdatePrefCookie(c, func(prefs *models.PrefCookie) {
prefs.SiteID = site.ID
})
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,21 +44,18 @@ 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 {
return func(c *fiber.Ctx) error {
return func(c *fiber.Ctx) (err error) {
id, err := c.ParamsInt("siteId")
if err != nil {
return err
@ -63,7 +66,19 @@ func (s *Site) WithSite() fiber.Handler {
return err
}
c.SetUserContext(context.WithValue(c.UserContext(), siteKey, site))
user := GetUser(c)
if site.OwnerUserID != user.ID {
return c.Status(http.StatusForbidden).SendString("not permitted")
}
c.Locals("site", site)
if prodTarget, err := s.Site.GetProdTargetOfSite(c.UserContext(), int(site.ID)); err == nil {
c.Locals("prodTarget", prodTarget)
} else if !errors.Is(err, pgx.ErrNoRows) {
return err
}
return c.Next()
}
}

80
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,25 @@ func main() {
}
defer dbp.Close()
log.Println("Connected to database")
if err := dbp.Migrate(context.Background()); err != nil {
log.Fatal(err)
}
log.Println("Database migrated")
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)
@ -51,15 +84,10 @@ func main() {
siteService := sites.NewService(cfg, dbp, themesProvider, siteBuilderService, jobService)
postService := posts.New(dbp, siteBuilderService, jobService)
indexHandlers := handlers.IndexHandler{}
siteHandlers := handlers.Site{Site: siteService}
postHandlers := handlers.Post{Post: postService}
log.Println("Connected to database")
if err := dbp.Migrate(context.Background()); err != nil {
log.Fatal(err)
}
log.Println("Database migrated")
authHandlers := handlers.AuthHandler{UserService: userService}
tmplEngine := html.NewFileSystem(http.FS(templates.FS), ".html")
tmplEngine.Funcmap["markdown"] = func(s string) (template.HTML, error) {
@ -70,33 +98,39 @@ 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.Get("/", func(c *fiber.Ctx) error {
return c.Render("index", fiber.Map{}, "layouts/main")
})
app.Use("/assets", filesystem.New(filesystem.Config{Root: http.FS(assets.FS)}))
app.Post("/sites", siteHandlers.Create())
app.Get("/sites/:siteId", siteHandlers.Show())
app.Get("/auth/login", authHandlers.ShowLogin)
app.Post("/auth/login", authHandlers.Login)
app.Use(authHandlers.RequireAuth)
app.Get("/", indexHandlers.Index)
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()

14
models/cookie.go Normal file
View file

@ -0,0 +1,14 @@
package models
const (
AuthCookieName = "hugocrm_auth"
PrefCookieName = "hugocrm_pref"
)
type AuthCookie struct {
UserID int64 `json:"uid"`
}
type PrefCookie struct {
SiteID int64 `json:"siteId"`
}

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,8 +2,8 @@ package models
type Site struct {
ID int64
OwnerUserID int64
Name string
Title 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
}
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

@ -1,42 +0,0 @@
package db
import (
"context"
"lmika.dev/lmika/hugo-cms/gen/sqlc/dbq"
"lmika.dev/lmika/hugo-cms/models"
"lmika.dev/pkg/modash/moslice"
)
func (db *DB) InsertPublishTarget(ctx context.Context, target *models.PublishTarget) error {
id, err := db.q.InsertPublishTarget(ctx, dbq.InsertPublishTargetParams{
SiteID: target.SiteID,
Role: dbq.TargetRole(target.Role),
TargetType: dbq.TargetType(target.Type),
Url: target.URL,
TargetRef: target.TargetRef,
})
if err != nil {
return err
}
target.ID = id
return nil
}
func (db *DB) GetPublishTargets(ctx context.Context, siteID int64) ([]models.PublishTarget, error) {
res, err := db.q.ListPublishTargetsOfRole(ctx, siteID)
if err != nil {
return nil, err
}
return moslice.Map(res, func(m dbq.PublishTarget) models.PublishTarget {
return models.PublishTarget{
ID: m.ID,
SiteID: m.SiteID,
Role: models.TargetRole(m.Role),
Type: models.TargetType(m.TargetType),
URL: m.Url,
TargetRef: m.TargetRef,
}
}), nil
}

View file

@ -10,7 +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,
Url: site.URL,
OwnerUserID: site.OwnerUserID,
Theme: site.Theme,
Props: []byte("{}"),
})
@ -29,6 +29,7 @@ func (db *DB) GetSite(ctx context.Context, id int64) (models.Site, error) {
return models.Site{
ID: site.ID,
OwnerUserID: site.OwnerUserID,
Name: site.Name,
Title: site.Title,
Theme: site.Theme,

56
providers/db/targets.go Normal file
View file

@ -0,0 +1,56 @@
package db
import (
"context"
"lmika.dev/lmika/hugo-cms/gen/sqlc/dbq"
"lmika.dev/lmika/hugo-cms/models"
"lmika.dev/pkg/modash/moslice"
)
func (db *DB) InsertPublishTarget(ctx context.Context, target *models.PublishTarget) error {
id, err := db.q.InsertPublishTarget(ctx, dbq.InsertPublishTargetParams{
SiteID: target.SiteID,
Role: dbq.TargetRole(target.Role),
TargetType: dbq.TargetType(target.Type),
Url: target.URL,
TargetRef: target.TargetRef,
})
if err != nil {
return err
}
target.ID = id
return nil
}
func (db *DB) GetPublishTargets(ctx context.Context, siteID int64) ([]models.PublishTarget, error) {
res, err := db.q.ListTargetsOfSite(ctx, siteID)
if err != nil {
return nil, err
}
return moslice.Map(res, dbTargetToTarget), nil
}
func (db *DB) GetPublishTargetBySiteRole(ctx context.Context, siteID int64, role models.TargetRole) (models.PublishTarget, error) {
target, err := db.q.GetTargetOfSiteRole(ctx, dbq.GetTargetOfSiteRoleParams{
SiteID: siteID,
Role: dbq.TargetRole(role),
})
if err != nil {
return models.PublishTarget{}, err
}
return dbTargetToTarget(target), nil
}
func dbTargetToTarget(m dbq.PublishTarget) models.PublishTarget {
return models.PublishTarget{
ID: m.ID,
SiteID: m.SiteID,
Role: models.TargetRole(m.Role),
Type: models.TargetType(m.TargetType),
URL: m.Url,
TargetRef: m.TargetRef,
}
}

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,9 +41,14 @@ 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) GetProdTargetOfSite(ctx context.Context, siteID int) (models.PublishTarget, error) {
return s.db.GetPublishTargetBySiteRole(ctx, int64(siteID), models.TargetRoleProduction)
}
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",
}
@ -62,7 +67,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,14 +1,14 @@
-- 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)

View file

@ -1,8 +1,11 @@
-- name: ListPublishTargetsOfRole :many
SELECT * FROM publish_target WHERE site_id = $1 AND role = 'production';
-- name: ListTargetsOfSite :many
SELECT * FROM publish_targets WHERE site_id = $1;
-- name: GetTargetOfSiteRole :one
SELECT * FROM publish_targets WHERE site_id = $1 AND role = $2 LIMIT 1;
-- 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,24 @@ CREATE TYPE target_type AS ENUM (
'netlify'
);
CREATE TABLE site (
CREATE TABLE users (
id BIGSERIAL NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
url TEXT NOT NULL,
theme TEXT NOT NULL,
props JSON NOT NULL
email TEXT NOT NULL UNIQUE,
password TEXT 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,
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,
site_id BIGINT NOT NULL,
title TEXT,
@ -30,10 +38,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 +49,6 @@ 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,
UNIQUE (site_id, role)
);

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

@ -1,5 +1,7 @@
<h1>Thing</h1>
User = {{.user.Email}}
<form method="post" action="/sites">
<input type="submit" value="Create Site">
</form>

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,16 @@
<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>
{{ if .prodTarget }}
<a href="{{.prodTarget.URL}}" target="_blank">Visit</a>
{{ end }}
<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">