{ errMsg }
8 |9 | 10 | ← Go Back 11 | 12 |
13 |├── doc └── cms-logo.png ├── .gitignore ├── assets ├── img │ ├── cms-icon.png │ └── cms-logo.png ├── css │ ├── transition.styles.css │ └── simple.min.css └── js │ └── htmx.min.js ├── internal ├── app │ └── api │ │ ├── dto │ │ └── post.binding.go │ │ ├── contact.handlers.go │ │ ├── error.middleware.handler.go │ │ ├── cache.go │ │ ├── posts.handlers.go │ │ └── api.go ├── admin_app │ └── api │ │ ├── dto │ │ ├── create.image.binding.go │ │ ├── post.binding.go │ │ ├── image.binding.go │ │ ├── update.post.binding.go │ │ └── create.post.binding.go │ │ ├── error.middleware.handler.go │ │ ├── posts.handlers.go │ │ ├── api .go │ │ └── images.handlers.go ├── model │ ├── image.model.go │ ├── post-summary.model.go │ └── post.model.go ├── entity │ ├── post.entity.go │ └── image.entity.go ├── repository │ ├── repository.go │ ├── image.repository.go │ └── post.repository.go └── service │ ├── service.go │ ├── image.service.go │ └── post.service.go ├── views ├── contact-failure.templ ├── contact-success.templ ├── error-page.templ ├── contact.templ ├── post.templ ├── home.templ └── layout.templ ├── .dev.env ├── timezone_conversion └── timezone_conversion.go ├── plugins ├── table_shortcode.lua └── image_shortcode.lua ├── migrations ├── 20240216130903_add_sample_post.sql ├── 20240216130850_create_table_images.sql ├── 20240216130800_create_table_posts.sql ├── 20240216172623_add_sample_post.sql ├── 20240218113309_add_complex_post_example.sql ├── 20240312064935_add_post_with_images.sql └── 20240219101315_add_readme_github_markdown_example.sql ├── mariadb_init └── schema.sql ├── settings ├── gocms_config.toml └── settings.go ├── docker ├── gocms_config.toml ├── .air.toml ├── mariadb.yml ├── Dockerfile ├── start-app.sh └── docker-compose.yml ├── .air.toml ├── .vscode └── launch.json ├── LICENSE ├── tests └── system_tests │ ├── app │ └── endpoint_tests │ │ ├── post_test.go │ │ └── home_test.go │ ├── admin_app │ └── endpoint_tests │ │ └── post_test.go │ └── helpers │ └── helpers.go ├── database └── database.go ├── benchmark.yml ├── cmd ├── gocms_admin │ └── main.go └── gocms │ └── main.go ├── Makefile ├── go.mod └── README.md /doc/cms-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emarifer/goCMS/HEAD/doc/cms-logo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp 2 | bin 3 | media 4 | tests/system_tests/helpers/migrations 5 | 6 | **/*_templ.go -------------------------------------------------------------------------------- /assets/img/cms-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emarifer/goCMS/HEAD/assets/img/cms-icon.png -------------------------------------------------------------------------------- /assets/img/cms-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emarifer/goCMS/HEAD/assets/img/cms-logo.png -------------------------------------------------------------------------------- /internal/app/api/dto/post.binding.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type PostBinding struct { 4 | Id string `uri:"id" binding:"required"` 5 | } 6 | -------------------------------------------------------------------------------- /internal/admin_app/api/dto/create.image.binding.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type AddImageRequest struct { 4 | Alt string `json:"alt"` 5 | } 6 | -------------------------------------------------------------------------------- /internal/admin_app/api/dto/post.binding.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type PostBinding struct { 4 | Id string `uri:"id" binding:"required"` 5 | } 6 | -------------------------------------------------------------------------------- /internal/admin_app/api/dto/image.binding.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type ImageBinding struct { 4 | UUID string `uri:"uuid" binding:"required"` 5 | } 6 | -------------------------------------------------------------------------------- /internal/model/image.model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Image struct { 4 | UUID string `json:"uuid"` 5 | Name string `json:"name"` 6 | Alt string `json:"alt"` 7 | } 8 | -------------------------------------------------------------------------------- /internal/model/post-summary.model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type PostSummary struct { 4 | ID int `json:"id"` 5 | Title string `json:"title"` 6 | Excerpt string `json:"excerpt"` 7 | } 8 | -------------------------------------------------------------------------------- /views/contact-failure.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | templ ContactFailure(email, err string) { 4 |
Your message could not be sent. Error: { err }
6 | } 7 | -------------------------------------------------------------------------------- /views/contact-success.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | templ ContactSuccess(email, name string) { 4 |Thank you { name }. The message was successfully sent.
6 | } 7 | -------------------------------------------------------------------------------- /.dev.env: -------------------------------------------------------------------------------- 1 | # Environment variables are used only for the `goose up` command, they are not needed for docker files 2 | MARIADB_ADDRESS=localhost 3 | MARIADB_PORT=3306 4 | MARIADB_USER=root 5 | MARIADB_ROOT_PASSWORD=my-secret-pw 6 | MARIADB_DATABASE=cms_db -------------------------------------------------------------------------------- /internal/admin_app/api/dto/update.post.binding.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type UpdatePostRequest struct { 4 | ID int `json:"id"` 5 | Title string `json:"title"` 6 | Excerpt string `json:"excerpt"` 7 | Content string `json:"content"` 8 | } 9 | -------------------------------------------------------------------------------- /timezone_conversion/timezone_conversion.go: -------------------------------------------------------------------------------- 1 | package timezone_conversion 2 | 3 | import "time" 4 | 5 | func ConvertDateTime(tz string, dt time.Time) string { 6 | loc, _ := time.LoadLocation(tz) 7 | 8 | return dt.In(loc).Format(time.RFC822Z) 9 | } 10 | -------------------------------------------------------------------------------- /internal/admin_app/api/dto/create.post.binding.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type AddPostRequest struct { 4 | Title string `json:"title" validate:"required"` 5 | Excerpt string `json:"excerpt" validate:"required"` 6 | Content string `json:"content" validate:"required"` 7 | } 8 | -------------------------------------------------------------------------------- /internal/entity/post.entity.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import "time" 4 | 5 | type Post struct { 6 | ID int `db:"id"` 7 | Title string `db:"title"` 8 | Content string `db:"content"` 9 | Excerpt string `db:"excerpt"` 10 | CreatedAt time.Time `db:"created_at"` 11 | } 12 | -------------------------------------------------------------------------------- /internal/model/post.model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | type Post struct { 6 | ID int `json:"id"` 7 | Title string `json:"title"` 8 | Content string `json:"content"` 9 | Excerpt string `json:"excerpt"` 10 | CreatedAt time.Time `json:"created_at"` 11 | } 12 | -------------------------------------------------------------------------------- /internal/entity/image.entity.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | type Image struct { 10 | UUID uuid.UUID `db:"uuid"` 11 | Name string `db:"name"` 12 | Alt string `db:"alt"` 13 | CreatedAt time.Time `db:"created_at"` 14 | } 15 | -------------------------------------------------------------------------------- /plugins/table_shortcode.lua: -------------------------------------------------------------------------------- 1 | function HandleShortcode(arguments) 2 | if #arguments ~= 2 then 3 | return "" 4 | end 5 | 6 | return 7 | "\n| Tables | Are | Cool |\n|----------|:-------------:|------:|\n| col 1 is | left-aligned | $1600 |\n| col 2 is | centered | $12 |\n| col 3 is | right-aligned | $1 |\n" 8 | end 9 | -------------------------------------------------------------------------------- /migrations/20240216130903_add_sample_post.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | INSERT INTO posts(title, content, excerpt) VALUES( 4 | 'My Very First Post', 5 | 'This is the content', 6 | 'Excerpt01' 7 | ); 8 | -- +goose StatementEnd 9 | 10 | -- +goose Down 11 | -- +goose StatementBegin 12 | DELETE FROM posts ORDER BY id DESC LIMIT 1; 13 | -- +goose StatementEnd 14 | -------------------------------------------------------------------------------- /plugins/image_shortcode.lua: -------------------------------------------------------------------------------- 1 | function HandleShortcode(args) 2 | if #args == 1 then 3 | local imageSrc = string.format("/media/%s", args[1]) 4 | 5 | return string.format("", imageSrc) 6 | elseif #args == 2 then 7 | local imageSrc = string.format("/media/%s", args[1]) 8 | 9 | return string.format("", args[2], imageSrc) 10 | else 11 | return "" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /migrations/20240216130850_create_table_images.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE IF NOT EXISTS images ( 4 | uuid VARCHAR(36) DEFAULT(UUID()) PRIMARY KEY, 5 | name TEXT NOT NULL, 6 | alt TEXT NOT NULL, 7 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 8 | ); 9 | -- +goose StatementEnd 10 | 11 | -- +goose Down 12 | -- +goose StatementBegin 13 | DROP TABLE IF EXISTS images; 14 | -- +goose StatementEnd 15 | -------------------------------------------------------------------------------- /views/error-page.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | templ ErrorPage(errMsg string) { 4 |9 | 10 | ← Go Back 11 | 12 |
13 |21 | @templ.Raw(post.Content) 22 |
23 |24 | 25 | ← Go Back 26 | 27 |
28 |17 | { post.Excerpt } 18 | 24 | read more… 25 | 26 |
27 |
32 |
33 |
38 |
39 |
44 |
45 | ---
46 |
47 | ## Setup:
48 |
49 | Besides the obvious prerequisite of having Go! on your machine, you must have Air installed for hot reloading when editing code.
50 |
51 | Start the app in development mode:
52 |
53 | ```
54 | $ air # Ctrl + C to stop the application
55 | ```
56 |
57 | Build for production:
58 |
59 | ```
60 | $ go build -ldflags="-s -w" -o ./bin/main . # ./bin/main to run the application
61 | ```
62 |
63 | >***In order to have autocompletion and syntax highlighting in VS Code for the Teml templating language, you will have to install the [templ-vscode](https://marketplace.visualstudio.com/items?itemName=a-h.templ) extension (for vim/nvim install this [plugin](https://github.com/joerdav/templ.vim)). To generate the Go code corresponding to these templates you will have to download this [executable binary](https://github.com/a-h/templ/releases/tag/v0.2.476) from Github and place it in the PATH of your system. The command:***
64 |
65 | ```
66 | $ templ generate --watch
67 | ```
68 |
69 | >***Will allow us to watch the .templ files and compile them as we save. Review the documentation on Templ [installation](https://templ.guide/quick-start/installation) and [support](https://templ.guide/commands-and-tools/ide-support/) for your IDE.***
70 |
71 | ---
72 |
73 | ### Happy coding 😀!!',
74 | 'A full-stack application using Golang´s Fiber framework.'
75 | );
76 | -- +goose StatementEnd
77 |
78 | -- +goose Down
79 | -- +goose StatementBegin
80 | DELETE FROM posts ORDER BY id DESC LIMIT 1;
81 | -- +goose StatementEnd
82 |
--------------------------------------------------------------------------------
/internal/app/api/api.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "log/slog"
5 | "net/http"
6 | "os"
7 | "regexp"
8 |
9 | "github.com/a-h/templ"
10 | "github.com/emarifer/gocms/internal/service"
11 | "github.com/emarifer/gocms/settings"
12 | "github.com/gin-contrib/gzip"
13 | "github.com/gin-gonic/gin"
14 | "github.com/gomarkdown/markdown"
15 | "github.com/gomarkdown/markdown/html"
16 | "github.com/gomarkdown/markdown/parser"
17 | )
18 |
19 | type API struct {
20 | serv service.Service
21 | logger *slog.Logger
22 | settings *settings.AppSettings
23 | }
24 |
25 | func New(
26 | serv service.Service, logger *slog.Logger, settings *settings.AppSettings,
27 | ) *API {
28 |
29 | return &API{
30 | serv: serv,
31 | logger: logger,
32 | settings: settings,
33 | }
34 | }
35 |
36 | type generator = func(*gin.Context) ([]byte, *customError)
37 |
38 | var re = regexp.MustCompile(`Table|refused`)
39 |
40 | func (a *API) Start(
41 | e *gin.Engine, address string, cache *Cache,
42 | ) (*gin.Engine, error) {
43 | e.Use(gzip.Gzip(gzip.DefaultCompression)) // gzip compression middleware
44 | e.Use(a.globalErrorHandler()) // Error handler middleware
45 | e.MaxMultipartMemory = 1 // 8 MiB max. request
46 |
47 | e.Static("/assets", "./assets")
48 | e.Static("/media", a.settings.ImageDirectory)
49 | // e.LoadHTMLGlob("views/**/*") // Used for Go Html templates
50 |
51 | a.registerRoutes(e, cache)
52 |
53 | // SEE NOTE BELOW (this is hacky):
54 | if os.Getenv("GO_ENV") == "testing" {
55 |
56 | return e, nil
57 | } else {
58 |
59 | return nil, e.Run(address)
60 | }
61 | }
62 |
63 | func (a *API) registerRoutes(e *gin.Engine, cache *Cache) {
64 | // ↓ injected into the Start function ↓
65 | // logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
66 | // cache := MakeCache(4, time.Minute*1)
67 |
68 | // All cache-able endpoints
69 | a.addCachableHandler(e, "GET", "/", a.homeHandler, cache)
70 | a.addCachableHandler(e, "GET", "/post/:id", a.postHandler, cache)
71 | a.addCachableHandler(e, "GET", "/contact", a.contactHandler, cache)
72 |
73 | // Do not cache as it needs to handle new form values
74 | e.POST("/contact-send", a.contactFormHandler)
75 | }
76 |
77 | // mdToHTML converts markdown to HTML
78 | func (a *API) mdToHTML(md []byte) []byte {
79 | // create markdown parser with extensions
80 | extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
81 | p := parser.NewWithExtensions(extensions)
82 | doc := p.Parse(md)
83 |
84 | // create HTML renderer with extensions
85 | htmlFlags := html.CommonFlags | html.HrefTargetBlank
86 | opts := html.RendererOptions{Flags: htmlFlags}
87 | renderer := html.NewRenderer(opts)
88 |
89 | return markdown.Render(doc, renderer)
90 | }
91 |
92 | // renderView will render the templ component into
93 | // a gin context's Response Writer
94 | func (a *API) renderView(
95 | c *gin.Context, status int, cmp templ.Component,
96 | ) error {
97 | c.Status(status)
98 |
99 | return cmp.Render(c.Request.Context(), c.Writer)
100 | }
101 |
102 | func (a *API) addCachableHandler(
103 | e *gin.Engine, method, endpoint string, gen generator, cache *Cache,
104 | ) {
105 |
106 | handler := func(c *gin.Context) {
107 | var errCache error
108 | // if endpoint is cached, get it from cache
109 | cachedEndpoint, errCache := (*cache).Get(c.Request.RequestURI)
110 | if errCache == nil {
111 | c.Data(
112 | http.StatusOK,
113 | "text/html; charset=utf-8",
114 | cachedEndpoint.Contents,
115 | )
116 |
117 | return
118 | } else {
119 | a.logger.Info(
120 | "cache info",
121 | slog.String("could not get page from cache", errCache.Error()),
122 | )
123 | }
124 |
125 | // If the endpoint data is not recovered, the handler (gen) is called
126 | html_buffer, err := gen(c)
127 | if err != nil {
128 | c.Error(err)
129 |
130 | return
131 | }
132 |
133 | // After handler call, add to cache
134 | errCache = (*cache).Store(c.Request.RequestURI, html_buffer)
135 | if errCache != nil {
136 | a.logger.Warn(
137 | "cache warning",
138 | slog.String("could not add page to cache", errCache.Error()),
139 | )
140 | }
141 |
142 | c.Data(http.StatusOK, "text/html; charset=utf-8", html_buffer)
143 | }
144 |
145 | // Hacky
146 | switch method {
147 | case "GET":
148 | e.GET(endpoint, handler)
149 | case "POST":
150 | e.POST(endpoint, handler)
151 | case "DELETE":
152 | e.DELETE(endpoint, handler)
153 | case "PUT":
154 | e.PUT(endpoint, handler)
155 | }
156 | }
157 |
158 | /* HOW DO I KNOW I'M RUNNING WITHIN "GO TEST". SEE:
159 | https://stackoverflow.com/questions/14249217/how-do-i-know-im-running-within-go-test#59444829
160 | */
161 |
--------------------------------------------------------------------------------
/internal/repository/post.repository.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/emarifer/gocms/internal/entity"
7 | )
8 |
9 | const (
10 | qryInsertPost = `
11 | INSERT INTO posts (title, excerpt, content)
12 | VALUES (:title, :excerpt, :content);
13 | `
14 |
15 | qryGetPostById = `
16 | SELECT * FROM posts
17 | WHERE id = ?;
18 | `
19 |
20 | qryGetAllPosts = `
21 | SELECT * FROM posts
22 | ORDER BY created_at DESC;
23 | `
24 |
25 | qryUpdatePostTitle = `
26 | UPDATE posts
27 | SET title = ?
28 | WHERE id = ?;
29 | `
30 |
31 | qryUpdatePostExcerpt = `
32 | UPDATE posts
33 | SET excerpt = ?
34 | WHERE id = ?;
35 | `
36 |
37 | qryUpdatePostContent = `
38 | UPDATE posts
39 | SET content = ?
40 | WHERE id = ?;
41 | `
42 |
43 | qryDeletePost = `
44 | DELETE FROM posts
45 | WHERE id = ?;
46 | `
47 | )
48 |
49 | // SavePost inserts a record into the database
50 | // passing the title, excerpt and content
51 | func (r *repo) SavePost(ctx context.Context, post *entity.Post) (int, error) {
52 | result, err := r.db.NamedExecContext(ctx, qryInsertPost, post)
53 | if err != nil {
54 | return -1, err
55 | }
56 |
57 | lastId, err := result.LastInsertId() // SEE NOTE BELOW
58 | if err != nil {
59 | return -1, err
60 | }
61 |
62 | return int(lastId), nil
63 | }
64 |
65 | // GetPosts gets all the posts from the current
66 | // database
67 | func (r *repo) GetPosts(ctx context.Context) ([]entity.Post, error) {
68 | pp := []entity.Post{}
69 |
70 | err := r.db.SelectContext(ctx, &pp, qryGetAllPosts)
71 | if err != nil {
72 | return nil, err
73 | }
74 |
75 | return pp, nil
76 | }
77 |
78 | // GetPost gets a post from the database
79 | // with the given ID.
80 | func (r *repo) GetPost(ctx context.Context, id int) (*entity.Post, error) {
81 | p := &entity.Post{}
82 |
83 | err := r.db.GetContext(ctx, p, qryGetPostById, id)
84 | if err != nil {
85 | return nil, err
86 | }
87 |
88 | return p, nil
89 | }
90 |
91 | // UpdatePost updates a post from the database.
92 | // with the ID and data provided.
93 | func (r *repo) UpdatePost(
94 | ctx context.Context, post *entity.Post,
95 | ) (int64, error) {
96 | var row int64
97 | tx, err := r.db.Beginx()
98 | if err != nil {
99 | return -1, err
100 | }
101 | defer tx.Rollback()
102 |
103 | if len(post.Title) > 0 {
104 | result, err := tx.ExecContext(
105 | ctx, qryUpdatePostTitle, post.Title, post.ID,
106 | )
107 | if err != nil {
108 | return -1, err
109 | }
110 |
111 | if row, err = result.RowsAffected(); err != nil {
112 | return -1, err
113 | } else if row == 0 {
114 | return -1, err
115 | }
116 | }
117 |
118 | if len(post.Excerpt) > 0 {
119 | result, err := tx.ExecContext(
120 | ctx, qryUpdatePostExcerpt, post.Excerpt, post.ID,
121 | )
122 | if err != nil {
123 | return -1, err
124 | }
125 |
126 | if row, err = result.RowsAffected(); err != nil {
127 | return -1, err
128 | } else if row == 0 {
129 | return -1, err
130 | }
131 | }
132 |
133 | if len(post.Content) > 0 {
134 | result, err := tx.ExecContext(
135 | ctx, qryUpdatePostContent, post.Content, post.ID,
136 | )
137 | if err != nil {
138 | return -1, err
139 | }
140 |
141 | if row, err = result.RowsAffected(); err != nil {
142 | return -1, err
143 | } else if row == 0 {
144 | return -1, err
145 | }
146 | }
147 |
148 | if err = tx.Commit(); err != nil {
149 | return -1, err
150 | }
151 |
152 | return row, nil
153 | }
154 |
155 | // DeletePost delete a post from the database
156 | // with the given ID.
157 | func (r *repo) DeletePost(ctx context.Context, id int) (int64, error) {
158 | var row int64
159 |
160 | result, err := r.db.ExecContext(ctx, qryDeletePost, id)
161 | if err != nil {
162 | return -1, err
163 | }
164 |
165 | if row, err = result.RowsAffected(); err != nil {
166 | return -1, err
167 | } else if row == 0 {
168 | return -1, err
169 | }
170 |
171 | return row, nil
172 | }
173 |
174 | /* How to get id of last inserted row from sqlx?. SEE:
175 | https://stackoverflow.com/questions/53990799/how-to-get-id-of-last-inserted-row-from-sqlx
176 |
177 | Using database/sql you have to close the rows with:
178 |
179 | defer func() {
180 | err = errors.Join(rows.Close())
181 | }()
182 |
183 | Or simply:
184 |
185 | defer rows.Close()
186 |
187 | But with github.com/jmoiron/sqlx it is no longer necessary, it is not even necessary to open them to be able to scan them and the library itself takes care of everything. SEE:
188 | https://www.youtube.com/watch?v=R-x9j5blTzI&list=PLZ51_5WcvDvCzCB2nwm8AWodXoBbaO3Iw&index=9&t=8300s
189 |
190 | IF THIS IS NOT DONE, USING DATABASE/SQL, WILL CAUSE A DATABASE CRASH WHEN PERFORMING BENCHMARK TESTS, WHICH GENERATE MANY REQUESTS PER SECOND.
191 | */
192 |
--------------------------------------------------------------------------------
/cmd/gocms/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "flag"
6 | "fmt"
7 | "log/slog"
8 | "os"
9 | "time"
10 |
11 | "github.com/emarifer/gocms/database"
12 | "github.com/emarifer/gocms/internal/app/api"
13 | "github.com/emarifer/gocms/internal/repository"
14 | "github.com/emarifer/gocms/internal/service"
15 | "github.com/emarifer/gocms/settings"
16 | "github.com/gin-gonic/gin"
17 | "go.uber.org/fx"
18 | )
19 |
20 | func main() {
21 | gocms := fx.New(
22 | fx.Provide(
23 | context.Background,
24 | func() *os.File { return os.Stdout },
25 | func() *slog.JSONHandler {
26 | return slog.NewJSONHandler(os.Stdout, nil)
27 | },
28 | func(h *slog.JSONHandler) *slog.Logger { return slog.New(h) },
29 | func(l *slog.Logger) *string {
30 | config_toml := flag.String("config", "", "path to the config to be used")
31 | flag.Parse()
32 | l.Info("reading config file", "from path", config_toml)
33 |
34 | return config_toml
35 | },
36 | settings.New,
37 | database.NewMariaDBConnection,
38 | repository.New,
39 | service.New,
40 | gin.Default,
41 | api.New,
42 | ),
43 |
44 | fx.Invoke(
45 | setLifeCycle,
46 | ),
47 | )
48 |
49 | gocms.Run()
50 | }
51 |
52 | func setLifeCycle(
53 | lc fx.Lifecycle,
54 | a *api.API,
55 | s *settings.AppSettings,
56 | e *gin.Engine,
57 | ) {
58 | lc.Append(fx.Hook{
59 | OnStart: func(ctx context.Context) error {
60 | address := fmt.Sprintf(":%d", s.WebserverPort)
61 | cache := api.MakeCache(4, time.Minute*10)
62 | go func() {
63 | // e.Logger.Fatal(a.Start(e, address))
64 | a.Start(e, address, &cache)
65 | }()
66 |
67 | return nil
68 | },
69 | OnStop: func(ctx context.Context) error {
70 | // return e.Close()
71 | return nil
72 | },
73 | })
74 | }
75 |
76 | /* REFERENCES:
77 | https://www.youtube.com/playlist?list=PLZ51_5WcvDvDBhgBymjAGEk0SR1qmOh4q
78 | https://github.com/matheusgomes28/urchin
79 |
80 | https://github.com/golang-standards/project-layout
81 | https://www.hostinger.es/tutoriales/headless-cms
82 | https://www.bacancytechnology.com/blog/golang-web-frameworks
83 | https://github.com/gin-gonic/gin
84 |
85 | Gin vs. Echo:
86 | https://www.codeavail.com/blog/gin-vs-echo/
87 |
88 | The client-side-templates Extension for HTMX:
89 | https://htmx.org/extensions/client-side-templates/
90 |
91 | Error handler middleware:
92 | https://blog.ruangdeveloper.com/membuat-global-error-handler-go-gin/
93 |
94 | Model binding and validation:
95 | https://gin-gonic.com/docs/examples/binding-and-validation/
96 | https://gin-gonic.com/docs/examples/bind-uri/
97 | https://dev.to/ankitmalikg/api-validation-in-gin-ensuring-data-integrity-in-your-api-2p40#:~:text=To%20perform%20input%20validation%20in,query%20parameters%2C%20and%20request%20bodies.
98 | https://www.google.com/search?q=validation+function+gin&oq=validation+function+gin&aqs=chrome..69i57j33i160j33i671.11287j0j7&sourceid=chrome&ie=UTF-8
99 |
100 | Go: how to check if a string contains multiple substrings?:
101 | https://stackoverflow.com/questions/47131996/go-how-to-check-if-a-string-contains-multiple-substrings#47134680
102 |
103 | Debugging Go with VSCode and Air:
104 | https://www.arhea.net/posts/2023-08-25-golang-debugging-with-air-and-vscode/
105 |
106 | What is the purpose of .PHONY in a Makefile?:
107 | https://stackoverflow.com/questions/2145590/what-is-the-purpose-of-phony-in-a-makefile
108 |
109 | A-H.TEMPL:
110 | https://templ.guide/syntax-and-usage/template-composition/
111 | https://templ.guide/syntax-and-usage/rendering-raw-html/
112 |
113 | INJECTION OF A LOGGER WITH FX:
114 | https://uber-go.github.io/fx/container.html#providing-values
115 |
116 | USING LOG/SLOG:
117 | https://www.youtube.com/watch?v=bDpB6k-Q_GY
118 | https://github.com/disturb16/go_examples/tree/main/btrlogs
119 | https://betterstack.com/community/guides/logging/logging-in-go/
120 | https://go.dev/blog/slog
121 |
122 | SHARDEDMAP:
123 | https://pkg.go.dev/github.com/zutto/shardedmap?utm_source=godoc
124 | https://github.com/zutto/shardedmap
125 |
126 | BENCHMARK WITH/WITHOUT CACHE:
127 | https://github.com/fcsonline/drill
128 |
129 | COMMAND-LINE FLAGS:
130 | https://gobyexample.com/command-line-flags
131 |
132 | DECODING AND ENCODING OF TOML FILES:
133 | https://github.com/BurntSushi/toml
134 | https://godocs.io/github.com/BurntSushi/toml
135 |
136 | MISCELLANEOUS:
137 | https://github.com/a-h/templ/tree/1f30f822a6edfdbfbab9e6851b1ff61e0ab01d4f/examples/integration-gin
138 |
139 | https://github.com/stackus/todos
140 |
141 | https://toml.io/en/
142 |
143 | https://github.com/pelletier/go-toml
144 |
145 | [Git: See my last commit]
146 | https://stackoverflow.com/questions/2231546/git-see-my-last-commit
147 | */
148 |
149 | /* CHECKS FUNCTIONS:
150 |
151 | // settings test:
152 | func(s *settings.Settings) {
153 | fmt.Println(s.DB.Name)
154 | },
155 |
156 | // Database operation check function:
157 | func(db *sqlx.DB) {
158 | _, err := db.Query("SELECT * FROM users")
159 | if err != nil {
160 | panic(err)
161 | }
162 | },
163 | */
164 |
--------------------------------------------------------------------------------
/docker/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | gocms:
5 | image: emarifer/gocms:0.1
6 | ports:
7 | - "8080:8080"
8 | - "8081:8081"
9 | volumes:
10 | - type: bind
11 | source: ..
12 | target: /gocms
13 | volume:
14 | nocopy: true
15 | command: /gocms/docker/start-app.sh
16 | depends_on:
17 | mariadb:
18 | condition: service_healthy
19 | restart: on-failure
20 | networks:
21 | - common-net
22 |
23 | mariadb:
24 | image: mariadb:jammy
25 | container_name: mariadb
26 | volumes:
27 | - ../mariadb_init:/docker-entrypoint-initdb.d
28 | environment:
29 | MARIADB_ROOT_PASSWORD: my-secret-pw
30 | healthcheck:
31 | test: [ "CMD", "mariadb", "-uroot", "-pmy-secret-pw", "-e", "USE cms_db" ]
32 | interval: 10s
33 | timeout: 10s
34 | retries: 10
35 | networks:
36 | - common-net
37 |
38 | networks:
39 | common-net: {}
40 |
41 | # docker exec -it mariadb mariadb -uroot -pmy-secret-pw [mariadb container]
42 | # docker exec -it docker-gocms-1 sh [docker-gocms-1 container]
43 | # command & regular expression to capture the volume id/name:
44 | # docker container inspect mariadb | egrep '\"Name\": \"[0-9a-f]{64}\"'
45 | # VIEWING CONTAINER SIZE:
46 | # https://docs.docker.com/reference/cli/docker/inspect/
47 | # https://refine.dev/blog/docker-list-containers/#viewing-container-size:
48 | # docker ps --size (Only running containers)
49 | # HOW TO GET THE CREATION DATE OF A DOCKER VOLUME:
50 | # https://stackoverflow.com/questions/75150068/how-to-get-the-creation-date-of-a-docker-volume
51 | # COMMAND:
52 | # docker volume ls --quiet | xargs docker volume inspect --format '{{ .CreatedAt }} {{ .Name }}' | sort
53 | # HOW TO INSPECT VOLUMES SIZE IN DOCKER:
54 | # https://medium.com/homullus/how-to-inspect-volumes-size-in-docker-de1068d57f6b
55 | # docker system df -v | grep cd6ec (e.g.)
56 | # DETECT DUBIOUS OWNERSHIP IN REPOSITORY AT DOCKER CONTAINER:
57 | # https://docs.docker.com/compose/compose-file/compose-file-v3/#volumes
58 | # https://community.jenkins.io/t/detected-dubious-ownership-in-repository-with-jenkins-upgrade/6182
59 |
60 | # =========== OTHER REFERENCES (1) ===========
61 | # https://iesgn.github.io/curso_docker_2021/sesion3/volumenes.html
62 |
63 | # https://docs.docker.com/storage/bind-mounts/
64 |
65 | # (VOLUMES 'NOCOPY')
66 | # https://stackoverflow.com/questions/38287388/docker-and-volumes-nocopy
67 | # https://github.com/docker/docs/issues/2992#issuecomment-299596714
68 |
69 | # https://stackoverflow.com/questions/51313852/docker-compose-cannot-connect-to-database
70 |
71 | # https://docs.docker.com/compose/startup-order/
72 | # https://docs.docker.com/compose/compose-file/05-services/#depends_on
73 | # https://www.warp.dev/terminus/docker-compose-depends-on
74 | # https://docs.docker.com/compose/compose-file/05-services/#healthcheck
75 | # https://www.google.com/search?q=mariadb+healthcheck+db+created&oq=mariadb+healthcheck+db+created&aqs=chrome..69i57j33i160l2.3778j0j7&sourceid=chrome&ie=UTF-8
76 | # https://kuttler.eu/code/docker-compose-mariadb-mysql-healthcheck/
77 | # https://www.google.com/search?q=set+-euo+pipefail&oq=set+-euo+pipefail&aqs=chrome..69i57.1859395j0j7&sourceid=chrome&ie=UTF-8
78 | # https://gist.github.com/mohanpedala/1e2ff5661761d3abd0385e8223e16425?permalink_comment_id=3935570
79 |
80 | # (USE A RESTART POLICY [START CONTAINERS AUTOMATICALLY])
81 | # https://docker-docs.uclv.cu/config/containers/start-containers-automatically/#use-a-restart-policy
82 | # restart: on-failure
83 |
84 | # (HOW TO CLEAR DOCKER CACHE)
85 | # https://tech.forums.softwareag.com/t/how-to-clear-docker-cache/283214
86 | # docker system prune -a
87 |
88 | # (DOCKER VOLUME PRUNE NOT DELETING UNUSED VOLUMES)
89 | # https://stackoverflow.com/questions/75493720/docker-volume-prune-not-deleting-unused-volumes
90 | # docker volume prune --filter all=1
91 |
92 | # =========== OTHER REFERENCES (2) ===========
93 | # docker compose up -d
94 |
95 | # docker exec -it gocms-db mariadb --user root -p # or alternatively:
96 | # docker exec -it gocms-db mariadb -uroot -pmy-secret-pw
97 | # (Enter password:my-secret-pw)
98 | # show databases;
99 | # use cms_db;
100 | # show tables;
101 | # show columns from posts; // describe posts; (is the same)
102 | # get the last 4 records from the "images" table:
103 | # SELECT * FROM images ORDER BY created_at DESC LIMIT 4;
104 |
105 | # Bootstrap with development data. SEE:
106 | # https://www.beekeeperstudio.io/blog/how-to-use-mariadb-with-docker
107 |
108 | # CURRENT TIMESTAMP IN MARIADB. SEE:
109 | # https://mariadb.com/kb/en/timestamp/
110 | # https://stackoverflow.com/questions/40864951/mariadb-current-timestamp-default
111 |
112 | # USING GOOSE:
113 | # export GOOSE_DRIVER=mysql
114 | # export GOOSE_DBSTRING="root:my-secret-pw@tcp(localhost:3306)/cms_db"
115 | # MAIN COMMANDS:
116 | # goose create add_sample_post sql
117 | # goose up / goose down
118 | # REST OF COMMANDS:
119 | # https://citizix.com/managing-database-migrations-with-golang-goose-using-incremental-sql-changes/
120 | # https://github.com/pressly/goose
121 |
--------------------------------------------------------------------------------
/settings/settings.go:
--------------------------------------------------------------------------------
1 | package settings
2 |
3 | import (
4 | // _ "embed"
5 |
6 | "fmt"
7 | "log"
8 | "os"
9 | "strconv"
10 |
11 | "github.com/BurntSushi/toml"
12 | // "gopkg.in/yaml.v3"
13 | )
14 |
15 | /* //go:embed settings.yaml
16 | var settingsFile []byte
17 |
18 | type DatabaseConfig struct {
19 | Host string `yaml:"host"`
20 | Port int `yaml:"port"`
21 | User string `yaml:"user"`
22 | Password string `yaml:"password"`
23 | Name string `yaml:"name"`
24 | }
25 |
26 | type Settings struct {
27 | Port string `yaml:"port"`
28 | AdminPort string `yaml:"admin-port"`
29 | DB DatabaseConfig `yaml:"database"`
30 | } */
31 |
32 | type Shortcode struct {
33 | Name string `toml:"name"` // name for the shortcode {{ name:…:…}}
34 | Plugin string `toml:"plugin"` // the lua plugin path
35 | }
36 |
37 | type AppSettings struct {
38 | WebserverPort int `toml:"webserver_port"`
39 | AdminWebserverPort int `toml:"admin_webserver_port"`
40 | DatabaseHost string `toml:"database_host"`
41 | DatabasePort int `toml:"database_port"`
42 | DatabaseUser string `toml:"database_user"`
43 | DatabasePassword string `toml:"database_password"`
44 | DatabaseName string `toml:"database_name"`
45 | ImageDirectory string `toml:"image_dir"`
46 | Shortcodes []Shortcode `toml:"shortcodes"`
47 | }
48 |
49 | func New(filepath *string) (*AppSettings, error) {
50 | var s *AppSettings
51 |
52 | if *filepath != "" {
53 | log.Printf("reading config file %s", *filepath)
54 | settings, err := ReadConfigToml(filepath)
55 | if err != nil {
56 | return nil, err
57 | }
58 |
59 | s = settings
60 | } else {
61 | log.Println("no config file, reading environment variables")
62 | settings, err := LoadSettings()
63 | if err != nil {
64 | return nil, err
65 | }
66 |
67 | s = settings
68 | }
69 |
70 | return s, nil
71 | }
72 |
73 | func LoadSettings() (*AppSettings, error) {
74 | // Want to load the environment variables
75 | var appSettings *AppSettings
76 |
77 | webserver_port_str := os.Getenv("WEBSERVER_PORT")
78 | if webserver_port_str == "" {
79 | webserver_port_str = "8080"
80 | }
81 |
82 | webserver_port, err := strconv.Atoi(webserver_port_str)
83 | if err != nil {
84 | return appSettings, fmt.Errorf(
85 | "WEBSERVER_PORT is not valid: %v", err,
86 | )
87 | }
88 |
89 | admin_webserver_port_str := os.Getenv("ADMIN_WEBSERVER_PORT")
90 | if admin_webserver_port_str == "" {
91 | admin_webserver_port_str = "8081"
92 | }
93 |
94 | admin_webserver_port, err := strconv.Atoi(admin_webserver_port_str)
95 | if err != nil {
96 | return appSettings, fmt.Errorf(
97 | "ADMIN_WEBSERVER_PORT is not valid: %v", err,
98 | )
99 | }
100 |
101 | database_host := os.Getenv("DATABASE_HOST")
102 | if len(database_host) == 0 {
103 | return appSettings, fmt.Errorf("DATABASE_HOST is not defined")
104 | }
105 |
106 | database_port_str := os.Getenv("DATABASE_PORT")
107 | if len(database_port_str) == 0 {
108 | return appSettings, fmt.Errorf("DATABASE_PORT is not defined")
109 | }
110 |
111 | database_port, err := strconv.Atoi(database_port_str)
112 | if err != nil {
113 | return appSettings, fmt.Errorf("DATABASE_PORT is not a valid integer: %v", err)
114 | }
115 |
116 | database_user := os.Getenv("DATABASE_USER")
117 | if len(database_user) == 0 {
118 | return appSettings, fmt.Errorf("DATABASE_USER is not defined")
119 | }
120 |
121 | database_password := os.Getenv("DATABASE_PASSWORD")
122 | if len(database_password) == 0 {
123 | return appSettings, fmt.Errorf("DATABASE_PASSWORD is not defined")
124 | }
125 |
126 | database_name := os.Getenv("DATABASE_NAME")
127 | if len(database_name) == 0 {
128 | return appSettings, fmt.Errorf("DATABASE_NAME is not defined")
129 | }
130 |
131 | image_directory := os.Getenv("IMAGE_DIRECTORY")
132 | if len(image_directory) == 0 {
133 | return appSettings, fmt.Errorf("IMAGE_DIRECTORY is not defined")
134 | }
135 |
136 | config_file_path := os.Getenv("CONFIG_FILE_PATH")
137 | if len(config_file_path) == 0 {
138 | return appSettings, fmt.Errorf("CONFIG_FILE_PATH is not defined")
139 | }
140 |
141 | s, err := ReadConfigToml(&config_file_path)
142 | if err != nil {
143 | return appSettings, fmt.Errorf(
144 | "CONFIG_FILE_PATH is not valid: %v", err,
145 | )
146 | }
147 |
148 | return &AppSettings{
149 | WebserverPort: webserver_port,
150 | AdminWebserverPort: admin_webserver_port,
151 | DatabaseHost: database_host,
152 | DatabaseUser: database_user,
153 | DatabasePassword: database_password,
154 | DatabasePort: database_port,
155 | DatabaseName: database_name,
156 | ImageDirectory: image_directory,
157 | Shortcodes: s.Shortcodes,
158 | }, nil
159 | }
160 |
161 | func ReadConfigToml(filepath *string) (*AppSettings, error) {
162 | /* var s Settings
163 |
164 | err := yaml.Unmarshal(settingsFile, &s)
165 | if err != nil {
166 | return nil, err
167 | }
168 |
169 | return &s, nil */
170 | var s AppSettings
171 |
172 | _, err := toml.DecodeFile(*filepath, &s)
173 | if err != nil {
174 | return nil, err
175 | }
176 |
177 | return &s, nil
178 | }
179 |
--------------------------------------------------------------------------------
/tests/system_tests/helpers/helpers.go:
--------------------------------------------------------------------------------
1 | package helpers
2 |
3 | import (
4 | "context"
5 | "embed"
6 | "fmt"
7 | "log/slog"
8 | "os"
9 | "time"
10 |
11 | "github.com/emarifer/gocms/database"
12 | admin_api "github.com/emarifer/gocms/internal/admin_app/api"
13 | "github.com/emarifer/gocms/internal/app/api"
14 | "github.com/emarifer/gocms/internal/repository"
15 | "github.com/emarifer/gocms/internal/service"
16 | "github.com/emarifer/gocms/settings"
17 | "github.com/gin-gonic/gin"
18 | "github.com/go-playground/validator/v10"
19 |
20 | sqle "github.com/dolthub/go-mysql-server"
21 | "github.com/dolthub/go-mysql-server/memory"
22 | "github.com/dolthub/go-mysql-server/server"
23 | "github.com/dolthub/go-mysql-server/sql"
24 | "github.com/jmoiron/sqlx"
25 |
26 | _ "github.com/go-sql-driver/mysql"
27 | )
28 |
29 | //go:embed migrations/*.sql
30 | var EmbedMigrations embed.FS
31 |
32 | // SEE NOTE BELOW:
33 | func init() {
34 | os.Setenv("GO_ENV", "testing")
35 | }
36 |
37 | func RunDatabaseServer(as settings.AppSettings) {
38 | pro := CreateTestDatabase(as.DatabaseName)
39 | engine := sqle.NewDefault(pro)
40 | engine.Analyzer.Catalog.MySQLDb.AddRootAccount()
41 |
42 | session := memory.NewSession(sql.NewBaseSession(), pro)
43 | ctx := sql.NewContext(context.Background(), sql.WithSession(session))
44 | ctx.SetCurrentDatabase(as.DatabaseName)
45 |
46 | config := server.Config{
47 | Protocol: "tcp",
48 | Address: fmt.Sprintf("%s:%d", as.DatabaseHost, as.DatabasePort),
49 | }
50 | s, err := server.NewServer(config, engine, memory.NewSessionBuilder(pro), nil)
51 | if err != nil {
52 | panic(err)
53 | }
54 | if err = s.Start(); err != nil {
55 | panic(err)
56 | }
57 | }
58 |
59 | func CreateTestDatabase(name string) *memory.DbProvider {
60 | db := memory.NewDatabase(name)
61 | db.BaseDatabase.EnablePrimaryKeyIndexes()
62 |
63 | pro := memory.NewDBProvider(db)
64 |
65 | return pro
66 | }
67 |
68 | func WaitForDb(ctx context.Context, as settings.AppSettings) (*sqlx.DB, error) {
69 | // can be done
11 |
12 | 15 | 16 | A Full Stack Url Shortener App using Golang\'s Echo framework + >HTMX & Templ. 17 | 18 |
19 | 20 |
57 |
58 |
61 |
62 |
65 |
66 |
69 |
70 |
6 |
7 | 10 | 11 | goCMS is a headless CMS (Content Management System) written in Golang using Gin framework + >Htmx & A-H Templ, designed to be fast, efficient, and easily extensible. It allows you to create a website or blog, with any template you like, in only a few commands. 12 | 13 |
14 | 15 |   16 | 17 |