diff --git a/.air.toml b/.air.toml index b155769..850b4a9 100644 --- a/.air.toml +++ b/.air.toml @@ -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 diff --git a/Makefile b/Makefile index ced17bd..7e99026 100644 --- a/Makefile +++ b/Makefile @@ -10,4 +10,9 @@ prep: .Phony: compile compile: prep - go build -o ./build/hugo-cms \ No newline at end of file + go build -o ./build/hugo-cms + +.Phony: init-db +init-db: + export $(cat .env | xargs) + ./build/hugo-cms -user test@example.com -password test123 \ No newline at end of file diff --git a/assets/css/main.css b/assets/css/main.css index 143bbbe..8fd49e2 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -40,6 +40,11 @@ body.role-site main { } +div.post { + border-bottom: solid thin grey; +} + + form.post-form { display: flex; diff --git a/config/config.go b/config/config.go index 1eecf8c..e7bd671 100644 --- a/config/config.go +++ b/config/config.go @@ -6,11 +6,13 @@ import ( ) type Config struct { - DatabaseURL string `env:"DATABASE_URL"` - NetlifyAuthToken string `env:"NETLIFY_AUTH_TOKEN"` - DataDir string `env:"DATA_DIR"` - DataStagingDir string `env:"DATA_STAGING_DIR,default=staging"` - DataScratchDir string `env:"DATA_SCRATCH_DIR,default=scratch"` + 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"` } func Load() (cfg Config, err error) { diff --git a/gen/sqlc/dbq/models.go b/gen/sqlc/dbq/models.go index b723142..b2b3d00 100644 --- a/gen/sqlc/dbq/models.go +++ b/gen/sqlc/dbq/models.go @@ -156,10 +156,17 @@ type PublishTarget struct { } type Site struct { - ID int64 - Name string - Title string - Url string - Theme string - Props []byte + ID int64 + OwnerUserID int64 + Name string + Title string + Url string + Theme string + Props []byte +} + +type User struct { + ID int64 + Email string + Password string } diff --git a/gen/sqlc/dbq/posts.sql.go b/gen/sqlc/dbq/posts.sql.go index de5a4b3..6724a64 100644 --- a/gen/sqlc/dbq/posts.sql.go +++ b/gen/sqlc/dbq/posts.sql.go @@ -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, diff --git a/gen/sqlc/dbq/sites.sql.go b/gen/sqlc/dbq/sites.sql.go index 3aea488..660c72c 100644 --- a/gen/sqlc/dbq/sites.sql.go +++ b/gen/sqlc/dbq/sites.sql.go @@ -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,27 +48,30 @@ 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 - Title string - Url string - Theme string - Props []byte + Name string + OwnerUserID int64 + Title string + Url string + Theme string + Props []byte } 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, diff --git a/gen/sqlc/dbq/targets.sql.go b/gen/sqlc/dbq/targets.sql.go index 12712bd..020d793 100644 --- a/gen/sqlc/dbq/targets.sql.go +++ b/gen/sqlc/dbq/targets.sql.go @@ -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) { diff --git a/gen/sqlc/dbq/users.sql.go b/gen/sqlc/dbq/users.sql.go new file mode 100644 index 0000000..c48133a --- /dev/null +++ b/gen/sqlc/dbq/users.sql.go @@ -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 +} diff --git a/go.mod b/go.mod index 3eaf20e..f64997c 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index b5d4c9b..9d4e412 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/handlers/auth.go b/handlers/auth.go new file mode 100644 index 0000000..64da6c1 --- /dev/null +++ b/handlers/auth.go @@ -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) +} diff --git a/handlers/ctx.go b/handlers/ctx.go index 4846d87..86c269a 100644 --- a/handlers/ctx.go +++ b/handlers/ctx.go @@ -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 } diff --git a/handlers/mimeselectors.go b/handlers/mimeselectors.go new file mode 100644 index 0000000..6a47e8b --- /dev/null +++ b/handlers/mimeselectors.go @@ -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") +} diff --git a/handlers/post.go b/handlers/post.go index 62964db..cf5bff5 100644 --- a/handlers/post.go +++ b/handlers/post.go @@ -5,121 +5,125 @@ 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 { - site := GetSite(c) +func (h *Post) Posts(c *fiber.Ctx) error { + site := GetSite(c) - posts, err := h.Post.ListPostOfSite(c.UserContext(), site) - if err != nil { - return err - } - - return c.Render("posts/index", fiber.Map{ - "site": site, - "posts": posts, - }, "layouts/site") + posts, err := h.Post.ListPostOfSite(c.UserContext(), site) + if err != nil { + return err } + + return c.Render("posts/index", fiber.Map{ + "posts": posts, + }, "layouts/site") } -func (h *Post) New() fiber.Handler { - return func(c *fiber.Ctx) error { - site := GetSite(c) - - return c.Render("posts/new", fiber.Map{ - "site": site, - "post": models.Post{}, - }, "layouts/site") - } +func (h *Post) New(c *fiber.Ctx) error { + return c.Render("posts/new", fiber.Map{ + "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 - } - - _, 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)) + 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)) } -func (h *Post) Edit() fiber.Handler { - return func(c *fiber.Ctx) error { - site := GetSite(c) +func (h *Post) Edit(c *fiber.Ctx) error { + site := GetSite(c) - postID, err := c.ParamsInt("postId") - if 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) - } - - return c.Render("posts/new", fiber.Map{ - "site": site, - "post": post, - }, "layouts/site") + postID, err := c.ParamsInt("postId") + if 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) + } + + return c.Render("posts/new", fiber.Map{ + "post": post, + }, "layouts/site") } -func (h *Post) Update() fiber.Handler { - type Req struct { +func (h *Post) Update(c *fiber.Ctx) error { + site := GetSite(c) + + postID, err := c.ParamsInt("postId") + if err != nil { + return err + } + + var req struct { Title string `json:"title" form:"title"` Body string `json:"body" form:"body"` } - - return func(c *fiber.Ctx) error { - 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)) + 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)) +} + +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) + }), + ) } diff --git a/handlers/site.go b/handlers/site.go index 8a6c701..94496bc 100644 --- a/handlers/site.go +++ b/handlers/site.go @@ -1,7 +1,6 @@ package handlers import ( - "context" "fmt" "github.com/gofiber/fiber/v2" "lmika.dev/lmika/hugo-cms/services/sites" @@ -12,43 +11,39 @@ 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")) - if err != nil { - return err - } +func (s *Site) Create(c *fiber.Ctx) error { + user := GetUser(c) - 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 { - return func(c *fiber.Ctx) error { - id, err := c.ParamsInt("siteId") - if err != nil { - 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") +func (s *Site) Show(c *fiber.Ctx) error { + id, err := c.ParamsInt("siteId") + if err != nil { + 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") } -func (s *Site) Rebuild() fiber.Handler { - return func(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) 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() } } diff --git a/main.go b/main.go index bde6e26..15bf513 100644 --- a/main.go +++ b/main.go @@ -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, + 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() diff --git a/models/cookie.go b/models/cookie.go new file mode 100644 index 0000000..156f96b --- /dev/null +++ b/models/cookie.go @@ -0,0 +1,9 @@ +package models + +const ( + AuthCookieName = "hugocrm_auth" +) + +type AuthCookie struct { + UserID int64 `json:"uid"` +} diff --git a/models/posts.go b/models/posts.go index 104625f..a95fb36 100644 --- a/models/posts.go +++ b/models/posts.go @@ -12,6 +12,7 @@ const ( type Post struct { ID int64 SiteID int64 + OwnerID int64 Title string Body string State PostState diff --git a/models/sites.go b/models/sites.go index 9685c60..7a45321 100644 --- a/models/sites.go +++ b/models/sites.go @@ -1,9 +1,10 @@ package models type Site struct { - ID int64 - Name string - Title string - URL string - Theme string + ID int64 + OwnerUserID int64 + Name string + Title string + URL string + Theme string } diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..7d50a4f --- /dev/null +++ b/models/user.go @@ -0,0 +1,7 @@ +package models + +type User struct { + ID int64 + Email string + PasswordHash string +} diff --git a/providers/db/posts.go b/providers/db/posts.go index e838c3a..a647453 100644 --- a/providers/db/posts.go +++ b/providers/db/posts.go @@ -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, diff --git a/providers/db/sites.go b/providers/db/sites.go index 8e8cc55..9733150 100644 --- a/providers/db/sites.go +++ b/providers/db/sites.go @@ -8,11 +8,12 @@ import ( 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, - Theme: site.Theme, - Props: []byte("{}"), + Name: site.Name, + Title: site.Title, + OwnerUserID: site.OwnerUserID, + Url: site.URL, + Theme: site.Theme, + Props: []byte("{}"), }) if err != nil { return err @@ -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 } diff --git a/providers/db/user.go b/providers/db/user.go new file mode 100644 index 0000000..1962ad3 --- /dev/null +++ b/providers/db/user.go @@ -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, + } +} diff --git a/services/posts/services.go b/services/posts/services.go index ce3cd5a..902c14c 100644 --- a/services/posts/services.go +++ b/services/posts/services.go @@ -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, diff --git a/services/sitebuilder/posts.go b/services/sitebuilder/posts.go index 9dd7829..1a7a135 100644 --- a/services/sitebuilder/posts.go +++ b/services/sitebuilder/posts.go @@ -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")) +} diff --git a/services/sites/service.go b/services/sites/service.go index 346f749..71b549c 100644 --- a/services/sites/service.go +++ b/services/sites/service.go @@ -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), - Title: name, - Theme: "bear", + 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 diff --git a/services/users/service.go b/services/users/service.go new file mode 100644 index 0000000..1fcd925 --- /dev/null +++ b/services/users/service.go @@ -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"` +} diff --git a/sql/queries/posts.sql b/sql/queries/posts.sql index 34130ad..1ddf3cf 100644 --- a/sql/queries/posts.sql +++ b/sql/queries/posts.sql @@ -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, @@ -31,4 +31,7 @@ UPDATE post SET props = $6, post_date = $7 -- updated_at = $7 -WHERE id = $1; \ No newline at end of file +WHERE id = $1; + +-- name: DeletePost :exec +DELETE FROM posts WHERE id = $1; \ No newline at end of file diff --git a/sql/queries/sites.sql b/sql/queries/sites.sql index d03f924..ce8a7fe 100644 --- a/sql/queries/sites.sql +++ b/sql/queries/sites.sql @@ -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; \ No newline at end of file diff --git a/sql/queries/targets.sql b/sql/queries/targets.sql index 55be1d6..ba75c7e 100644 --- a/sql/queries/targets.sql +++ b/sql/queries/targets.sql @@ -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, diff --git a/sql/queries/users.sql b/sql/queries/users.sql new file mode 100644 index 0000000..cb01e46 --- /dev/null +++ b/sql/queries/users.sql @@ -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; \ No newline at end of file diff --git a/sql/schema/1_init.up.sql b/sql/schema/1_init.up.sql index 15b6253..b4f0c42 100644 --- a/sql/schema/1_init.up.sql +++ b/sql/schema/1_init.up.sql @@ -11,16 +11,25 @@ CREATE TYPE target_type AS ENUM ( 'netlify' ); -CREATE TABLE site ( - 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 +CREATE TABLE users ( + id BIGSERIAL NOT NULL PRIMARY KEY, + 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, + 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, 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 ); \ No newline at end of file diff --git a/templates/auth/login.html b/templates/auth/login.html new file mode 100644 index 0000000..1cd4f0a --- /dev/null +++ b/templates/auth/login.html @@ -0,0 +1,11 @@ +
\ No newline at end of file diff --git a/templates/fs.go b/templates/fs.go index 64b74d4..aedde93 100644 --- a/templates/fs.go +++ b/templates/fs.go @@ -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 diff --git a/templates/layouts/login.html b/templates/layouts/login.html new file mode 100644 index 0000000..822b957 --- /dev/null +++ b/templates/layouts/login.html @@ -0,0 +1,13 @@ + + + + + + + +No posts yet
{{end}} \ No newline at end of file diff --git a/templates/posts/new.html b/templates/posts/new.html index 72769fa..350d71e 100644 --- a/templates/posts/new.html +++ b/templates/posts/new.html @@ -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 -}}