├── 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 |

Failed to send message from { email }!

5 |

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 |

Message Sent From { email }!

5 |

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("![image](%s)", imageSrc) 6 | elseif #args == 2 then 7 | local imageSrc = string.format("/media/%s", args[1]) 8 | 9 | return string.format("![%s](%s)", 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 |
5 |

Something went wrong

6 |
7 |

{ errMsg }

8 |

9 | 10 | ← Go Back 11 | 12 |

13 |
14 |
15 | } 16 | -------------------------------------------------------------------------------- /migrations/20240216130800_create_table_posts.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE IF NOT EXISTS posts ( 4 | id INT NOT NULL AUTO_INCREMENT, 5 | title TEXT NOT NULL, 6 | content TEXT NOT NULL, 7 | excerpt TEXT NOT NULL, 8 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | PRIMARY KEY (id) 10 | ); 11 | -- +goose StatementEnd 12 | 13 | -- +goose Down 14 | -- +goose StatementBegin 15 | DROP TABLE IF EXISTS posts; 16 | -- +goose StatementEnd 17 | -------------------------------------------------------------------------------- /mariadb_init/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE cms_db; 2 | USE cms_db; 3 | 4 | CREATE TABLE IF NOT EXISTS posts ( 5 | id INT NOT NULL AUTO_INCREMENT, 6 | title TEXT NOT NULL, 7 | content TEXT NOT NULL, 8 | excerpt TEXT NOT NULL, 9 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 | PRIMARY KEY (id) 11 | ); 12 | 13 | -- content LONGBLOB NOT NULL, 14 | 15 | CREATE TABLE IF NOT EXISTS images ( 16 | uuid VARCHAR(36) DEFAULT(UUID()) PRIMARY KEY, 17 | name TEXT NOT NULL, 18 | alt TEXT NOT NULL, 19 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 20 | ); 21 | -------------------------------------------------------------------------------- /views/contact.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | templ Contact() { 4 |
5 |

Contact Us

6 |
7 | 8 | 9 |
10 | 11 | 12 |
13 | 14 | 15 |
16 | 17 |
18 | 19 |
20 |
21 | } 22 | -------------------------------------------------------------------------------- /views/post.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "github.com/emarifer/gocms/internal/model" 5 | "github.com/emarifer/gocms/timezone_conversion" 6 | ) 7 | 8 | templ Post(post model.Post, tz string) { 9 |
10 |

Post Details

11 |
12 |

13 | { post.Title } 14 |

15 | 16 | Posted at: { 17 | timezone_conversion.ConvertDateTime(tz, post.CreatedAt) 18 | } 19 | 20 |

21 | @templ.Raw(post.Content) 22 |

23 |

24 | 25 | ← Go Back 26 | 27 |

28 |
29 |
30 | } 31 | -------------------------------------------------------------------------------- /views/home.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/emarifer/gocms/internal/model" 7 | ) 8 | 9 | templ Home(posts []model.Post) { 10 |
11 |

Latest Posts

12 | if len(posts) != 0 { 13 | for _, post := range posts { 14 |
15 |

{ post.Title }

16 |

17 | { post.Excerpt }  18 | 24 | read more… 25 | 26 |

27 |
28 | } 29 | } else { 30 |
31 | There are no Posts 32 |
33 | } 34 |
35 | } 36 | -------------------------------------------------------------------------------- /settings/gocms_config.toml: -------------------------------------------------------------------------------- 1 | # port to run the webserver on 2 | webserver_port = 8080 3 | 4 | # port to run the webserver on 5 | admin_webserver_port = 8081 6 | 7 | # database host 8 | database_host = "localhost" 9 | 10 | # database port 11 | database_port = 3306 12 | 13 | # database user 14 | database_user = "root" 15 | 16 | # database password 17 | database_password = "my-secret-pw" 18 | 19 | # name of database 20 | database_name = "cms_db" 21 | 22 | # directory to use for storing uploaded images 23 | image_dir = "./media" 24 | 25 | [[shortcodes]] 26 | name = "img" 27 | # must have function "HandleShortcode(arguments []string) -> string" 28 | plugin = "plugins/image_shortcode.lua" 29 | 30 | [[shortcodes]] 31 | name = "table" 32 | # must have function "HandleShortcode(arguments []string) -> string" 33 | plugin = "plugins/table_shortcode.lua" 34 | 35 | 36 | # ARRAY OF TABLES IN .TOML FILES: 37 | # https://toml.io/en/v1.0.0#array-of-tables 38 | -------------------------------------------------------------------------------- /docker/gocms_config.toml: -------------------------------------------------------------------------------- 1 | # port to run the webserver on 2 | webserver_port = 8080 3 | 4 | # port to run the webserver on 5 | admin_webserver_port = 8081 6 | 7 | # database host (address to the MariaDB database) 8 | database_host = "mariadb" 9 | 10 | # database port 11 | database_port = 3306 12 | 13 | # database user 14 | database_user = "root" 15 | 16 | # database user 17 | database_password = "my-secret-pw" 18 | 19 | # name of database where gocms's 20 | # migrations was installed 21 | database_name = "cms_db" 22 | 23 | # directory to use for storing uploaded images 24 | image_dir = "./media" 25 | 26 | [[shortcodes]] 27 | name = "img" 28 | # must have function "HandleShortcode(arguments []string) -> string" 29 | plugin = "plugins/image_shortcode.lua" 30 | 31 | [[shortcodes]] 32 | name = "table" 33 | # must have function "HandleShortcode(arguments []string) -> string" 34 | plugin = "plugins/table_shortcode.lua" 35 | 36 | 37 | # ARRAY OF TABLES IN .TOML FILES: 38 | # https://toml.io/en/v1.0.0#array-of-tables 39 | -------------------------------------------------------------------------------- /internal/repository/repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/emarifer/gocms/internal/entity" 7 | "github.com/jmoiron/sqlx" 8 | ) 9 | 10 | // Repository is the interface that wraps the basic CRUD operations. 11 | type Repository interface { 12 | SavePost(ctx context.Context, post *entity.Post) (int, error) 13 | GetPosts(ctx context.Context) ([]entity.Post, error) 14 | GetPost(ctx context.Context, id int) (*entity.Post, error) 15 | UpdatePost(ctx context.Context, post *entity.Post) (int64, error) 16 | DeletePost(ctx context.Context, id int) (int64, error) 17 | 18 | GetImage(ctx context.Context, uuid string) (*entity.Image, error) 19 | GetImages(ctx context.Context, limit int) ([]entity.Image, error) 20 | SaveImage(ctx context.Context, imageData *entity.Image) error 21 | DeleteImage(ctx context.Context, uuid string) (int64, error) 22 | } 23 | 24 | type repo struct { 25 | db *sqlx.DB 26 | } 27 | 28 | func New(db *sqlx.DB) Repository { 29 | 30 | return &repo{ 31 | db: db, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/gocms" 8 | cmd = "make build" 9 | delay = 1000 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata", "media"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go", "_templ.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "./tmp/gocms --config settings/gocms_config.toml" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "templ", "html"] 18 | include_file = [] 19 | kill_delay = "0s" 20 | log = "build-errors.log" 21 | poll = false 22 | poll_interval = 0 23 | rerun = false 24 | rerun_delay = 500 25 | send_interrupt = false 26 | stop_on_error = false 27 | 28 | [color] 29 | app = "" 30 | build = "yellow" 31 | main = "magenta" 32 | runner = "green" 33 | watcher = "cyan" 34 | 35 | [log] 36 | main_only = false 37 | time = false 38 | 39 | [misc] 40 | clean_on_exit = false 41 | 42 | [screen] 43 | clear_on_rebuild = false 44 | keep_scroll = true 45 | 46 | # "go build -o ./tmp/main ./cmd/gocms/main.go" 47 | -------------------------------------------------------------------------------- /docker/.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/gocms" 8 | cmd = "make build" 9 | delay = 1000 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata", "media"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go", "_templ.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "./tmp/gocms --config ./docker/gocms_config.toml" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "templ", "html"] 18 | include_file = [] 19 | kill_delay = "0s" 20 | log = "build-errors.log" 21 | poll = false 22 | poll_interval = 0 23 | rerun = false 24 | rerun_delay = 500 25 | send_interrupt = false 26 | stop_on_error = false 27 | 28 | [color] 29 | app = "" 30 | build = "yellow" 31 | main = "magenta" 32 | runner = "green" 33 | watcher = "cyan" 34 | 35 | [log] 36 | main_only = false 37 | time = false 38 | 39 | [misc] 40 | clean_on_exit = false 41 | 42 | [screen] 43 | clear_on_rebuild = false 44 | keep_scroll = true 45 | 46 | # "go build -o ./tmp/main ./cmd/gocms/main.go" 47 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "AdminCMS", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${workspaceFolder}/cmd/gocms_admin", 13 | "cwd": "${workspaceFolder}", 14 | "args": [ 15 | "--config", 16 | "settings/gocms_config.toml" 17 | ] 18 | }, 19 | { 20 | "name": "goCMS", 21 | "type": "go", 22 | "request": "launch", 23 | "mode": "auto", 24 | "program": "${workspaceFolder}/cmd/gocms", 25 | "cwd": "${workspaceFolder}", 26 | "args": [ 27 | "--config", 28 | "settings/gocms_config.toml" 29 | ] 30 | } 31 | ] 32 | } -------------------------------------------------------------------------------- /assets/css/transition.styles.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Kanit&display=swap'); 2 | 3 | body { 4 | font-family: "Kanit", sans-serif; 5 | } 6 | 7 | @keyframes fade-in { 8 | from { 9 | opacity: 0; 10 | } 11 | } 12 | 13 | @keyframes fade-out { 14 | to { 15 | opacity: 0; 16 | } 17 | } 18 | 19 | @keyframes slide-from-right { 20 | from { 21 | transform: translateX(90px); 22 | } 23 | } 24 | 25 | @keyframes slide-to-left { 26 | to { 27 | transform: translateX(-90px); 28 | } 29 | } 30 | 31 | /* define animations for the old and new content */ 32 | ::view-transition-old(slide-it) { 33 | animation: 180ms cubic-bezier(0.4, 0, 1, 1) both fade-out, 34 | 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left; 35 | } 36 | 37 | ::view-transition-new(slide-it) { 38 | animation: 420ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in, 39 | 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right; 40 | } 41 | 42 | /* tie the view transition to a given CSS class */ 43 | .sample-transition { 44 | view-transition-name: slide-it; 45 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Enrique Marín 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /internal/service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/emarifer/gocms/internal/entity" 7 | "github.com/emarifer/gocms/internal/model" 8 | "github.com/emarifer/gocms/internal/repository" 9 | ) 10 | 11 | // Service is the business logic of the application. 12 | type Service interface { 13 | CreatePost(ctx context.Context, post *entity.Post) (int, error) 14 | RecoverPosts(ctx context.Context) ([]model.Post, error) 15 | RecoverPost(ctx context.Context, id int) (*model.Post, error) 16 | ChangePost(ctx context.Context, post *entity.Post) (int64, error) 17 | RemovePost(ctx context.Context, id int) (int64, error) 18 | 19 | RecoverImageMetadata(ctx context.Context, uuid string) (*model.Image, error) 20 | RecoverAllImageMetadata(ctx context.Context, limit int) ( 21 | []model.Image, error, 22 | ) 23 | CreateImageMetadata(ctx context.Context, image *entity.Image) error 24 | RemoveImage(ctx context.Context, uuid string) (int64, error) 25 | } 26 | 27 | type serv struct { 28 | repo repository.Repository 29 | } 30 | 31 | func New(repo repository.Repository) Service { 32 | 33 | return &serv{ 34 | repo: repo, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/system_tests/app/endpoint_tests/post_test.go: -------------------------------------------------------------------------------- 1 | package endpoint_tests 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/emarifer/gocms/tests/system_tests/helpers" 10 | "github.com/pressly/goose/v3" 11 | "github.com/stretchr/testify/require" 12 | 13 | _ "github.com/go-sql-driver/mysql" 14 | ) 15 | 16 | func TestPostExists(t *testing.T) { 17 | 18 | // This is gonna be the in-memory mysql 19 | appSettings := helpers.GetAppSettings(3) 20 | 21 | go helpers.RunDatabaseServer(appSettings) 22 | 23 | ctx := context.Background() 24 | dbConn, err := helpers.WaitForDb(ctx, appSettings) 25 | require.Nil(t, err) 26 | 27 | // make migrations 28 | goose.SetBaseFS(helpers.EmbedMigrations) 29 | 30 | if err := goose.SetDialect("mysql"); err != nil { 31 | require.Nil(t, err) 32 | } 33 | 34 | if err := goose.Up(dbConn.DB, "migrations"); err != nil { 35 | require.Nil(t, err) 36 | } 37 | 38 | e, err := helpers.StartApp(ctx, appSettings, dbConn) 39 | require.Nil(t, err) 40 | 41 | w := httptest.NewRecorder() 42 | req, err := http.NewRequest("GET", "/post/1", nil) 43 | require.Nil(t, err) 44 | e.ServeHTTP(w, req) 45 | 46 | require.Equal(t, http.StatusOK, w.Code) 47 | } 48 | -------------------------------------------------------------------------------- /internal/admin_app/api/error.middleware.handler.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | type customError struct { 10 | Code int `json:"code"` 11 | ErrMsg string `json:"errMsg"` 12 | CustomMsg string `json:"customMsg"` 13 | } 14 | 15 | func (ce *customError) Error() string { 16 | return fmt.Sprintf("Error %d: %s. %s", ce.Code, ce.ErrMsg, ce.CustomMsg) 17 | } 18 | 19 | func NewCustomError(code int, errMsg, customMsg string) *customError { 20 | return &customError{ 21 | Code: code, 22 | ErrMsg: errMsg, 23 | CustomMsg: customMsg, 24 | } 25 | } 26 | 27 | func (a *API) globalErrorHandler() gin.HandlerFunc { 28 | return func(c *gin.Context) { 29 | c.Next() 30 | 31 | // Catch errors that appear in the middleware or handler 32 | err := c.Errors.Last() 33 | if err != nil { 34 | // Handle errors here 35 | switch e := err.Err.(type) { 36 | case *customError: 37 | // Handle custom errors 38 | c.JSON(e.Code, gin.H{ 39 | "errMsg": e.ErrMsg, 40 | "customMsg": e.CustomMsg, 41 | }) 42 | default: 43 | // Handle other errors 44 | c.JSON(500, gin.H{ 45 | "error": err.Error(), 46 | }) 47 | } 48 | 49 | // Stop context execution 50 | c.Abort() 51 | } 52 | } 53 | } 54 | 55 | /* REFERENCES: 56 | https://blog.ruangdeveloper.com/membuat-global-error-handler-go-gin/ 57 | */ 58 | -------------------------------------------------------------------------------- /docker/mariadb.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | db: 5 | container_name: gocms-db 6 | image: mariadb:jammy 7 | volumes: 8 | - ../mariadb_init:/docker-entrypoint-initdb.d 9 | ports: 10 | - 3306:3306 11 | environment: 12 | - MARIADB_ROOT_PASSWORD=my-secret-pw 13 | 14 | 15 | # docker compose up -d 16 | 17 | # docker exec -it gocms-db mariadb --user root -p # or alternatively: 18 | # docker exec -it gocms-db mariadb -uroot -pmy-secret-pw 19 | # (Enter password:my-secret-pw) 20 | # show databases; 21 | # use cms_db; 22 | # show tables; 23 | # show columns from posts; // describe posts; (is the same) 24 | # get the last 4 records from the "images" table: 25 | # SELECT * FROM images ORDER BY created_at DESC LIMIT 4; 26 | 27 | # Bootstrap with development data. SEE: 28 | # https://www.beekeeperstudio.io/blog/how-to-use-mariadb-with-docker 29 | 30 | # CURRENT TIMESTAMP IN MARIADB. SEE: 31 | # https://mariadb.com/kb/en/timestamp/ 32 | # https://stackoverflow.com/questions/40864951/mariadb-current-timestamp-default 33 | 34 | # USING GOOSE: 35 | # export GOOSE_DRIVER=mysql 36 | # export GOOSE_DBSTRING="root:my-secret-pw@tcp(localhost:3306)/cms_db" 37 | # MAIN COMMANDS: 38 | # goose create add_sample_post sql 39 | # goose up / goose down 40 | # REST OF COMMANDS: 41 | # https://citizix.com/managing-database-migrations-with-golang-goose-using-incremental-sql-changes/ 42 | # https://github.com/pressly/goose -------------------------------------------------------------------------------- /database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "github.com/emarifer/gocms/settings" 10 | "github.com/jmoiron/sqlx" 11 | 12 | _ "github.com/go-sql-driver/mysql" 13 | ) 14 | 15 | // NewMariaDBConnection creates a new MariaDB database connection 16 | func NewMariaDBConnection( 17 | ctx context.Context, s *settings.AppSettings, 18 | ) (*sqlx.DB, error) { 19 | // connectionString := "root:my-secret-pw@tcp(localhost:3306)/cms_db?parseTime=true" // harcoded connection string 20 | 21 | connectionString := fmt.Sprintf( 22 | "%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4", 23 | s.DatabaseUser, 24 | s.DatabasePassword, 25 | s.DatabaseHost, 26 | s.DatabasePort, 27 | s.DatabaseName, 28 | ) 29 | 30 | db, err := sqlx.ConnectContext(ctx, "mysql", connectionString) 31 | if err != nil { 32 | return nil, fmt.Errorf("🔥 failed to connect to the database: %s", err) 33 | } 34 | 35 | // connection settings 36 | db.SetConnMaxLifetime(time.Minute * 3) 37 | db.SetMaxOpenConns(10) 38 | db.SetMaxIdleConns(10) 39 | 40 | // sqlx.BindDriver("mysql", sqlx.DOLLAR) 41 | 42 | log.Println("🚀 Connected Successfully to the Database") 43 | 44 | return db, nil 45 | } 46 | 47 | /* GOLANG STRING CONNECTION CHARSET UTF8MB4. SEE: 48 | https://chromium.googlesource.com/external/github.com/go-sql-driver/mysql/+/a732e14c62dde3285440047bba97581bc472ae18/README.md 49 | https://dev.to/matthewdale/sending-in-go-46bf 50 | */ 51 | -------------------------------------------------------------------------------- /migrations/20240216172623_add_sample_post.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | INSERT INTO posts(title, content, excerpt) VALUES( 4 | 'This is the post dedicated to Don Quixote', 5 | 'En un lugar de la Mancha, de cuyo nombre no quiero acordarme, no ha mucho tiempo que vivía un hidalgo de los de lanza en astillero, adarga antigua, rocín flaco y galgo corredor. Una olla de algo más vaca que carnero, salpicón las más noches, duelos y quebrantos los sábados, lantejas los viernes, algún palomino de añadidura los domingos, consumían las tres partes de su hacienda. El resto della concluían sayo de velarte, calzas de velludo para las fiestas, con sus pantuflos de lo mesmo, y los días de entresemana se honraba con su vellorí de lo más fino. Tenía en su casa una ama que pasaba de los cuarenta, y una sobrina que no llegaba a los veinte, y un mozo de campo y plaza, que así ensillaba el rocín como tomaba la podadera. Frisaba la edad de nuestro hidalgo con los cincuenta años; era de complexión recia, seco de carnes, enjuto de rostro, gran madrugador y amigo de la caza. Quieren decir que tenía el sobrenombre de Quijada, o Quesada, que en esto hay alguna diferencia en los autores que deste caso escriben; aunque por conjeturas verosímiles se deja entender que se llamaba Quijana. Pero esto importa poco a nuestro cuento: basta que en la narración dél no se salga un punto de la verdad.', 6 | 'Excerpt02' 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 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # Version: 0.1 2 | 3 | # 4 | # We base our image from the alpine light image 5 | FROM ubuntu:jammy-20240125 6 | 7 | # 8 | # Environment variables needed for the 9 | # build system 10 | ENV TZ=Europe/Madrid 11 | ENV DEBIAN_FRONTEND="noninteractive" 12 | ENV PATH="${PATH}:/usr/local/go/bin" 13 | 14 | # 15 | # Identify the maintainer of an image 16 | LABEL maintainer="enriquemarin_sierra@hotmail.com" 17 | 18 | # install build dependencies 19 | RUN apt-get update \ 20 | && apt-get install -y --no-install-recommends --no-install-suggests \ 21 | ca-certificates \ 22 | git \ 23 | make \ 24 | pkg-config \ 25 | wget \ 26 | && wget https://go.dev/dl/go1.22.0.linux-amd64.tar.gz -P golang-install \ 27 | && cd golang-install \ 28 | && rm -rf /usr/local/go && tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz \ 29 | && GOBIN=/usr/local/bin go install github.com/pressly/goose/v3/cmd/goose@v3.18.0 \ 30 | && GOBIN=/usr/local/bin go install github.com/a-h/templ/cmd/templ@v0.2.476 \ 31 | && GOBIN=/usr/local/bin go install github.com/cosmtrek/air@v1.49.0 \ 32 | && apt-get remove -y wget \ 33 | && apt-get clean \ 34 | && apt-get autoremove -y \ 35 | && apt-get autoremove --purge -y \ 36 | && go clean --cache \ 37 | && go clean --modcache \ 38 | && go clean --testcache \ 39 | && go clean --fuzzcache \ 40 | && rm -rf golang-install \ 41 | && rm -rf /var/lib/apt/lists/* \ 42 | && rm -rf /usr/lib/gcc/x86_64-linux-gnu/7* 43 | 44 | # COMMAND TO CREATE IMAGE: 45 | # docker build -t emarifer/gocms:0.1 . 46 | -------------------------------------------------------------------------------- /views/layout.templ: -------------------------------------------------------------------------------- 1 | package views 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | templ Layout(title, errMsg string) { 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | goCMS { title } 18 | 19 | 20 | 21 | 22 | 23 | if errMsg == "" { 24 |
25 |

goCMS

26 | 36 |
37 | } 38 |
39 | { children... } 40 |
41 | 46 | 47 | 48 | } 49 | 50 | templ MakePage( 51 | title, errMsg string, cmp templ.Component, 52 | ) { 53 | @Layout(title, errMsg) { 54 | @cmp 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /internal/service/image.service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/emarifer/gocms/internal/entity" 8 | "github.com/emarifer/gocms/internal/model" 9 | ) 10 | 11 | func (s *serv) RecoverImageMetadata( 12 | ctx context.Context, uuid string, 13 | ) (*model.Image, error) { 14 | entityImage, err := s.repo.GetImage(ctx, uuid) 15 | if err != nil { 16 | 17 | return nil, err 18 | } 19 | 20 | mi := &model.Image{ 21 | UUID: entityImage.UUID.String(), 22 | Name: entityImage.Name, 23 | Alt: entityImage.Alt, 24 | } 25 | 26 | return mi, nil 27 | } 28 | 29 | func (s *serv) RecoverAllImageMetadata(ctx context.Context, limit int) ( 30 | []model.Image, error, 31 | ) { 32 | ii := []model.Image{} 33 | 34 | entityImages, err := s.repo.GetImages(ctx, limit) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | for _, item := range entityImages { 40 | i := model.Image{ 41 | UUID: item.UUID.String(), 42 | Name: item.Name, 43 | Alt: item.Alt, 44 | } 45 | 46 | ii = append(ii, i) 47 | } 48 | 49 | return ii, nil 50 | } 51 | 52 | func (s *serv) CreateImageMetadata( 53 | ctx context.Context, image *entity.Image, 54 | ) error { 55 | if image.Name == "" { 56 | return fmt.Errorf("cannot have empty name") 57 | } 58 | 59 | if image.Alt == "" { 60 | return fmt.Errorf("cannot have empty alt text") 61 | } 62 | 63 | if err := s.repo.SaveImage(ctx, image); err != nil { 64 | return err 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func (s *serv) RemoveImage(ctx context.Context, uuid string) (int64, error) { 71 | row, err := s.repo.DeleteImage(ctx, uuid) 72 | if err != nil { 73 | return row, err 74 | } 75 | 76 | return row, nil 77 | } 78 | -------------------------------------------------------------------------------- /benchmark.yml: -------------------------------------------------------------------------------- 1 | # Example of a test plan with 100 iterations and ~25 requests each. 2 | # A plan with 2500 requests in total at 5 iterations at the same time, 3 | # and a rampup of 2 seconds. 4 | # COMMAND: 5 | # ./target/release/drill --benchmark example/benchmark.yml --stats 6 | # SEE: https://github.com/fcsonline/drill 7 | 8 | --- 9 | base: 'http://localhost:8080' 10 | iterations: 1 11 | concurrency: 1 12 | rampup: 2 13 | 14 | plan: 15 | - name: Show Index {{ index }} 16 | request: 17 | url: / 18 | method: GET 19 | body: '' 20 | with_items_range: 21 | start: 1 22 | stop: 10000 23 | 24 | - name: Show Post 25 | request: 26 | url: /post/5 27 | method: GET 28 | body: '' 29 | with_items_range: 30 | start: 1 31 | stop: 10000 32 | 33 | # =========== BENCHMARK =========== 34 | 35 | # WITH CACHE: 36 | 37 | # Time taken for tests 4.7 seconds 38 | # Total requests 20000 39 | # Successful requests 20000 40 | # Failed requests 0 41 | # Requests per second 4221.01 [#/sec] 42 | # Median time per request 0ms 43 | # Average time per request 0ms 44 | # Sample standard deviation 0ms 45 | # 99.0'th percentile 0ms 46 | # 99.5'th percentile 1ms 47 | # 99.9'th percentile 2ms 48 | 49 | # WITHOUT CACHE: 50 | 51 | # Time taken for tests 15.8 seconds 52 | # Total requests 20000 53 | # Successful requests 20000 54 | # Failed requests 0 55 | # Requests per second 1266.54 [#/sec] 56 | # Median time per request 1ms 57 | # Average time per request 1ms 58 | # Sample standard deviation 0ms 59 | # 99.0'th percentile 2ms 60 | # 99.5'th percentile 2ms 61 | # 99.9'th percentile 3ms -------------------------------------------------------------------------------- /internal/app/api/contact.handlers.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "net/mail" 7 | 8 | "github.com/emarifer/gocms/views" 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | // contactHandler will act as a controller 13 | // for the Contact page 14 | func (a *API) contactHandler(c *gin.Context) ([]byte, *customError) { 15 | 16 | cmp := views.MakePage("| Contact", "", views.Contact()) 17 | html_buffer := bytes.NewBuffer(nil) 18 | err := cmp.Render(c.Request.Context(), html_buffer) 19 | if err != nil { 20 | err := NewCustomError( 21 | http.StatusInternalServerError, 22 | "An unexpected condition was encountered. The requested page could not be rendered.", 23 | ) 24 | 25 | return nil, err 26 | } 27 | 28 | return html_buffer.Bytes(), nil 29 | } 30 | 31 | // contactFormHandle will act as a controller 32 | // for the POST request made by the contact form 33 | func (a *API) contactFormHandler(c *gin.Context) { 34 | name := c.Request.FormValue("name") // name := c.PostForm("name") 35 | email := c.Request.FormValue("email") 36 | message := c.Request.FormValue("message") 37 | 38 | // Check email 39 | _, err := mail.ParseAddress(email) 40 | if err != nil { 41 | a.renderView(c, http.StatusOK, views.ContactFailure( 42 | email, "invalid email", 43 | )) 44 | 45 | return 46 | } 47 | 48 | // Make sure name and message is reasonable 49 | if len(name) > 200 { 50 | a.renderView(c, http.StatusOK, views.ContactFailure( 51 | email, "enter a name of less than 200 characters", 52 | )) 53 | 54 | return 55 | } 56 | 57 | if len(message) > 1000 { 58 | a.renderView(c, http.StatusOK, views.ContactFailure( 59 | email, "message too big", 60 | )) 61 | 62 | return 63 | } 64 | 65 | a.renderView(c, http.StatusOK, views.ContactSuccess(email, name)) 66 | } 67 | -------------------------------------------------------------------------------- /cmd/gocms_admin/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | 10 | "github.com/emarifer/gocms/database" 11 | "github.com/emarifer/gocms/internal/admin_app/api" 12 | "github.com/emarifer/gocms/internal/repository" 13 | "github.com/emarifer/gocms/internal/service" 14 | "github.com/emarifer/gocms/settings" 15 | "github.com/gin-gonic/gin" 16 | "github.com/go-playground/validator/v10" 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 | validator.New, 38 | database.NewMariaDBConnection, 39 | repository.New, 40 | service.New, 41 | gin.Default, 42 | api.New, 43 | ), 44 | 45 | fx.Invoke( 46 | setLifeCycle, 47 | ), 48 | ) 49 | 50 | gocms.Run() 51 | } 52 | 53 | func setLifeCycle( 54 | lc fx.Lifecycle, 55 | a *api.API, 56 | s *settings.AppSettings, 57 | e *gin.Engine, 58 | ) { 59 | lc.Append(fx.Hook{ 60 | OnStart: func(ctx context.Context) error { 61 | address := fmt.Sprintf(":%d", s.AdminWebserverPort) 62 | go func() { 63 | // e.Logger.Fatal(a.Start(e, address)) 64 | a.Start(e, address) 65 | }() 66 | 67 | return nil 68 | }, 69 | OnStop: func(ctx context.Context) error { 70 | // return e.Close() 71 | return nil 72 | }, 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /internal/service/post.service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/emarifer/gocms/internal/entity" 7 | "github.com/emarifer/gocms/internal/model" 8 | ) 9 | 10 | func (s *serv) CreatePost(ctx context.Context, post *entity.Post) (int, error) { 11 | var ( 12 | lastInsertId int 13 | err error 14 | ) 15 | 16 | if lastInsertId, err = s.repo.SavePost(ctx, post); err != nil { 17 | return lastInsertId, err 18 | } 19 | 20 | return lastInsertId, nil 21 | } 22 | 23 | func (s *serv) RecoverPosts(ctx context.Context) ([]model.Post, error) { 24 | pp := []model.Post{} 25 | 26 | entityPosts, err := s.repo.GetPosts(ctx) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | for _, item := range entityPosts { 32 | p := model.Post{ 33 | ID: item.ID, 34 | Title: item.Title, 35 | Excerpt: item.Excerpt, 36 | } 37 | 38 | pp = append(pp, p) 39 | } 40 | 41 | return pp, nil 42 | } 43 | 44 | func (s *serv) RecoverPost(ctx context.Context, id int) (*model.Post, error) { 45 | entityPost, err := s.repo.GetPost(ctx, id) 46 | if err != nil { 47 | 48 | return nil, err 49 | } 50 | 51 | mp := &model.Post{ 52 | ID: entityPost.ID, 53 | Title: entityPost.Title, 54 | Content: entityPost.Content, 55 | Excerpt: entityPost.Excerpt, 56 | CreatedAt: entityPost.CreatedAt, 57 | } 58 | 59 | return mp, nil 60 | } 61 | 62 | func (s *serv) ChangePost( 63 | ctx context.Context, post *entity.Post, 64 | ) (int64, error) { 65 | row, err := s.repo.UpdatePost(ctx, post) 66 | if err != nil { 67 | return row, err 68 | } 69 | 70 | return row, nil 71 | } 72 | 73 | func (s *serv) RemovePost(ctx context.Context, id int) (int64, error) { 74 | row, err := s.repo.DeletePost(ctx, id) 75 | if err != nil { 76 | return row, err 77 | } 78 | 79 | return row, nil 80 | } 81 | -------------------------------------------------------------------------------- /docker/start-app.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | git config --global --add safe.directory '*' 6 | 7 | cd /gocms/migrations 8 | GOOSE_DRIVER="mysql" GOOSE_DBSTRING="root:my-secret-pw@tcp(mariadb:3306)/cms_db" goose up 9 | 10 | cd /gocms 11 | air -c ./docker/.air.toml 12 | 13 | # root:my-secret-pw@tcp(localhost:3306)/cms_db 14 | 15 | # reason this line (git config --global --add safe.directory '*') is added: 16 | # https://www.youtube.com/watch?v=41iONer9RxM&list=PLZ51_5WcvDvCzCB2nwm8AWodXoBbaO3Iw&index=10&t=4063s 17 | # https://www.google.com/search?q=detect+dubious+ownership+in+repository+at+docker+container&oq=detect+dubious+ownership+in+repository+at+docker+co&aqs=chrome.2.69i57j33i160l2j33i21.9509j0j4&sourceid=chrome&ie=UTF-8#ip=1 18 | # https://community.jenkins.io/t/detected-dubious-ownership-in-repository-with-jenkins-upgrade/6182 19 | # https://docs.docker.com/compose/compose-file/compose-file-v3/#volumes 20 | 21 | # ============================================================================ 22 | # Explanation from this moment "https://www.youtube.com/live/41iONer9RxM?si=7SA8DwA9yyu_BhJi&t=7134" until 5 minutes later ("https://www.youtube.com/live/41iONer9RxM?si=BD0TT3Rgqk_FJxtp&t=7349") 23 | 24 | # SCRIPT EXAMPLE THAT FAILS LOUDLY: 25 | # #!/bin/bash 26 | 27 | # set -euxo pipefail 28 | 29 | # firstName="Aaron" 30 | # fullName="$firstname Maxwell" 31 | # echo "$fullName" 32 | 33 | # IF WE DO NOT ADD THE "set -euxo pipefail" LINE THE SCRIPT WILL FAIL WITHOUT US KNOWING WHY. 34 | # WITH THE COMMAND "echo $?" WE CAN OBTAIN THE EXIT CODE: 35 | # echo $? => 36 | # 1 37 | # [What does echo $? do?] => https://unix.stackexchange.com/questions/501128/what-does-echo-do 38 | # REFERENCES: 39 | # https://www.google.com/search?q=set+-euo+pipefail&oq=set+-euo+pipefail&aqs=chrome..69i57.1859395j0j7&sourceid=chrome&ie=UTF-8 40 | # https://gist.github.com/mohanpedala/1e2ff5661761d3abd0385e8223e16425?permalink_comment_id=3935570 -------------------------------------------------------------------------------- /internal/app/api/error.middleware.handler.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | 8 | "github.com/emarifer/gocms/views" 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | type customError struct { 13 | Code int `json:"code"` 14 | Message string `json:"message"` 15 | } 16 | 17 | func (ce *customError) Error() string { 18 | return fmt.Sprintf("Error %d: %s", ce.Code, ce.Message) 19 | } 20 | 21 | func NewCustomError(code int, message string) *customError { 22 | return &customError{ 23 | Code: code, 24 | Message: message, 25 | } 26 | } 27 | 28 | func (a *API) globalErrorHandler() gin.HandlerFunc { 29 | return func(c *gin.Context) { 30 | c.Next() 31 | 32 | // Catch errors that appear in the middleware or handler 33 | err := c.Errors.Last() 34 | if err != nil { 35 | // Handle errors here 36 | switch e := err.Err.(type) { 37 | case *customError: 38 | // Handle custom errors 39 | // SEE NOTE BELOW (this is hacky): 40 | if os.Getenv("GO_ENV") == "testing" { 41 | a.logger.Error( 42 | "error message", 43 | slog.String("could not render HTML", err.Error()), 44 | ) 45 | c.JSON(e.Code, gin.H{ 46 | "error": "could not render HTML", 47 | "msg": e.Error(), 48 | }) 49 | } else { 50 | a.renderView(c, e.Code, views.MakePage( 51 | fmt.Sprintf("| Error %d", e.Code), 52 | e.Message, 53 | views.ErrorPage(e.Message), 54 | )) 55 | } 56 | default: 57 | // Handle other errors 58 | c.JSON(500, gin.H{ 59 | "error": err.Error(), 60 | }) 61 | } 62 | 63 | // Stop context execution 64 | c.Abort() 65 | } 66 | } 67 | } 68 | 69 | /* REFERENCES: 70 | https://blog.ruangdeveloper.com/membuat-global-error-handler-go-gin/ 71 | */ 72 | 73 | /* HOW DO I KNOW I'M RUNNING WITHIN "GO TEST". SEE: 74 | https://stackoverflow.com/questions/14249217/how-do-i-know-im-running-within-go-test#59444829 75 | */ 76 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #!make 2 | include .dev.env 3 | 4 | # Go parameters 5 | GOCMD=go 6 | GOBUILD=$(GOCMD) build 7 | GOCLEAN=$(GOCMD) clean 8 | GOTEST=$(GOCMD) test 9 | GOGET=$(GOCMD) get 10 | 11 | # Templ parameters 12 | TEMPLCMD=templ 13 | 14 | # Application name 15 | APPNAME=gocms 16 | ADMINAPPNAME=gocms-admin 17 | 18 | # Directories 19 | SRC=./cmd/gocms 20 | ADMINSRC=./cmd/gocms_admin 21 | OUT=./tmp 22 | 23 | # Targets 24 | all: build test 25 | 26 | # Migrations for DB 27 | prepare_env: 28 | cp -r migrations tests/system_tests/helpers/ 29 | 30 | # ==== Development ==== 31 | 32 | install-tools: 33 | go install github.com/a-h/templ/cmd/templ@v0.2.476 34 | go install github.com/pressly/goose/v3/cmd/goose@v3.18.0 35 | go install github.com/cosmtrek/air@v1.49.0 36 | 37 | start-devdb: 38 | docker compose -f docker/mariadb.yml up -d 39 | 40 | run-migrations: 41 | GOOSE_DRIVER=mysql GOOSE_DBSTRING="${MARIADB_USER}:${MARIADB_ROOT_PASSWORD}@tcp(${MARIADB_ADDRESS}:${MARIADB_PORT})/${MARIADB_DATABASE}" goose -dir ./migrations up 42 | 43 | build: 44 | $(TEMPLCMD) generate 45 | $(GOBUILD) -ldflags="-s -w" -v -o $(OUT)/$(APPNAME) $(SRC)/*.go 46 | $(GOBUILD) -ldflags="-s -w" -v -o $(OUT)/$(ADMINAPPNAME) $(ADMINSRC)/*.go 47 | 48 | run: 49 | # $(GOBUILD) -o $(OUT)/$(APPNAME) $(SRC)/*.go 50 | # $(OUT)/$(APPNAME) 51 | DATABASE_HOST=localhost DATABASE_PORT=3306 DATABASE_USER=root DATABASE_PASSWORD=my-secret-pw DATABASE_NAME=cms_db IMAGE_DIRECTORY="./media" CONFIG_FILE_PATH="settings/gocms_config.toml" $(OUT)/$(ADMINAPPNAME) 52 | 53 | # ==== Docker Containers ==== 54 | 55 | run-containers: 56 | docker build -t emarifer/gocms:0.1 docker/ 57 | docker compose -f docker/docker-compose.yml up 58 | 59 | start-admin-container: 60 | $(OUT)/$(ADMINAPPNAME) --config docker/gocms_config.toml 61 | 62 | # Testing 63 | test: prepare_env 64 | $(GOTEST) -v ./... 65 | 66 | clean: 67 | $(GOCLEAN) 68 | rm -rf $(OUT) 69 | 70 | .PHONY: all build test clean 71 | 72 | # Why does make think the target is up to date?. SEE: 73 | # https://stackoverflow.com/questions/3931741/why-does-make-think-the-target-is-up-to-date 74 | -------------------------------------------------------------------------------- /tests/system_tests/app/endpoint_tests/home_test.go: -------------------------------------------------------------------------------- 1 | package endpoint_tests 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/emarifer/gocms/tests/system_tests/helpers" 10 | "github.com/pressly/goose/v3" 11 | "github.com/stretchr/testify/require" 12 | 13 | _ "github.com/go-sql-driver/mysql" 14 | ) 15 | 16 | func TestHomePagePing(t *testing.T) { 17 | 18 | // This is gonna be the in-memory mysql 19 | appSettings := helpers.GetAppSettings(1) 20 | 21 | go helpers.RunDatabaseServer(appSettings) 22 | // time.Sleep(25 * time.Millisecond) 23 | 24 | ctx := context.Background() 25 | dbConn, err := helpers.WaitForDb(ctx, appSettings) 26 | require.Nil(t, err) 27 | 28 | // make migrations 29 | goose.SetBaseFS(helpers.EmbedMigrations) 30 | 31 | if err := goose.SetDialect("mysql"); err != nil { 32 | require.Nil(t, err) 33 | } 34 | 35 | if err := goose.Up(dbConn.DB, "migrations"); err != nil { 36 | require.Nil(t, err) 37 | } 38 | 39 | e, err := helpers.StartApp(ctx, appSettings, dbConn) 40 | require.Nil(t, err) 41 | 42 | w := httptest.NewRecorder() 43 | req, err := http.NewRequest("GET", "/", nil) 44 | require.Nil(t, err) 45 | e.ServeHTTP(w, req) 46 | 47 | require.Equal(t, http.StatusOK, w.Code) 48 | } 49 | 50 | func TestHomePostExists(t *testing.T) { 51 | 52 | // This is gonna be the in-memory mysql 53 | appSettings := helpers.GetAppSettings(2) 54 | 55 | go helpers.RunDatabaseServer(appSettings) 56 | 57 | ctx := context.Background() 58 | dbConn, err := helpers.WaitForDb(ctx, appSettings) 59 | require.Nil(t, err) 60 | 61 | // make migrations 62 | goose.SetBaseFS(helpers.EmbedMigrations) 63 | 64 | if err := goose.SetDialect("mysql"); err != nil { 65 | require.Nil(t, err) 66 | } 67 | 68 | if err := goose.Up(dbConn.DB, "migrations"); err != nil { 69 | require.Nil(t, err) 70 | } 71 | 72 | e, err := helpers.StartApp(ctx, appSettings, dbConn) 73 | require.Nil(t, err) 74 | 75 | w := httptest.NewRecorder() 76 | req, err := http.NewRequest("GET", "/", nil) 77 | require.Nil(t, err) 78 | e.ServeHTTP(w, req) 79 | 80 | require.Equal(t, http.StatusOK, w.Code) 81 | 82 | require.Contains(t, w.Body.String(), "Gofiber Templ Htmx") 83 | } 84 | -------------------------------------------------------------------------------- /internal/repository/image.repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/emarifer/gocms/internal/entity" 7 | ) 8 | 9 | const ( 10 | qryInsertImageData = ` 11 | INSERT INTO images (uuid, name, alt) 12 | VALUES (:uuid, :name, :alt); 13 | ` 14 | 15 | qryGetImageById = ` 16 | SELECT * FROM images 17 | WHERE uuid = ?; 18 | ` 19 | 20 | qryGetAllImages = ` 21 | SELECT * FROM images 22 | ORDER BY created_at DESC LIMIT ?; 23 | ` 24 | 25 | qryDeleteImage = ` 26 | DELETE FROM images 27 | WHERE uuid = ?; 28 | ` 29 | ) 30 | 31 | // SaveImage inserts the metadata of an image 32 | // in the database, passing the id, name and alt text 33 | func (r *repo) SaveImage(ctx context.Context, imageData *entity.Image) error { 34 | tx, err := r.db.Beginx() 35 | if err != nil { 36 | return err 37 | } 38 | defer tx.Rollback() 39 | 40 | _, err = tx.NamedExecContext(ctx, qryInsertImageData, imageData) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | if err = tx.Commit(); err != nil { 46 | return err 47 | } 48 | 49 | return nil 50 | } 51 | 52 | // GetImages gets all the image metadata from 53 | // the current database sorted in descending order according to 54 | // insertion timestamp with a limit 55 | func (r *repo) GetImages(ctx context.Context, limit int) ( 56 | []entity.Image, error, 57 | ) { 58 | ii := []entity.Image{} 59 | 60 | err := r.db.SelectContext(ctx, &ii, qryGetAllImages, limit) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | return ii, nil 66 | } 67 | 68 | // GetImage gets image metadata from the database 69 | // with the given ID. 70 | func (r *repo) GetImage(ctx context.Context, uuid string) ( 71 | *entity.Image, error, 72 | ) { 73 | img := &entity.Image{} 74 | 75 | err := r.db.GetContext(ctx, img, qryGetImageById, uuid) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | return img, nil 81 | } 82 | 83 | // DeleteImage delete image metadata from the database 84 | // with the given ID. 85 | func (r *repo) DeleteImage(ctx context.Context, uuid string) (int64, error) { 86 | var row int64 87 | 88 | result, err := r.db.ExecContext(ctx, qryDeleteImage, uuid) 89 | if err != nil { 90 | return -1, err 91 | } 92 | 93 | if row, err = result.RowsAffected(); err != nil { 94 | return -1, err 95 | } else if row == 0 { 96 | return -1, err 97 | } 98 | 99 | return row, nil 100 | } 101 | -------------------------------------------------------------------------------- /tests/system_tests/admin_app/endpoint_tests/post_test.go: -------------------------------------------------------------------------------- 1 | package admin_endpoint_tests 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | 12 | "github.com/emarifer/gocms/internal/admin_app/api/dto" 13 | "github.com/emarifer/gocms/internal/model" 14 | "github.com/emarifer/gocms/tests/system_tests/helpers" 15 | "github.com/pressly/goose/v3" 16 | "github.com/stretchr/testify/require" 17 | 18 | _ "github.com/go-sql-driver/mysql" 19 | ) 20 | 21 | type AddPostResponse struct { 22 | Id int `json:"id"` 23 | } 24 | 25 | func TestAdminPostExists(t *testing.T) { 26 | 27 | // This is gonna be the in-memory mysql 28 | appSettings := helpers.GetAppSettings(4) 29 | 30 | go helpers.RunDatabaseServer(appSettings) 31 | 32 | ctx := context.Background() 33 | dbConn, err := helpers.WaitForDb(ctx, appSettings) 34 | require.Nil(t, err) 35 | 36 | // make migrations 37 | goose.SetBaseFS(helpers.EmbedMigrations) 38 | 39 | if err := goose.SetDialect("mysql"); err != nil { 40 | require.Nil(t, err) 41 | } 42 | 43 | if err := goose.Up(dbConn.DB, "migrations"); err != nil { 44 | require.Nil(t, err) 45 | } 46 | 47 | // send the post in 48 | addPostRequest := dto.AddPostRequest{ 49 | Title: "Test Post Title", 50 | Excerpt: "test post excerpt", 51 | Content: "test post content", 52 | } 53 | 54 | e, err := helpers.StartAdminApp(ctx, appSettings, dbConn) 55 | require.Nil(t, err) 56 | 57 | w := httptest.NewRecorder() 58 | requestBytes, err := json.Marshal(addPostRequest) 59 | require.Nil(t, err) 60 | req, err := http.NewRequest( 61 | "POST", "/api/v1/post", bytes.NewBuffer(requestBytes), 62 | ) 63 | require.Nil(t, err) 64 | e.ServeHTTP(w, req) 65 | 66 | require.Equal(t, http.StatusCreated, w.Code) 67 | 68 | // make sure it's an expected response 69 | addPostResponse := &AddPostResponse{} 70 | err = json.Unmarshal(w.Body.Bytes(), addPostResponse) 71 | require.Nil(t, err) 72 | 73 | // Get the post 74 | w = httptest.NewRecorder() 75 | req, err = http.NewRequest( 76 | "GET", fmt.Sprintf("/api/v1/post/%d", addPostResponse.Id), nil, 77 | ) 78 | require.Nil(t, err) 79 | e.ServeHTTP(w, req) 80 | 81 | require.Equal(t, http.StatusOK, w.Code) 82 | 83 | // make sure it's the expected content 84 | getPostResponse := &model.Post{} 85 | err = json.Unmarshal(w.Body.Bytes(), getPostResponse) 86 | require.Nil(t, err) 87 | 88 | require.Equal(t, getPostResponse.ID, addPostResponse.Id) 89 | require.Equal(t, getPostResponse.Title, addPostRequest.Title) 90 | require.Equal(t, getPostResponse.Excerpt, addPostRequest.Excerpt) 91 | require.Equal(t, getPostResponse.Content, addPostRequest.Content) 92 | } 93 | -------------------------------------------------------------------------------- /migrations/20240218113309_add_complex_post_example.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | INSERT INTO posts(title, content, excerpt) VALUES( 4 | 'First post with Markdown', 5 | '## goCMS 🐚 6 | 7 | goCMS is a headless CMS (Content Management System) written in Golang, designed to be fast, efficient, and easily extensible. It allows you to 8 | create a website or blog, with any template you like, in only a few 9 | commands. 10 | 11 | ### Features 🚀 12 | 13 | - **Headless Architecture:** Adding pages, posts, or forms should all 14 | be done with easy requests to the API. 15 | - **Golang-Powered:** Leverage the performance and safety of one of the 16 | best languages in the market for backend development. 17 | - **SQL Database Integration:** Store your posts and pages in SQL databases for reliable and scalable data storage. 18 | 19 | ### Installation 20 | 21 | Ensure you have Golang installed on your system before proceeding with the installation. 22 | 23 | ```bash 24 | go get -u github.com/username/goCMS 25 | ``` 26 | 27 | ### Example - Running the App 28 | 29 | After you\'ve replaced the default template files with your prefered 30 | template, simply build and start the app with the following commands. 31 | 32 | ```bash 33 | go build 34 | ./goCMS 35 | ``` 36 | 37 | This will start goCMS on `http://localhost:8080`. You can customize 38 | the configuration by providing the necessary environment variables. 39 | 40 | For more information, see the [configuration settings](#configuration). 41 | 42 | ### Dependencies 43 | 44 | goCMS relies on the following Golang dependencies: 45 | 46 | - [Gin](github.com/gin-gonic/gin) as the web framework for Golang. 47 | - [ZeroLog](https://github.com/rs/zerolog) for logging. 48 | 49 | ### Configuration 50 | 51 | The runtime configuration is handled through reading the 52 | necessary environment variables. This approach was chosen as 53 | it makes integrating `envfile`s quite easy. 54 | 55 | The following list outlines the environment variables needed. 56 | 57 | - `GOCMS_DATABASE_ADDRESS` should contain the database addres, 58 | e.g. `localhost`. 59 | - `GOCMS_DATABASE_PORT` should be the connection port to the 60 | database. For example `3306`. 61 | - `GOCMS_DATABASE_USER` is the database username. 62 | - `GOCMS_DATABASE_PASSWORD` needs to contain the database 63 | password for the given user. 64 | 65 | ### License 66 | 67 | goCMS is released under the MIT License. See LICENSE (TODO) for 68 | details. Feel free to fork, modify, and use it in your projects!', 69 | 'This is goCMS! This post is an example of how markdown can be rendered as a post.' 70 | ); 71 | -- +goose StatementEnd 72 | 73 | -- +goose Down 74 | -- +goose StatementBegin 75 | DELETE FROM posts ORDER BY id DESC LIMIT 1; 76 | -- +goose StatementEnd 77 | -------------------------------------------------------------------------------- /internal/app/api/cache.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "sync/atomic" 6 | "time" 7 | 8 | shardedmap "github.com/zutto/shardedmap" 9 | ) 10 | 11 | // Do not store over this amount 12 | // of MBs in the cache 13 | const MAX_CACHE_SIZE_MB = 10 14 | 15 | type EndpointCache struct { 16 | Name string 17 | Contents []byte 18 | ValidUntil time.Time 19 | } 20 | 21 | func emptyEndpointCache() EndpointCache { 22 | 23 | return EndpointCache{"", []byte{}, time.Now()} 24 | } 25 | 26 | type TimeValidator struct{} 27 | 28 | type CacheValidator interface { 29 | IsValid(cache *EndpointCache) bool 30 | } 31 | 32 | func (v *TimeValidator) IsValid(cache *EndpointCache) bool { 33 | 34 | // We only return the cache if it's still valid 35 | return cache.ValidUntil.After(time.Now()) 36 | } 37 | 38 | type TimedCache struct { 39 | CacheMap shardedmap.ShardMap 40 | CacheTimeout time.Duration 41 | EstimatedSize atomic.Uint64 // in bytes 42 | Validator CacheValidator 43 | } 44 | 45 | type Cache interface { 46 | Get(name string) (EndpointCache, error) 47 | Store(name string, buffer []byte) error 48 | Size() uint64 49 | } 50 | 51 | func MakeCache(n_shards int, expiry_duration time.Duration) Cache { 52 | 53 | return &TimedCache{ 54 | CacheMap: shardedmap.NewShardMap(n_shards), 55 | CacheTimeout: expiry_duration, 56 | EstimatedSize: atomic.Uint64{}, 57 | Validator: &TimeValidator{}, 58 | } 59 | } 60 | 61 | // Implementing the interface Cache 62 | 63 | func (cache *TimedCache) Get(name string) (EndpointCache, error) { 64 | cacheEntry := cache.CacheMap.Get(name) 65 | // if the endpoint is cached 66 | if cacheEntry != nil { 67 | cacheContents, ok := (*cacheEntry).(EndpointCache) 68 | if !ok { 69 | return emptyEndpointCache(), fmt.Errorf( 70 | "it has not been possible to make a type assertion on the cache stored under that key", 71 | ) 72 | } 73 | 74 | // We only return the cache if it's still valid 75 | if cache.Validator.IsValid(&cacheContents) { 76 | return cacheContents, nil 77 | } else { 78 | cache.CacheMap.Delete(name) 79 | 80 | return emptyEndpointCache(), fmt.Errorf( 81 | "cached endpoint had expired", 82 | ) 83 | } 84 | } 85 | 86 | return emptyEndpointCache(), fmt.Errorf("cache does not contain key") 87 | } 88 | 89 | func (cache *TimedCache) Store(name string, buffer []byte) error { 90 | // Only store to the cache if we have enough space left 91 | afterSizeMB := float64( 92 | cache.EstimatedSize.Load()+uint64(len(buffer)), 93 | ) / 1000000 94 | if afterSizeMB > MAX_CACHE_SIZE_MB { 95 | return fmt.Errorf("maximum size reached") 96 | } 97 | 98 | // Saving to shardedmap 99 | var cacheEntry interface{} = EndpointCache{ 100 | Name: name, 101 | Contents: buffer, 102 | ValidUntil: time.Now().Add(cache.CacheTimeout), 103 | } 104 | 105 | cache.CacheMap.Set(name, &cacheEntry) 106 | cache.EstimatedSize.Add(uint64(len(buffer))) 107 | 108 | return nil 109 | } 110 | 111 | func (cache *TimedCache) Size() uint64 { 112 | 113 | return cache.EstimatedSize.Load() 114 | } 115 | -------------------------------------------------------------------------------- /internal/app/api/posts.handlers.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/emarifer/gocms/internal/app/api/dto" 11 | "github.com/emarifer/gocms/views" 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | // homeHandler will act as the handler for 16 | // the home page 17 | func (a *API) homeHandler(c *gin.Context) ([]byte, *customError) { 18 | ctx := c.Request.Context() 19 | 20 | posts, err := a.serv.RecoverPosts(ctx) 21 | if err != nil { 22 | if re.MatchString(err.Error()) { 23 | err := NewCustomError( 24 | http.StatusInternalServerError, 25 | "An unexpected condition was encountered. Please wait a few moments to reload the page.", 26 | ) 27 | 28 | return nil, err 29 | } else { 30 | err := NewCustomError( 31 | http.StatusInternalServerError, 32 | fmt.Sprintf( 33 | "An unexpected condition was encountered: %s", err.Error(), 34 | ), 35 | ) 36 | 37 | return nil, err 38 | } 39 | } 40 | 41 | cmp := views.MakePage("| Home", "", views.Home(posts)) 42 | html_buffer := bytes.NewBuffer(nil) 43 | err = cmp.Render(c.Request.Context(), html_buffer) 44 | if err != nil { 45 | err := NewCustomError( 46 | http.StatusInternalServerError, 47 | "An unexpected condition was encountered. The requested page could not be rendered.", 48 | ) 49 | 50 | return nil, err 51 | } 52 | 53 | return html_buffer.Bytes(), nil 54 | } 55 | 56 | // postHandler will act as a controller 57 | // the post details display page 58 | func (a *API) postHandler(c *gin.Context) ([]byte, *customError) { 59 | tz := c.GetHeader("X-TimeZone") 60 | ctx := c.Request.Context() 61 | postBinding := &dto.PostBinding{} 62 | 63 | // localhost:8080/post/{id} 64 | if err := c.ShouldBindUri(postBinding); err != nil { 65 | err := NewCustomError( 66 | http.StatusBadRequest, 67 | "Invalid URL.", 68 | ) 69 | 70 | return nil, err 71 | } 72 | 73 | // Get the post with the ID 74 | 75 | postId, err := strconv.Atoi(postBinding.Id) 76 | if err != nil { 77 | if strings.Contains(err.Error(), "strconv.Atoi") { 78 | err := NewCustomError( 79 | http.StatusBadRequest, 80 | "Invalid URL.", 81 | ) 82 | 83 | return nil, err 84 | } 85 | } 86 | 87 | post, err := a.serv.RecoverPost(ctx, postId) 88 | if err != nil { 89 | if re.MatchString(err.Error()) { 90 | err := NewCustomError( 91 | http.StatusInternalServerError, 92 | "An unexpected condition was encountered. Please wait a few moments to reload the page.", 93 | ) 94 | 95 | return nil, err 96 | } 97 | 98 | if strings.Contains(err.Error(), "no rows in result set") { 99 | err := NewCustomError( 100 | http.StatusNotFound, 101 | "The requested resource could not be found but may be available again in the future.", 102 | ) 103 | 104 | return nil, err 105 | } 106 | } 107 | 108 | // Markdown to HTML the post content 109 | post.Content = string(a.mdToHTML([]byte(post.Content))) 110 | 111 | cmp := views.MakePage( 112 | fmt.Sprintf("| %s", post.Title), 113 | "", 114 | views.Post(*post, tz), 115 | ) 116 | html_buffer := bytes.NewBuffer(nil) 117 | err = cmp.Render(c.Request.Context(), html_buffer) 118 | if err != nil { 119 | err := NewCustomError( 120 | http.StatusInternalServerError, 121 | "An unexpected condition was encountered. The requested page could not be rendered.", 122 | ) 123 | 124 | return nil, err 125 | } 126 | 127 | return html_buffer.Bytes(), nil 128 | } 129 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/emarifer/gocms 2 | 3 | go 1.22.1 4 | 5 | require ( 6 | github.com/gin-contrib/gzip v0.0.6 7 | github.com/gin-gonic/gin v1.9.1 8 | github.com/go-sql-driver/mysql v1.8.0 9 | github.com/jmoiron/sqlx v1.3.5 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | 13 | require github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 14 | 15 | require ( 16 | github.com/BurntSushi/toml v1.3.2 17 | github.com/google/uuid v1.6.0 18 | github.com/pressly/goose/v3 v3.19.2 19 | github.com/yuin/gopher-lua v1.1.1 20 | github.com/zutto/shardedmap v0.0.0-20180201164343-415202d0910e 21 | ) 22 | 23 | require ( 24 | filippo.io/edwards25519 v1.1.0 // indirect 25 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 26 | github.com/davecgh/go-spew v1.1.1 // indirect 27 | github.com/dolthub/flatbuffers/v23 v23.3.3-dh.2 // indirect 28 | github.com/dolthub/go-icu-regex v0.0.0-20230524105445-af7e7991c97e // indirect 29 | github.com/dolthub/jsonpath v0.0.2-0.20240227200619-19675ab05c71 // indirect 30 | github.com/dolthub/vitess v0.0.0-20240312232959-8ee510931c7b // indirect 31 | github.com/go-kit/kit v0.10.0 // indirect 32 | github.com/golang/protobuf v1.5.3 // indirect 33 | github.com/hashicorp/golang-lru v0.5.4 // indirect 34 | github.com/lestrrat-go/strftime v1.0.4 // indirect 35 | github.com/lib/pq v1.10.0 // indirect 36 | github.com/mattn/go-sqlite3 v1.14.7 // indirect 37 | github.com/mfridman/interpolate v0.0.2 // indirect 38 | github.com/pkg/errors v0.9.1 // indirect 39 | github.com/pmezard/go-difflib v1.0.0 // indirect 40 | github.com/sethvargo/go-retry v0.2.4 // indirect 41 | github.com/shopspring/decimal v1.3.1 // indirect 42 | github.com/sirupsen/logrus v1.9.3 // indirect 43 | github.com/tetratelabs/wazero v1.1.0 // indirect 44 | go.opentelemetry.io/otel v1.20.0 // indirect 45 | go.opentelemetry.io/otel/trace v1.20.0 // indirect 46 | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect 47 | golang.org/x/mod v0.14.0 // indirect 48 | golang.org/x/sync v0.6.0 // indirect 49 | golang.org/x/tools v0.17.0 // indirect 50 | google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 // indirect 51 | google.golang.org/grpc v1.59.0 // indirect 52 | gopkg.in/src-d/go-errors.v1 v1.0.0 // indirect 53 | ) 54 | 55 | require ( 56 | github.com/a-h/templ v0.2.543 57 | github.com/bytedance/sonic v1.9.1 // indirect 58 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect 59 | github.com/dolthub/go-mysql-server v0.18.1-0.20240317073429-152477c4b580 60 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 61 | github.com/gin-contrib/sse v0.1.0 // indirect 62 | github.com/go-playground/locales v0.14.1 // indirect 63 | github.com/go-playground/universal-translator v0.18.1 // indirect 64 | github.com/go-playground/validator/v10 v10.14.0 65 | github.com/goccy/go-json v0.10.2 // indirect 66 | github.com/json-iterator/go v1.1.12 // indirect 67 | github.com/klauspost/cpuid/v2 v2.2.4 // indirect 68 | github.com/leodido/go-urn v1.2.4 // indirect 69 | github.com/mattn/go-isatty v0.0.20 // indirect 70 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 71 | github.com/modern-go/reflect2 v1.0.2 // indirect 72 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect 73 | github.com/stretchr/testify v1.9.0 74 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 75 | github.com/ugorji/go/codec v1.2.11 // indirect 76 | go.uber.org/atomic v1.10.0 // indirect 77 | go.uber.org/dig v1.17.0 // indirect 78 | go.uber.org/fx v1.20.1 79 | go.uber.org/multierr v1.11.0 // indirect 80 | go.uber.org/zap v1.24.0 // indirect 81 | golang.org/x/arch v0.3.0 // indirect 82 | golang.org/x/crypto v0.18.0 // indirect 83 | golang.org/x/net v0.20.0 // indirect 84 | golang.org/x/sys v0.16.0 // indirect 85 | golang.org/x/text v0.14.0 // indirect 86 | google.golang.org/protobuf v1.31.0 // indirect 87 | ) 88 | -------------------------------------------------------------------------------- /migrations/20240312064935_add_post_with_images.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | INSERT INTO posts(title, content, excerpt) VALUES( 4 | 'Gofiber Templ Htmx', 5 | '# Go/Fiber🧬+Templ to-do list app with user login and HTMx-powered frontend (Demo) 6 | 7 | A full-stack application using Golang´s Fiber framework with session-based authentication. Once we are authenticated we can enter a view from which we can manage a list of tasks (list, update and delete). Requests to the backend are controlled by [htmx](https://htmx.org/) ([hypermedia](https://hypermedia.systems/) only). 8 | 9 | ### Explanation 10 | 11 | The application allows us to perform a complete CRUD on the database, in this case SQLite3. 12 | 13 | The DB stores both a table with the users and another table for each user´s to-do. Both tables are related using a foreign key. 14 | 15 | >***In this application, instead of using the html/template package (gofiber/template/html, specifically), we use the [a-h/templ](https://github.com/a-h/templ) library. This amazing library implements a templating language (very similar to JSX) that compiles to Go code. Templ will allow us to write code almost identical to Go (with expressions, control flow, if/else, for loops, etc.) and have autocompletion thanks to strong typing. This means that errors appear at compile time and any calls to these templates (which are compiled as Go functions) from the handlers side will always require the correct data, minimizing errors and thus increasing the security and speed of our coding.*** 16 | 17 | The use of htmx allows behavior similar to that of a SPA, without page reloads when switching from one route to another or when making requests (via AJAX) to the backend. 18 | 19 | On the other hand, the styling of the views is achieved through Tailwind CSS and DaisyUI that are obtained from their respective CDNs. 20 | 21 | Finally, minimal use of [_hyperscript](https://hyperscript.org/) is made to achieve the action of closing the alerts when they are displayed. 22 | 23 | >***This application is identical to that of a previous [repository](https://github.com/emarifer/gofiber-htmx-todolist) of mine, which is developed in GoFiber-template/html instead of the [Templ](https://templ.guide/) templating language, as in this case.*** 24 | 25 | --- 26 | 27 | ## Screenshots: 28 | 29 | ###### Todo List Page with success alert: 30 | 31 | 32 | 33 |
34 | 35 | ###### Another App: 36 | 37 | 38 | 39 |
40 | 41 | ###### Task update page: 42 | 43 | 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 in go 1.22 70 | for range 50 { 71 | db, err := database.NewMariaDBConnection(ctx, &as) 72 | 73 | if err == nil { 74 | return db, nil 75 | } 76 | 77 | time.Sleep(25 * time.Millisecond) 78 | } 79 | 80 | return nil, fmt.Errorf("database did not start") 81 | } 82 | 83 | // GetAppSettings gets the settings for the http servers 84 | // taking into account a unique port. Very hacky way to 85 | // get a unique port: manually have to pass a new number 86 | // for every test... 87 | // TODO : Find a way to assign a unique port at compile time 88 | func GetAppSettings(appNum int) settings.AppSettings { 89 | 90 | appSettings := settings.AppSettings{ 91 | WebserverPort: 8080, 92 | DatabaseHost: "localhost", 93 | DatabasePort: 3336 + appNum, // initial port 94 | DatabaseUser: "root", 95 | DatabasePassword: "", 96 | DatabaseName: "cms_db", 97 | } 98 | 99 | return appSettings 100 | } 101 | 102 | // StartApp configures and starts the application to be 103 | // tested by returning a `*gin.Engine` but without attaching 104 | // an http.Server to it 105 | func StartApp( 106 | ctx context.Context, as settings.AppSettings, dbConn *sqlx.DB, 107 | ) (*gin.Engine, error) { 108 | 109 | repo := repository.New(dbConn) 110 | serv := service.New(repo) 111 | h := slog.NewJSONHandler(os.Stdout, nil) 112 | logger := slog.New(h) 113 | a := api.New(serv, logger, &as) 114 | 115 | cache := api.MakeCache(4, time.Minute*10) 116 | e, err := a.Start( 117 | gin.Default(), 118 | fmt.Sprintf(":%d", as.WebserverPort), 119 | &cache, 120 | ) 121 | return e, err 122 | } 123 | 124 | // StartAdminApp configures and starts the administration application 125 | // which will be tested by returning a `*gin.Engine` but without 126 | // attaching an http.Server 127 | func StartAdminApp( 128 | ctx context.Context, as settings.AppSettings, dbConn *sqlx.DB, 129 | ) (*gin.Engine, error) { 130 | 131 | repo := repository.New(dbConn) 132 | serv := service.New(repo) 133 | v := validator.New() 134 | a := admin_api.New(serv, v, &as) 135 | 136 | e, err := a.Start( 137 | gin.Default(), 138 | fmt.Sprintf(":%d", as.WebserverPort), 139 | ) 140 | return e, err 141 | } 142 | 143 | /* HOW DO I KNOW I'M RUNNING WITHIN "GO TEST". SEE: 144 | https://stackoverflow.com/questions/14249217/how-do-i-know-im-running-within-go-test#59444829 145 | */ 146 | 147 | /* PROBLEM WITH `memory.NewSessionBuilder`: HOW DO I GET THE MAIN BRANCH WITH GOLANG. SEE: 148 | https://stackoverflow.com/questions/42761820/how-to-get-another-branch-instead-of-default-branch-with-go-get 149 | https://www.youtube.com/live/rmgLKG4kmMw?si=8JeeZohJ5myot4yw&t=2524 150 | */ 151 | 152 | /* PROBLEM WITH `ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/var/run/mysqld/mysqld.sock'`. SEE: 153 | 154 | IT IS NECESSARY TO INSTALL THE MYSQL CLIENT FOR LINUX: 155 | sudo apt install mysql-client-core-8.0 156 | 157 | https://www.dailyrazor.com/blog/cant-connect-to-local-mysql-server-through-socket/ 158 | 159 | SOLUTION: 160 | sudo mysql -h 127.0.0.1 -u root -P3336 161 | OR: 162 | mariadb -h 127.0.0.1 -u root -P3336 163 | */ 164 | 165 | /* EXAMPLE. SEE: 166 | https://github.com/dolthub/go-mysql-server 167 | https://docs.dolthub.com/sql-reference/sql-support 168 | 169 | This is an example of how to implement a MySQL server. 170 | After running the example, you may connect to it using the following: 171 | 172 | > mysql --host=localhost --port=3306 --user=root mydb --execute="SELECT * FROM mytable;" 173 | +----------+-------------------+-------------------------------+----------------------------+ 174 | | name | email | phone_numbers | created_at | 175 | +----------+-------------------+-------------------------------+----------------------------+ 176 | | Jane Deo | janedeo@gmail.com | ["556-565-566","777-777-777"] | 2022-11-01 12:00:00.000001 | 177 | | Jane Doe | jane@doe.com | [] | 2022-11-01 12:00:00.000001 | 178 | | John Doe | john@doe.com | ["555-555-555"] | 2022-11-01 12:00:00.000001 | 179 | | John Doe | johnalt@doe.com | [] | 2022-11-01 12:00:00.000001 | 180 | +----------+-------------------+-------------------------------+----------------------------+ 181 | 182 | The included MySQL client is used in this example, however any MySQL-compatible client will work. 183 | */ 184 | -------------------------------------------------------------------------------- /internal/admin_app/api/posts.handlers.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/emarifer/gocms/internal/admin_app/api/dto" 11 | "github.com/emarifer/gocms/internal/entity" 12 | "github.com/emarifer/gocms/internal/model" 13 | "github.com/gin-gonic/gin" 14 | ) 15 | 16 | func (a *API) getPostsHandler(c *gin.Context) { 17 | ctx := c.Request.Context() 18 | 19 | posts, err := a.serv.RecoverPosts(ctx) 20 | if err != nil { 21 | if re.MatchString(err.Error()) { 22 | customError := NewCustomError( 23 | http.StatusInternalServerError, 24 | err.Error(), 25 | "An unexpected condition was encountered.", 26 | ) 27 | c.Error(customError) 28 | 29 | return 30 | } 31 | } 32 | 33 | pps := []model.PostSummary{} 34 | for _, post := range posts { 35 | ps := model.PostSummary{ 36 | ID: post.ID, 37 | Title: post.Title, 38 | Excerpt: post.Excerpt, 39 | } 40 | pps = append(pps, ps) 41 | } 42 | 43 | c.JSON(http.StatusOK, gin.H{ 44 | "post": pps, 45 | }) 46 | } 47 | 48 | func (a *API) postHandler(c *gin.Context) { 49 | ctx := c.Request.Context() 50 | postBinding := &dto.PostBinding{} 51 | 52 | // localhost:8080/post/{id} 53 | if err := c.ShouldBindUri(postBinding); err != nil { 54 | customError := NewCustomError( 55 | http.StatusBadRequest, 56 | err.Error(), 57 | "could not get post id", 58 | ) 59 | c.Error(customError) 60 | 61 | return 62 | } 63 | 64 | // Get the post with the ID 65 | 66 | postId, err := strconv.Atoi(postBinding.Id) 67 | if err != nil { 68 | customError := NewCustomError( 69 | http.StatusBadRequest, 70 | err.Error(), 71 | "invalid post id type", 72 | ) 73 | c.Error(customError) 74 | 75 | return 76 | } 77 | 78 | post, err := a.serv.RecoverPost(ctx, postId) 79 | if err != nil { 80 | if re.MatchString(err.Error()) { 81 | customError := NewCustomError( 82 | http.StatusInternalServerError, 83 | err.Error(), 84 | "An unexpected condition was encountered.", 85 | ) 86 | c.Error(customError) 87 | 88 | return 89 | } 90 | 91 | if strings.Contains(err.Error(), "no rows in result set") { 92 | customError := NewCustomError( 93 | http.StatusNotFound, 94 | err.Error(), 95 | "The requested resource could not be found but may be available again in the future.", 96 | ) 97 | c.Error(customError) 98 | 99 | return 100 | } 101 | } 102 | 103 | c.JSON(http.StatusOK, gin.H{ 104 | "id": post.ID, 105 | "title": post.Title, 106 | "excerpt": post.Excerpt, 107 | "content": post.Content, 108 | "created_at": post.CreatedAt, 109 | }) 110 | } 111 | 112 | func (a *API) addPostHandler(c *gin.Context) { 113 | ctx := c.Request.Context() 114 | addPostBinding := &dto.AddPostRequest{} 115 | 116 | if c.Request.Body == nil { 117 | customError := NewCustomError( 118 | http.StatusBadRequest, 119 | "invalid request", 120 | "null request body", 121 | ) 122 | c.Error(customError) 123 | 124 | return 125 | } 126 | 127 | decoder := json.NewDecoder(c.Request.Body) 128 | err := decoder.Decode(addPostBinding) 129 | if err != nil { 130 | customError := NewCustomError( 131 | http.StatusBadRequest, 132 | err.Error(), 133 | "invalid request body", 134 | ) 135 | c.Error(customError) 136 | 137 | return 138 | } 139 | 140 | // Validating request body data 141 | if errors := a.validateStruct(*addPostBinding); errors != nil { 142 | errMsgs := []string{} 143 | for _, err := range errors { 144 | errStr := fmt.Sprintf("%#v", err) 145 | errMsgs = append(errMsgs, errStr) 146 | } 147 | customError := NewCustomError( 148 | http.StatusBadRequest, 149 | strings.Join(errMsgs, "\n"), 150 | "errors occurred while validating request body", 151 | ) 152 | c.Error(customError) 153 | 154 | return 155 | } 156 | 157 | // Getting shortcodeHandlers 158 | shortcodeHandlers, err := a.loadShortcodeHandlers() 159 | if err != nil { 160 | customError := NewCustomError( 161 | http.StatusInternalServerError, 162 | err.Error(), 163 | "the server encountered an unexpected condition that prevented add the post", 164 | ) 165 | c.Error(customError) 166 | 167 | return 168 | } 169 | 170 | transformedContent, err := a.transformContent( 171 | addPostBinding.Content, shortcodeHandlers, 172 | ) 173 | if err != nil { 174 | customError := NewCustomError( 175 | http.StatusInternalServerError, 176 | err.Error(), 177 | "the server encountered an unexpected condition that prevented add the post", 178 | ) 179 | c.Error(customError) 180 | 181 | return 182 | } 183 | 184 | post := &entity.Post{ 185 | Title: addPostBinding.Title, 186 | Excerpt: addPostBinding.Excerpt, 187 | Content: transformedContent, 188 | } 189 | 190 | id, err := a.serv.CreatePost(ctx, post) 191 | if err != nil { 192 | customError := NewCustomError( 193 | http.StatusInternalServerError, 194 | err.Error(), 195 | "the server encountered an unexpected condition that prevented add the post", 196 | ) 197 | c.Error(customError) 198 | 199 | return 200 | } 201 | 202 | c.JSON(http.StatusCreated, gin.H{ 203 | "id": id, 204 | }) 205 | } 206 | 207 | func (a *API) updatePostHandler(c *gin.Context) { 208 | ctx := c.Request.Context() 209 | updatePostRequest := &dto.UpdatePostRequest{} 210 | decoder := json.NewDecoder(c.Request.Body) 211 | decoder.DisallowUnknownFields() 212 | 213 | if err := decoder.Decode(updatePostRequest); err != nil { 214 | customError := NewCustomError( 215 | http.StatusBadRequest, 216 | err.Error(), 217 | "invalid request body", 218 | ) 219 | c.Error(customError) 220 | 221 | return 222 | } 223 | 224 | post := &entity.Post{ 225 | ID: updatePostRequest.ID, 226 | Title: updatePostRequest.Title, 227 | Excerpt: updatePostRequest.Excerpt, 228 | Content: updatePostRequest.Content, 229 | } 230 | 231 | rowsAffected, err := a.serv.ChangePost(ctx, post) 232 | if err != nil { 233 | // fmt.Println(err) 234 | if re.MatchString(err.Error()) { 235 | customError := NewCustomError( 236 | http.StatusInternalServerError, 237 | err.Error(), 238 | "An unexpected condition was encountered", 239 | ) 240 | c.Error(customError) 241 | 242 | return 243 | } 244 | } 245 | 246 | if rowsAffected == -1 { 247 | customError := NewCustomError( 248 | http.StatusNotFound, 249 | "no rows in result set", 250 | "could not change post", 251 | ) 252 | c.Error(customError) 253 | 254 | return 255 | } 256 | 257 | c.JSON(http.StatusOK, gin.H{ 258 | "rowsAffected": rowsAffected, 259 | }) 260 | } 261 | 262 | func (a *API) deletePostHandler(c *gin.Context) { 263 | ctx := c.Request.Context() 264 | postBinding := &dto.PostBinding{} 265 | 266 | if err := c.ShouldBindUri(postBinding); err != nil { 267 | customError := NewCustomError( 268 | http.StatusBadRequest, 269 | err.Error(), 270 | "could not get post id", 271 | ) 272 | c.Error(customError) 273 | 274 | return 275 | } 276 | 277 | // Remove the post with the ID 278 | 279 | postId, err := strconv.Atoi(postBinding.Id) 280 | if err != nil { 281 | customError := NewCustomError( 282 | http.StatusBadRequest, 283 | err.Error(), 284 | "invalid post id type", 285 | ) 286 | c.Error(customError) 287 | 288 | return 289 | } 290 | 291 | rowsAffected, err := a.serv.RemovePost(ctx, postId) 292 | if err != nil { 293 | if re.MatchString(err.Error()) { 294 | customError := NewCustomError( 295 | http.StatusInternalServerError, 296 | err.Error(), 297 | "An unexpected condition was encountered", 298 | ) 299 | c.Error(customError) 300 | 301 | return 302 | } 303 | } 304 | 305 | if rowsAffected == -1 { 306 | customError := NewCustomError( 307 | http.StatusNotFound, 308 | "no rows in result set", 309 | "could not delete post", 310 | ) 311 | c.Error(customError) 312 | 313 | return 314 | } 315 | 316 | c.JSON(http.StatusOK, gin.H{ 317 | "rowsAffected": rowsAffected, 318 | }) 319 | } 320 | -------------------------------------------------------------------------------- /migrations/20240219101315_add_readme_github_markdown_example.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | INSERT INTO posts(title, content, excerpt) VALUES( 4 | 'Second post with Markdown', 5 | 6 | '
7 | 8 |

Shortify

9 | 10 | 11 | 12 |
13 | 14 |

15 | 16 | A Full Stack Url Shortener App using Golang\'s Echo framework + HTMX & Templ. 17 | 18 |

19 | 20 |
21 | 22 |
23 | 24 | ## 🤔 What Stack have we used? 25 | 26 | In the implementation of this application we have used the following technologies: 27 | 28 | - ✅ **Bootstrapping**: [Go programming language (v1.21)](https://go.dev/) 29 | - ✅ **Backend Framework**: [Echo v4.11.4 ](https://echo.labstack.com/) 30 | - ✅ **Auth & Session middleware**: [Echo Contrib Session](https://github.com/labstack/echo-contrib/) 31 | - ✅ **Dependency Injection**: [Fx - dependency injection system for Go](https://uber-go.github.io/fx/) 32 | - ✅ **Database**: [PostgreSQL](https://www.postgresql.org/) 33 | - ✅ **Styling**: [TailwindCSS + DaisyUI](https://tailwindcss.com) 34 | - ✅ **Frontend interactivity**: [Htmx + _Hyperscript](https://htmx.org/) 35 | - ✅ **Templating Language**: [Templ - build HTML with Go](https://templ.guide/) 36 | - ✅ **Popup Boxes (Alerts)**: [Sweetalert2 - responsive & customizable replacement for JavaScript\'s popup boxes](https://sweetalert2.github.io/) 37 | 38 |
39 | 40 | 41 | >***The use of [htmx](https://htmx.org/) allows behavior similar to that of a SPA, without page reloads when switching from one route to another or when making requests (via AJAX) to the backend. Likewise, the [_hyperscript](https://hyperscript.org/) library allows you to add some dynamic features to the frontend in a very easy way.*** 42 | 43 |
44 | 45 | 46 | >***In this application, instead of using the [html/template](https://pkg.go.dev/html/template) package (native Golang templates), we use the [a-h/templ](https://github.com/a-h/templ) library. This amazing library implements a templating language (very similar to JSX) that compiles to Go code. Templ will allow us to write code almost identical to Go (with expressions, control flow, if/else, for loops, etc.) and have autocompletion thanks to strong typing. This means that errors appear at compile time and any calls to these templates (which are compiled as Go functions) from the handlers side will always require the correct data, minimizing errors and thus increasing the security and speed of our coding.*** 47 | 48 | --- 49 | 50 | ## 🖼️ Screenshots: 51 | 52 |
53 | 54 |
Home & Login Pages with success alert:
55 | 56 |      57 | 58 |
Short link creator Page and Dashboard Page with alerts:
59 | 60 |      61 | 62 |
Dashboard Page with alert and Short Link Update Modal:
63 | 64 |      65 | 66 |
Centralized HTTP error handling:
67 | 68 | 69 | 70 |
71 | 72 | --- 73 | 74 | ## 📦 Project structure 75 | 76 | ``` 77 | - assets 78 | |- css 79 | |- img 80 | - database 81 | - encryption 82 | - internal 83 | |- api 84 | |- dto 85 | |- entity 86 | |- model 87 | |- repository 88 | |- service 89 | - postgresdb_init 90 | - settings 91 | - timezone_conversion 92 | - views 93 | |- auth_views 94 | |- components 95 | |- errors_pages 96 | |- layout 97 | |- links_views 98 | ``` 99 | 100 |
101 | 102 | 103 | 104 |
105 | 106 | 107 | >***In this application, we have tried to apply a [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) pattern. The architecture follows a typical "onion model" where each layer doesn\'t know about the layer above it, and each layer is responsible for a specific thing. Although the application is extremely simple, we use this pattern to illustrate its use in more complex applications. Layering an application in this way can simplify code structure, since the responsibility of each type is clear. To ensure that each part of the application is initialized with its dependencies, each struct defines a constructor (the New function in this example). Related to the latter, we have used a dependency injector ([Fx](https://uber-go.github.io/fx/) from uber-go), which helps to remove the global state in the application and add new components and have them instantly accessible across the application.*** 108 | 109 |
110 | 111 | >***When applying this approach in a real-life application, as with most things, taking the layering approach to an extreme level can have a negative effect. Ask yourself if what you are doing actually helps make the code understandable or simply spreads the application logic across many files and makes the overall structure difficult to see.*** 112 | 113 | 114 | ## 👨‍🚀 Getting Started 115 | 116 | Besides the obvious prerequisite of having Go! on your machine, you must have Air installed for hot reloading when editing code. 117 | 118 | Since we use the PostgreSQL database from a Docker container, it is necessary to have the latter also installed and execute this command in the project folder: 119 | 120 | ``` 121 | $ docker compose up -d 122 | ``` 123 | 124 | These other commands will also be useful to manage the database from its container: 125 | 126 | ``` 127 | $ docker container start shortify-db # start container 128 | $ docker container stop shortify-db # stop container 129 | $ docker exec -it shortify-db psql -U admin -W shortify_db # (pass: admin) access the database 130 | ``` 131 | 132 | Download the necessary dependencies: 133 | 134 | ``` 135 | $ go mod tidy 136 | ``` 137 | 138 | Start the app in development mode: 139 | 140 | ``` 141 | $ air # Ctrl + C to stop the application 142 | ``` 143 | 144 | Build for production: 145 | 146 | ``` 147 | $ go build -ldflags="-s -w" -o ./bin/main . # ./bin/main to run the application / Ctrl + C to stop the application 148 | ``` 149 | 150 | 151 | >***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:*** 152 | 153 | ``` 154 | $ templ generate --watch 155 | ``` 156 | 157 | >***This will allow us to monitor changes to the .templ files and compile them as we save them. 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.*** 158 | 159 | --- 160 | 161 | ### Happy coding 😀!!', 162 | 'Example post of markdown rendering with its own syntax from the GitHub README.md file.' 163 | ); 164 | -- +goose StatementEnd 165 | 166 | -- +goose Down 167 | -- +goose StatementBegin 168 | DELETE FROM posts ORDER BY id DESC LIMIT 1; 169 | -- +goose StatementEnd 170 | -------------------------------------------------------------------------------- /assets/css/simple.min.css: -------------------------------------------------------------------------------- 1 | ::backdrop,:root{--sans-font:-apple-system,BlinkMacSystemFont,"Avenir Next",Avenir,"Nimbus Sans L",Roboto,"Noto Sans","Segoe UI",Arial,Helvetica,"Helvetica Neue",sans-serif;--mono-font:Consolas,Menlo,Monaco,"Andale Mono","Ubuntu Mono",monospace;--standard-border-radius:5px;--bg:#fff;--accent-bg:#f5f7ff;--text:#212121;--text-light:#585858;--border:#898EA4;--accent:#0d47a1;--accent-hover:#1266e2;--accent-text:var(--bg);--code:#d81b60;--preformatted:#444;--marked:#ffdd33;--disabled:#efefef}@media (prefers-color-scheme:dark){::backdrop,:root{color-scheme:dark;--bg:#212121;--accent-bg:#2b2b2b;--text:#dcdcdc;--text-light:#ababab;--accent:#ffb300;--accent-hover:#ffe099;--accent-text:var(--bg);--code:#f06292;--preformatted:#ccc;--disabled:#111}img,video{opacity:.8}}*,::after,::before{box-sizing:border-box}input,progress,select,textarea{appearance:none;-webkit-appearance:none;-moz-appearance:none}html{font-family:var(--sans-font);scroll-behavior:smooth}body{color:var(--text);background-color:var(--bg);font-size:1.15rem;line-height:1.5;display:grid;grid-template-columns:1fr min(45rem,90%) 1fr;margin:0}body>*{grid-column:2}body>header{background-color:var(--accent-bg);border-bottom:1px solid var(--border);text-align:center;padding:0 .5rem 2rem .5rem;grid-column:1/-1}body>header>:only-child{margin-block-start:2rem}body>header h1{max-width:1200px;margin:1rem auto}body>header p{max-width:40rem;margin:1rem auto}main{padding-top:1.5rem}body>footer{margin-top:4rem;padding:2rem 1rem 1.5rem 1rem;color:var(--text-light);font-size:.9rem;text-align:center;border-top:1px solid var(--border)}h1{font-size:3rem}h2{font-size:2.6rem;margin-top:3rem}h3{font-size:2rem;margin-top:3rem}h4{font-size:1.44rem}h5{font-size:1.15rem}h6{font-size:.96rem}p{margin:1.5rem 0}h1,h2,h3,h4,h5,h6,p{overflow-wrap:break-word}h1,h2,h3{line-height:1.1}@media only screen and (max-width:720px){h1{font-size:2.5rem}h2{font-size:2.1rem}h3{font-size:1.75rem}h4{font-size:1.25rem}}a,a:visited{color:var(--accent)}a:hover{text-decoration:none}.button,a.button,button,input[type=button],input[type=reset],input[type=submit],label[type=button]{border:1px solid var(--accent);background-color:var(--accent);color:var(--accent-text);padding:.5rem .9rem;text-decoration:none;line-height:normal}.button[aria-disabled=true],button[disabled],input:disabled,select:disabled,textarea:disabled{cursor:not-allowed;background-color:var(--disabled);border-color:var(--disabled);color:var(--text-light)}input[type=range]{padding:0}abbr[title]{cursor:help;text-decoration-line:underline;text-decoration-style:dotted}.button:not([aria-disabled=true]):hover,button:enabled:hover,input[type=button]:enabled:hover,input[type=reset]:enabled:hover,input[type=submit]:enabled:hover,label[type=button]:hover{background-color:var(--accent-hover);border-color:var(--accent-hover);cursor:pointer}.button:focus-visible,button:focus-visible:where(:enabled),input:enabled:focus-visible:where([type=submit],[type=reset],[type=button]){outline:2px solid var(--accent);outline-offset:1px}header>nav{font-size:1rem;line-height:2;padding:1rem 0 0 0}header>nav ol,header>nav ul{align-content:space-around;align-items:center;display:flex;flex-direction:row;flex-wrap:wrap;justify-content:center;list-style-type:none;margin:0;padding:0}header>nav ol li,header>nav ul li{display:inline-block}header>nav a,header>nav a:visited{margin:0 .5rem 1rem .5rem;border:1px solid var(--border);border-radius:var(--standard-border-radius);color:var(--text);display:inline-block;padding:.1rem 1rem;text-decoration:none}header>nav a.current,header>nav a:hover,header>nav a[aria-current=page]{border-color:var(--accent);color:var(--accent);cursor:pointer}@media only screen and (max-width:720px){header>nav a{border:none;padding:0;text-decoration:underline;line-height:1}}aside,details,pre,progress{background-color:var(--accent-bg);border:1px solid var(--border);border-radius:var(--standard-border-radius);margin-bottom:1rem}aside{font-size:1rem;width:30%;padding:0 15px;margin-inline-start:15px;float:right}[dir=rtl] aside{float:left}@media only screen and (max-width:720px){aside{width:100%;float:none;margin-inline-start:0}}article,dialog,fieldset{border:1px solid var(--border);padding:1rem;border-radius:var(--standard-border-radius);margin-bottom:1rem}article h2:first-child,section h2:first-child{margin-top:1rem}section{border-top:1px solid var(--border);border-bottom:1px solid var(--border);padding:2rem 1rem;margin:3rem 0}section+section,section:first-child{border-top:0;padding-top:0}section:last-child{border-bottom:0;padding-bottom:0}details{padding:.7rem 1rem}summary{cursor:pointer;font-weight:700;padding:.7rem 1rem;margin:-.7rem -1rem;word-break:break-all}details[open]>summary+*{margin-top:0}details[open]>summary{margin-bottom:.5rem}details[open]>:last-child{margin-bottom:0}table{border-collapse:collapse;margin:1.5rem 0}figure>table{width:max-content}td,th{border:1px solid var(--border);text-align:start;padding:.5rem}th{background-color:var(--accent-bg);font-weight:700}tr:nth-child(even){background-color:var(--accent-bg)}table caption{font-weight:700;margin-bottom:.5rem}.button,button,input,select,textarea{font-size:inherit;font-family:inherit;padding:.5rem;margin-bottom:.5rem;border-radius:var(--standard-border-radius);box-shadow:none;max-width:100%;display:inline-block}input,select,textarea{color:var(--text);background-color:var(--bg);border:1px solid var(--border)}label{display:block}textarea:not([cols]){width:100%}select:not([multiple]){background-image:linear-gradient(45deg,transparent 49%,var(--text) 51%),linear-gradient(135deg,var(--text) 51%,transparent 49%);background-position:calc(100% - 15px),calc(100% - 10px);background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-inline-end:25px}[dir=rtl] select:not([multiple]){background-position:10px,15px}input[type=checkbox],input[type=radio]{vertical-align:middle;position:relative;width:min-content}input[type=checkbox]+label,input[type=radio]+label{display:inline-block}input[type=radio]{border-radius:100%}input[type=checkbox]:checked,input[type=radio]:checked{background-color:var(--accent)}input[type=checkbox]:checked::after{content:" ";width:.18em;height:.32em;border-radius:0;position:absolute;top:.05em;left:.17em;background-color:transparent;border-right:solid var(--bg) .08em;border-bottom:solid var(--bg) .08em;font-size:1.8em;transform:rotate(45deg)}input[type=radio]:checked::after{content:" ";width:.25em;height:.25em;border-radius:100%;position:absolute;top:.125em;background-color:var(--bg);left:.125em;font-size:32px}@media only screen and (max-width:720px){input,select,textarea{width:100%}}input[type=color]{height:2.5rem;padding:.2rem}input[type=file]{border:0}hr{border:none;height:1px;background:var(--border);margin:1rem auto}mark{padding:2px 5px;border-radius:var(--standard-border-radius);background-color:var(--marked);color:#000}mark a{color:#0d47a1}img,video{max-width:100%;height:auto;border-radius:var(--standard-border-radius)}figure{margin:0;display:block;overflow-x:auto}figcaption{text-align:center;font-size:.9rem;color:var(--text-light);margin-bottom:1rem}blockquote{margin-inline-start:2rem;margin-inline-end:0;margin-block:2rem;padding:.4rem .8rem;border-inline-start:.35rem solid var(--accent);color:var(--text-light);font-style:italic}cite{font-size:.9rem;color:var(--text-light);font-style:normal}dt{color:var(--text-light)}code,kbd,pre,pre span,samp{font-family:var(--mono-font);color:var(--code)}kbd{color:var(--preformatted);border:1px solid var(--preformatted);border-bottom:3px solid var(--preformatted);border-radius:var(--standard-border-radius);padding:.1rem .4rem}pre{padding:1rem 1.4rem;max-width:100%;overflow:auto;color:var(--preformatted)}pre code{color:var(--preformatted);background:0 0;margin:0;padding:0}progress{width:100%}progress:indeterminate{background-color:var(--accent-bg)}progress::-webkit-progress-bar{border-radius:var(--standard-border-radius);background-color:var(--accent-bg)}progress::-webkit-progress-value{border-radius:var(--standard-border-radius);background-color:var(--accent)}progress::-moz-progress-bar{border-radius:var(--standard-border-radius);background-color:var(--accent);transition-property:width;transition-duration:.3s}progress:indeterminate::-moz-progress-bar{background-color:var(--accent-bg)}dialog{max-width:40rem;margin:auto}dialog::backdrop{background-color:var(--bg);opacity:.8}@media only screen and (max-width:720px){dialog{max-width:100%;margin:auto 1em}}sub,sup{vertical-align:baseline;position:relative}sup{top:-.4em}sub{top:.3em}.notice{background:var(--accent-bg);border:2px solid var(--border);border-radius:var(--standard-border-radius);padding:1.5rem;margin:2rem 0} -------------------------------------------------------------------------------- /internal/admin_app/api/api .go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/emarifer/gocms/internal/service" 10 | "github.com/emarifer/gocms/settings" 11 | "github.com/gin-contrib/gzip" 12 | "github.com/gin-gonic/gin" 13 | "github.com/go-playground/validator/v10" 14 | lua "github.com/yuin/gopher-lua" 15 | ) 16 | 17 | type API struct { 18 | serv service.Service 19 | dataValidator *validator.Validate 20 | settings *settings.AppSettings 21 | } 22 | 23 | type ErrorResponse struct { 24 | Field string `json:"field"` 25 | Tag string `json:"tag"` 26 | Value string `json:"value,omitempty"` 27 | } 28 | 29 | func New( 30 | serv service.Service, 31 | dataValidator *validator.Validate, 32 | settings *settings.AppSettings, 33 | ) *API { 34 | 35 | return &API{ 36 | serv: serv, 37 | dataValidator: dataValidator, 38 | settings: settings, 39 | } 40 | } 41 | 42 | var re = regexp.MustCompile(`Table|refused`) 43 | 44 | // Find all the occurences of {{ and }} (including whitespace) 45 | var shortcodesFound = regexp.MustCompile(`{{[\s\w.-]+(:[\s\w.-]+)+}}`) 46 | 47 | func (a *API) Start(e *gin.Engine, address string) (*gin.Engine, error) { 48 | e.Use(gzip.Gzip(gzip.DefaultCompression)) // gzip compression middleware 49 | e.Use(a.globalErrorHandler()) // Error handler middleware 50 | e.MaxMultipartMemory = 1 // 8 MiB max. request 51 | 52 | a.registerRoutes(e) 53 | 54 | // SEE NOTE BELOW (this is hacky): 55 | if os.Getenv("GO_ENV") == "testing" { 56 | 57 | return e, nil 58 | } else { 59 | 60 | return nil, e.Run(address) 61 | } 62 | } 63 | 64 | func (a *API) registerRoutes(e *gin.Engine) { 65 | v1 := e.Group("api/v1") 66 | v1.POST("/post", a.addPostHandler) 67 | v1.GET("/post", a.getPostsHandler) 68 | v1.GET("/post/:id", a.postHandler) 69 | v1.PUT("/post", a.updatePostHandler) 70 | v1.DELETE("/post/:id", a.deletePostHandler) 71 | 72 | v1.GET("/image/:uuid", a.getImageHandler) 73 | v1.GET("/image", a.getAllImagesHandler) 74 | v1.POST("image", a.addImageHandler) 75 | v1.DELETE("/image/:uuid", a.deleteImageHandler) 76 | 77 | /* e.GET("/contact", a.contactHandler) 78 | e.POST("/contact", a.contactHandler) */ 79 | } 80 | 81 | func (a *API) validateStruct(payload any) []ErrorResponse { // SEE NOTE BELOW 82 | var errors []ErrorResponse 83 | 84 | err := a.dataValidator.Struct(payload) 85 | if err != nil { 86 | for _, err := range err.(validator.ValidationErrors) { 87 | var element ErrorResponse 88 | element.Field = err.StructNamespace() 89 | element.Tag = err.Tag() 90 | element.Value = err.Param() 91 | errors = append(errors, element) 92 | } 93 | } 94 | 95 | return errors 96 | } 97 | 98 | func (a *API) loadShortcodeHandlers() (map[string]*lua.LState, error) { 99 | shortcodeHandlers := map[string]*lua.LState{} 100 | 101 | for _, shortcode := range a.settings.Shortcodes { 102 | // Read the LState (Lua state) 103 | state := lua.NewState() 104 | err := state.DoFile(shortcode.Plugin) 105 | // TODO : check that the function HandleShortcode(args) 106 | // exists and returns the correct type 107 | if err != nil { 108 | return shortcodeHandlers, fmt.Errorf( 109 | "could not load shortcode %s: %v", shortcode.Name, err, 110 | ) 111 | } 112 | shortcodeHandlers[shortcode.Name] = state 113 | } 114 | 115 | return shortcodeHandlers, nil 116 | } 117 | 118 | // partitionString will partition the strings by 119 | // removing the given ranges 120 | func (a *API) partitionString(text string, indexes [][]int) []string { 121 | partitions := []string{} 122 | start := 0 123 | 124 | for _, window := range indexes { 125 | partitions = append(partitions, text[start:window[0]]) 126 | start = window[1] 127 | } 128 | 129 | partitions = append(partitions, text[start:len(text)-1]) 130 | 131 | return partitions 132 | } 133 | 134 | func (a *API) shortcodeToMarkdown( 135 | shortcode string, shortcodeHandlers map[string]*lua.LState, 136 | ) (string, error) { 137 | keyValue := strings.Split(shortcode, ":") 138 | key := keyValue[0] 139 | values := keyValue[1:] 140 | 141 | /* if key == "img" { 142 | if len(values) == 1 { 143 | imageSrc := fmt.Sprintf("/media/%s", values[0]) 144 | 145 | return fmt.Sprintf("![image](%s)", imageSrc), nil 146 | } else if len(values) == 2 { 147 | imageSrc := fmt.Sprintf("/media/%s", values[0]) 148 | altText := values[1] 149 | 150 | return fmt.Sprintf("![%s](%s)", altText, imageSrc), nil 151 | } else { 152 | return "", fmt.Errorf("invalid shortcode: %s", shortcode) 153 | } 154 | } */ 155 | 156 | if handler, found := shortcodeHandlers[key]; found { 157 | // Need to quote all values for a valid lua syntax 158 | quotedValues := []string{} 159 | for _, value := range values { 160 | quotedValues = append(quotedValues, fmt.Sprintf("%q", value)) 161 | } 162 | 163 | err := handler.DoString(fmt.Sprintf( 164 | `result = HandleShortcode({%s})`, strings.Join(quotedValues, ","), 165 | )) 166 | if err != nil { 167 | return "", fmt.Errorf("error running %s shortcode: %v", key, err) 168 | } 169 | 170 | value := handler.GetGlobal("result") 171 | if retType := value.Type().String(); retType != "string" { 172 | return "", fmt.Errorf( 173 | "error running %s shortcode: invalid return type %s", key, retType, 174 | ) 175 | } else if retType == "" { 176 | return "", fmt.Errorf( 177 | "error running %s shortcode: returned empty string", key, 178 | ) 179 | } 180 | 181 | return value.String(), nil 182 | } 183 | 184 | return "", fmt.Errorf("unsupported shortcode: %s", key) 185 | } 186 | 187 | func (a *API) transformContent( 188 | content string, shortcodeHandlers map[string]*lua.LState, 189 | ) (string, error) { 190 | // see note below 191 | shortcodes := shortcodesFound.FindAllStringIndex(content, -1) 192 | // content without shortcodes 193 | partitions := a.partitionString(content, shortcodes) 194 | 195 | builder := strings.Builder{} 196 | // i := 0 197 | for i, shortcode := range shortcodes { 198 | builder.WriteString(partitions[i]) 199 | 200 | // +2 is added, or -2 is regressed, due to the double curly brackets 201 | md, err := a.shortcodeToMarkdown( 202 | content[shortcode[0]+2:shortcode[1]-2], shortcodeHandlers, 203 | ) 204 | if err != nil { 205 | return "", fmt.Errorf("could not transform post: %v", err) 206 | } 207 | 208 | builder.WriteString(md) 209 | } 210 | 211 | // Guaranteed to have +1 than the number of 212 | // shortcodes by algorithm 213 | if len(shortcodes) > 0 { 214 | builder.WriteString(partitions[len(partitions)-1]) 215 | } else { 216 | builder.WriteString(content) 217 | } 218 | 219 | return builder.String(), nil 220 | } 221 | 222 | /* HOW DO I KNOW I'M RUNNING WITHIN "GO TEST". SEE: 223 | https://stackoverflow.com/questions/14249217/how-do-i-know-im-running-within-go-test#59444829 224 | */ 225 | 226 | /* 227 | // Markdown to HTML conversion 228 | func (a *API) mdToHTML(md []byte) []byte { 229 | // create markdown parser with extensions 230 | extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock 231 | p := parser.NewWithExtensions(extensions) 232 | doc := p.Parse(md) 233 | 234 | // create HTML renderer with extensions 235 | htmlFlags := html.CommonFlags | html.HrefTargetBlank 236 | opts := html.RendererOptions{Flags: htmlFlags} 237 | renderer := html.NewRenderer(opts) 238 | 239 | return markdown.Render(doc, renderer) 240 | } 241 | 242 | // This function will render the templ component into 243 | // a gin context's Response Writer 244 | func (a *API) renderView( 245 | c *gin.Context, status int, cmp templ.Component, 246 | ) error { 247 | c.Status(status) 248 | 249 | return cmp.Render(c.Request.Context(), c.Writer) 250 | } 251 | */ 252 | 253 | /* FINDING THE INDEX OF ALL OCCURRENCES OF A REGULAR EXPRESSION IN A STRING. SEE: 254 | https://www.tutorialspoint.com/finding-index-of-the-regular-expression-present-in-string-of-golang 255 | */ 256 | 257 | /* NO PARAMETERIZED METHODS. SEE: 258 | https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md#No-parameterized-methods 259 | 260 | JSON Escape / Unescape Online. SEE: 261 | https://www.freeformatter.com/json-escape.html#before-output 262 | 263 | What HTTP status code should I return for POST when no resource is created?. SEE: 264 | https://stackoverflow.com/questions/55685576/what-http-status-code-should-i-return-for-post-when-no-resource-is-created 265 | */ 266 | -------------------------------------------------------------------------------- /internal/admin_app/api/images.handlers.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | "path/filepath" 8 | "slices" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/emarifer/gocms/internal/admin_app/api/dto" 13 | "github.com/emarifer/gocms/internal/entity" 14 | "github.com/gin-gonic/gin" 15 | "github.com/google/uuid" 16 | ) 17 | 18 | func (a *API) addImageHandler(c *gin.Context) { 19 | c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 10*1000000) 20 | ctx := c.Request.Context() 21 | 22 | mForm, err := c.MultipartForm() 23 | if err != nil { 24 | customError := NewCustomError( 25 | http.StatusInternalServerError, 26 | err.Error(), 27 | "could not create multipart form", 28 | ) 29 | c.Error(customError) 30 | 31 | return 32 | } 33 | 34 | alt_text_array := mForm.Value["alt"] 35 | alt_text := "unknown" 36 | if len(alt_text_array) > 0 { 37 | alt_text = alt_text_array[0] 38 | } 39 | 40 | /* addImageBinding := &dto.AddImageRequest{} 41 | 42 | if err := c.ShouldBind(addImageBinding); err != nil { 43 | customError := NewCustomError( 44 | http.StatusBadRequest, 45 | err.Error(), 46 | "invalid request body", 47 | ) 48 | c.Error(customError) 49 | 50 | return 51 | } 52 | 53 | alt_text := addImageBinding.Alt */ 54 | 55 | // Begin saving the file to the filesystem 56 | file_array := mForm.File["file"] 57 | if len(file_array) == 0 { 58 | customError := NewCustomError( 59 | http.StatusBadRequest, 60 | "error: could not get the file array", 61 | "could not get the file array", 62 | ) 63 | c.Error(customError) 64 | 65 | return 66 | } 67 | file := file_array[0] 68 | if file == nil { 69 | customError := NewCustomError( 70 | http.StatusBadRequest, 71 | "error: could not upload file", 72 | "could not upload file", 73 | ) 74 | c.Error(customError) 75 | 76 | return 77 | } 78 | allowed_types := []string{"image/jpeg", "image/png", "image/gif"} 79 | if file_content_type := file.Header.Get("content-type"); !slices.Contains( 80 | allowed_types, file_content_type, 81 | ) { 82 | customError := NewCustomError( 83 | http.StatusBadRequest, 84 | "error: file type not supported", 85 | "file type not supported", 86 | ) 87 | c.Error(customError) 88 | 89 | return 90 | } 91 | allowed_extensions := []string{".jpeg", ".jpg", ".png"} 92 | ext := filepath.Ext(file.Filename) 93 | // check ext is supported 94 | if ext != "" && !slices.Contains(allowed_extensions, ext) { 95 | customError := NewCustomError( 96 | http.StatusBadRequest, 97 | "error: file extension is not supported", 98 | "file extension is not supported", 99 | ) 100 | c.Error(customError) 101 | 102 | return 103 | } 104 | 105 | uuid := uuid.New() 106 | filename := fmt.Sprintf("%s%s", uuid.String(), ext) 107 | // rootPath, _ := os.Getwd() 108 | image_path := filepath.Join( 109 | a.settings.ImageDirectory, filename, 110 | ) 111 | err = c.SaveUploadedFile(file, image_path) 112 | if err != nil { 113 | customError := NewCustomError( 114 | http.StatusInternalServerError, 115 | err.Error(), 116 | "could not save file", 117 | ) 118 | c.Error(customError) 119 | 120 | return 121 | } 122 | // End saving to filesystem 123 | 124 | // Save metadata into the DB 125 | imageData := &entity.Image{ 126 | UUID: uuid, 127 | Name: filename, 128 | Alt: alt_text, 129 | } 130 | 131 | err = a.serv.CreateImageMetadata(ctx, imageData) 132 | if err != nil { 133 | customError := NewCustomError( 134 | http.StatusInternalServerError, 135 | err.Error(), 136 | "could not add image metadata to db", 137 | ) 138 | c.Error(customError) 139 | 140 | err = os.Remove(image_path) 141 | if err != nil { 142 | customError := NewCustomError( 143 | http.StatusInternalServerError, 144 | err.Error(), 145 | "could not remove image", 146 | ) 147 | c.Error(customError) 148 | } 149 | 150 | return 151 | } 152 | 153 | c.JSON(http.StatusCreated, gin.H{ 154 | "uuid": uuid.String(), 155 | }) 156 | } 157 | 158 | func (a *API) getImageHandler(c *gin.Context) { 159 | ctx := c.Request.Context() 160 | imageBinding := &dto.ImageBinding{} 161 | 162 | // localhost:8080/image/{uuid} 163 | if err := c.ShouldBindUri(imageBinding); err != nil { 164 | customError := NewCustomError( 165 | http.StatusBadRequest, 166 | err.Error(), 167 | "could not get image uuid", 168 | ) 169 | c.Error(customError) 170 | 171 | return 172 | } 173 | 174 | // Get image metadata by UUID 175 | 176 | imageMetadata, err := a.serv.RecoverImageMetadata(ctx, imageBinding.UUID) 177 | if err != nil { 178 | if re.MatchString(err.Error()) { 179 | customError := NewCustomError( 180 | http.StatusInternalServerError, 181 | err.Error(), 182 | "An unexpected condition was encountered.", 183 | ) 184 | c.Error(customError) 185 | 186 | return 187 | } 188 | 189 | if strings.Contains(err.Error(), "no rows in result set") { 190 | customError := NewCustomError( 191 | http.StatusNotFound, 192 | err.Error(), 193 | "The requested resource could not be found but may be available again in the future.", 194 | ) 195 | c.Error(customError) 196 | 197 | return 198 | } 199 | } 200 | 201 | c.JSON(http.StatusOK, gin.H{ 202 | "uuid": imageMetadata.UUID, 203 | "name": imageMetadata.Name, 204 | "alt": imageMetadata.Alt, 205 | }) 206 | } 207 | 208 | func (a *API) getAllImagesHandler(c *gin.Context) { 209 | var ( 210 | limit int 211 | err error 212 | ) 213 | ctx := c.Request.Context() 214 | limitStr := c.Query("limit") 215 | 216 | if limitStr == "" { 217 | limit = 5 218 | } else { 219 | limit, err = strconv.Atoi(limitStr) 220 | if err != nil { 221 | customError := NewCustomError( 222 | http.StatusBadRequest, 223 | err.Error(), 224 | "images limit type invalid", 225 | ) 226 | c.Error(customError) 227 | 228 | return 229 | } 230 | 231 | if limit == 0 { 232 | limit = 5 233 | } 234 | } 235 | 236 | ii, err := a.serv.RecoverAllImageMetadata(ctx, limit) 237 | if err != nil { 238 | if re.MatchString(err.Error()) { 239 | customError := NewCustomError( 240 | http.StatusInternalServerError, 241 | err.Error(), 242 | "An unexpected condition was encountered.", 243 | ) 244 | c.Error(customError) 245 | 246 | return 247 | } 248 | } 249 | 250 | c.JSON(http.StatusOK, gin.H{ 251 | "imageMetadata": ii, 252 | }) 253 | } 254 | 255 | func (a *API) deleteImageHandler(c *gin.Context) { 256 | ctx := c.Request.Context() 257 | imageBinding := &dto.ImageBinding{} 258 | 259 | // localhost:8080/image/{uuid} 260 | if err := c.ShouldBindUri(imageBinding); err != nil { 261 | customError := NewCustomError( 262 | http.StatusBadRequest, 263 | err.Error(), 264 | "could not get image uuid", 265 | ) 266 | c.Error(customError) 267 | 268 | return 269 | } 270 | 271 | // Get image metadata by UUID 272 | 273 | imageMetadata, err := a.serv.RecoverImageMetadata(ctx, imageBinding.UUID) 274 | if err != nil { 275 | if re.MatchString(err.Error()) { 276 | customError := NewCustomError( 277 | http.StatusInternalServerError, 278 | err.Error(), 279 | "An unexpected condition was encountered.", 280 | ) 281 | c.Error(customError) 282 | 283 | return 284 | } 285 | 286 | if strings.Contains(err.Error(), "no rows in result set") { 287 | customError := NewCustomError( 288 | http.StatusNotFound, 289 | err.Error(), 290 | "The requested resource could not be found but may be available again in the future.", 291 | ) 292 | c.Error(customError) 293 | 294 | return 295 | } 296 | } 297 | 298 | // Remove image metadata by uuid 299 | 300 | rowsAffected, err := a.serv.RemoveImage(ctx, imageBinding.UUID) 301 | if err != nil { 302 | if re.MatchString(err.Error()) { 303 | customError := NewCustomError( 304 | http.StatusInternalServerError, 305 | err.Error(), 306 | "An unexpected condition was encountered", 307 | ) 308 | c.Error(customError) 309 | 310 | return 311 | } 312 | } 313 | 314 | if rowsAffected == -1 { 315 | customError := NewCustomError( 316 | http.StatusNotFound, 317 | "no rows in result set", 318 | "could not delete image metadata", 319 | ) 320 | c.Error(customError) 321 | 322 | return 323 | } 324 | 325 | // Delete file system image by its path 326 | // rootPath, _ := os.Getwd() 327 | image_path := filepath.Join( 328 | a.settings.ImageDirectory, imageMetadata.Name, 329 | ) 330 | err = os.Remove(image_path) 331 | if err != nil { 332 | customError := NewCustomError( 333 | http.StatusInternalServerError, 334 | err.Error(), 335 | "could not remove image file", 336 | ) 337 | c.Error(customError) 338 | 339 | return 340 | } 341 | 342 | c.JSON(http.StatusOK, gin.H{ 343 | "rowsAffected": rowsAffected, 344 | }) 345 | } 346 | 347 | /* REFERENCES: 348 | https://www.freeformatter.com/json-escape.html#before-output 349 | https://www.youtube.com/watch?v=yqDYYjbatfE 350 | https://github.com/sikozonpc/fullstack-go-htmx 351 | https://stackoverflow.com/questions/9722603/storing-image-in-database-directly-or-as-base64-data 352 | 353 | https://tutorialedge.net/golang/go-file-upload-tutorial/ 354 | 355 | https://www.google.com/search?q=golang+file+upload+rest+api&sca_esv=dcb7b8806130e33c&sxsrf=ACQVn08616pDvbQ3cGeZyya8IskU-hxACA%3A1709290929268&ei=sbXhZezxD4yX9u8Ps6CtuAI&oq=golang+file+u&gs_lp=Egxnd3Mtd2l6LXNlcnAiDWdvbGFuZyBmaWxlIHUqAggBMgwQIxiABBiKBRgTGCcyCBAAGIAEGMsBMggQABiABBjLATIIEAAYgAQYywEyCBAAGIAEGMsBMggQABiABBjLATIGEAAYFhgeMgYQABgWGB4yBhAAGBYYHjIGEAAYFhgeSPVIUNkMWKsScAF4AZABAJgBqAGgAcYEqgEDMi4zuAEByAEA-AEBmAIGoAL8BMICChAAGEcY1gQYsAOYAwCIBgGQBgiSBwMyLjQ&sclient=gws-wiz-serp 356 | */ 357 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

goCMS

4 | 5 | 6 | 7 |
8 | 9 |

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 | ![GitHub License](https://img.shields.io/github/license/emarifer/url-shortener-echo-templ-htmx) ![Static Badge](https://img.shields.io/badge/Go-%3E=1.22-blue) 16 | 17 |
18 | 19 |
20 | 21 | ## Features 🚀 22 | 23 | - [x] **Headless Architecture:** Adding pages, posts, or forms should all 24 | be done with easy requests to the API. 25 | - [x] **Golang-Powered:** Leverage the performance and safety of one of the 26 | best languages in the market for backend development. 27 | - [x] **SQL Database Integration:** Store your posts and pages in SQL databases for reliable and scalable data storage. 28 | - [x] **Centralized HTTP error handling:** The user receives feedback about the Http errors that their actions may cause, through the use of middleware that centralizes the Http errors that occur. 29 | - [x] **Caching HTML responses from endpoints:** Own implementation of an in-memory cache that stores HTML responses for 10 minutes in a map with mutex lock R/W access. 30 | - [x] **Live Reload:** through the use of `air`. 31 | - [x] **Possibility for the user to add their own plugins written in `Lua`:** this feature allows you to customize the admin application at runtime. 32 | - [ ] **Post**: We can add, update, and delete posts. Posts can be served 33 | through a unique URL. 34 | - [ ] **Pages**: TODO. 35 | - [ ] **Menus**: TODO 36 | 37 |
38 | 39 | >[!IMPORTANT] 40 | >***The Go language uses [html/template](https://pkg.go.dev/html/template) package to render HTML. In this application we have used the [a-h/templ](https://github.com/a-h/templ) library instead. The main difference is that templ uses a generation step to compile the files .templ into Go code (as functions). This means that the templates are type-safe and can be checked at compile time. This amazing library implements a templating language (very similar to JSX) which allows you to write code almost identical to Go (with expressions, control flow, if/else, for loops, etc.) and have autocompletion. For all these reasons, calling these templates from the controllers side will always require the correct data, minimizing errors and thus increasing the security and speed of our coding.*** 41 | 42 | ## Installation 43 | 44 | Ensure you have Golang installed on your system before proceeding with the installation. 45 | 46 | ```bash 47 | go get -u github.com/emarifer/gocms 48 | ``` 49 | 50 | ### Example - Running the App (user application) manually 51 | 52 | First, make sure you have the necessary executable binaries to run and work with the application. 53 | 54 | ```bash 55 | make install-tools 56 | ``` 57 | 58 | After that, with the `MariaDB` database engine running, with the idea of populating the database with some sample data, make sure to run the migrations with the previously installed `Goose` tool. We recommend creating a database called `cms_db` and running the following command: 59 | 60 | ```bash 61 | GOOSE_DRIVER="mysql" GOOSE_DBSTRING="root:root@/cms_db" goose up 62 | ``` 63 | Replace the database connection string with the appropriate string 64 | dependending on where your database is. 65 | 66 | After you've replaced the default template files with your prefered 67 | template, simply build and start the app with the following commands. 68 | 69 | ```bash 70 | go mod tidy && go build -ldflags="-s -w" -v -o ./tmp/gocms ./cmd/gocms && ./tmp/gocms 71 | ``` 72 | 73 | Alternatively, the `air` command will allow us to start the user application (also creating the admin application executable), having, however, with said command the possibility of hot reloading after any change in the user/admin applications code. 74 | 75 | This will start `goCMS` on `http://localhost:8080`. If we have used the `air` command we can start the admin application with the `make run` (on `http://localhost:8081`) command. You can customize the configuration by providing the necessary environment variables. 76 | 77 | ```bash 78 | # e.g. 79 | 80 | DATABASE_PORT=3306 ./tmp/gocms-admin 81 | ``` 82 | 83 | For more information, see the [configuration settings](#configuration). 84 | 85 | ### Example - Running with Docker Compose (user & admin applications) 86 | 87 | In this case the only requirement is to have `Docker` installed and running. 88 | 89 | To create the image and the `Docker` containers and start the application you only need to run the following command in the project folder: 90 | 91 | ```bash 92 | make run-containers 93 | ``` 94 | The above will create an Ubuntu:jammy image and, within that OS, will install Golang, Goose, A-H.Templ and Air. Next, from said image and the mariadb:jammy image, you will create and start two containers: one containing the `goCMS` app, serving on port `8080`, and another one serving the `mariadb` database internally. This will also run the migrations automatically to setup the database! 95 | 96 | To stop and eliminate both containers we will execute the following in another terminal: 97 | 98 | ```bash 99 | docker compose down # to stop and remove containers (run in another terminal) 100 | ``` 101 | 102 | If we do not plan to delete the containers with the idea of continuing to reuse them, we will simply press `Ctrl+C` in the same terminal. This will stop the containers without deleting them. The next time we want to start the application we will run `make run-containers` again. 103 | 104 | As long as we have created/run the aforementioned containers, the management application executable file will have been created. To start it (within `Docker`), simply run the following commands: 105 | 106 | ```bash 107 | docker exec -it docker-gocms-1 sh # to enter the `docker-gocms-1` container 108 | 109 | cd gocms && make start-admin-container # to enter the project folder (inside the container) and start the admin application 110 | ``` 111 | 112 | >[!NOTE] 113 | >***the above serves the application in `http://localhost:8081`.*** 114 | 115 | ## Architecture 116 | 117 | Currently, the architecture of `goCMS` is still in its early days. 118 | The plan is to have two main applications: the public facing application 119 | to serve the content through a website, and the admin application that 120 | can be hidden, where users can modify the settings, add posts, pages, etc. 121 | 122 | ## Configuration 123 | 124 | The runtime configuration can be done through a [toml](https://toml.io/en/) configuration file or by setting the mandatory environment variables (*fallback*). This approach was chosen because configuration via toml supports advanced features (i.e. *relationships*, *arrays*, etc.). The `.dev.env`-file used only for the `goose up` command, they are not needed for `Docker` files. 125 | 126 | ### `.toml` configuration 127 | 128 | The application can be started by providing the `config` flag which has to be set to a toml configuration file. The file has to contain the following mandatory values: 129 | 130 | ```toml 131 | webserver_port = 8080 # port to run the webserver on 132 | admin_webserver_port = 8081 # port to run the webserver (admin) on 133 | database_host = "localhost" # database host (address to the MariaDB database) 134 | database_port = 3306 # database port 135 | database_user = "root" # database user 136 | database_password = "my-secret-pw" # database password 137 | database_name = "cms_db" # name of the database that is created through `Docker` 138 | image_dir = "./media" # directory to use for storing uploaded images 139 | 140 | # optional: directives containing the name and path of user-supplied `Lua` plugins 141 | # e.g. 142 | 143 | [[shortcodes]] 144 | name = "img" 145 | # must have function "HandleShortcode(arguments []string) -> string" 146 | plugin = "plugins/image_shortcode.lua" 147 | ``` 148 | 149 | >[!NOTE] 150 | >***The above configuration values are used to start the local development database, in addition to the user/admin application ports, media storage folder, or optionally, admin plugins directives.*** 151 | 152 | ### Environment variables configuration (fallback) 153 | 154 | If chosen, by setting the following environment variables the application can be started without providing a toml configuration file (although a file of this type is necessary to establish the directives of user plugins written in `Lua`). 155 | 156 | - `WEBSERVER_PORT` port the application should run on 157 | - `ADMIN_WEBSERVER_PORT` the same as the previous one but for the admin app 158 | - `DATABASE_HOST` should contain the database addres, e.g. `localhost` 159 | - `DATABASE_PORT` should be the connection port to the db, e.g. `3306` 160 | - `DATABASE_USER` is the database username. 161 | - `DATABASE_PASSWORD` needs to contain the database password for the given user. 162 | - `DATABASE_NAME` sets the name of the database `goCMS` will use. 163 | - `IMAGE_DIRECTORY` directory images should be stored to if uploaded to `goCMS` 164 | 165 | To the above (as we have already mentioned), we would have to add an environment variable (`CONFIG_FILE_PATH`) that contains the path to a `.toml` file that contains the directives for the plugins (`Lua` scripts) that the user wants add. This file would have the form: 166 | 167 | ```toml 168 | # e.g. 169 | 170 | [[shortcodes]] 171 | name = "img" 172 | # must have function "HandleShortcode(arguments []string) -> string" 173 | plugin = "plugins/image_shortcode.lua" 174 | ``` 175 | 176 | ## Development 177 | 178 | To facilitate the development process, `Docker` is highly recommended. This way you can use `docker/mariadb.yml` to configure a predefined MariaDB database server. The file `mariadb.yml` creates the database `cms_db`. 179 | 180 | ```bash 181 | $ make start-devdb 182 | ``` 183 | 184 | To populate the aforementioned db with some sample data you can use this command: 185 | 186 | ```bash 187 | $ make run-migrations 188 | ``` 189 | 190 | ## License 191 | 192 | `goCMS` is released under the MIT License. See LICENSE for 193 | details. Feel free to fork, modify, and use it in your projects! 194 | 195 | --- 196 | 197 | ## Happy coding 😀!! -------------------------------------------------------------------------------- /assets/js/htmx.min.js: -------------------------------------------------------------------------------- 1 | (function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){var r=dr(e,t||"post");return r.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.10"};var r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R};var w=["get","post","put","delete","patch"];var i=w.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");var S=e("head"),q=e("title"),H=e("svg",true);function e(e,t=false){return new RegExp(`<${e}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${e}>`,t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function L(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=L(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function a(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function N(e){return/",0);return i.querySelector("template").content}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return a(""+n+"
",1);case"col":return a(""+n+"
",2);case"tr":return a(""+n+"
",2);case"td":case"th":return a(""+n+"
",3);case"script":case"style":return a("
"+n+"
",1);default:return a(n,0)}}function ie(e){if(e){e()}}function I(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return I(e,"Function")}function P(e){return I(e,"Object")}function ae(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function M(e){var t=[];if(e){for(var r=0;r=0}function se(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return re().body.contains(e.getRootNode().host)}else{return re().body.contains(e)}}function D(e){return e.trim().split(/\s+/)}function le(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function E(e){try{return JSON.parse(e)}catch(e){b(e);return null}}function U(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function B(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function t(e){return Tr(re().body,function(){return eval(e)})}function F(t){var e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function j(){Q.logger=null}function C(e,t){if(t){return e.querySelector(t)}else{return C(re(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(re(),e)}}function _(e,t){e=g(e);if(t){setTimeout(function(){_(e);e=null},t)}else{e.parentElement.removeChild(e)}}function z(e,t,r){e=g(e);if(r){setTimeout(function(){z(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=g(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function $(e,t){e=g(e);e.classList.toggle(t)}function W(e,t){e=g(e);oe(e.parentElement.children,function(e){n(e,t)});z(e,t)}function v(e,t){e=g(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function s(e,t){return e.substring(0,t.length)===t}function G(e,t){return e.substring(e.length-t.length)===t}function J(e){var t=e.trim();if(s(t,"<")&&G(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function Z(e,t){if(t.indexOf("closest ")===0){return[v(e,J(t.substr(8)))]}else if(t.indexOf("find ")===0){return[C(e,J(t.substr(5)))]}else if(t==="next"){return[e.nextElementSibling]}else if(t.indexOf("next ")===0){return[K(e,J(t.substr(5)))]}else if(t==="previous"){return[e.previousElementSibling]}else if(t.indexOf("previous ")===0){return[Y(e,J(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else{return re().querySelectorAll(J(t))}}var K=function(e,t){var r=re().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function ue(e,t){if(t){return Z(e,t)[0]}else{return Z(re().body,e)[0]}}function g(e){if(I(e,"String")){return C(e)}else{return e}}function ve(e,t,r){if(k(t)){return{target:re().body,event:e,listener:t}}else{return{target:g(e),event:t,listener:r}}}function de(t,r,n){jr(function(){var e=ve(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=k(r);return e?r:n}function ge(t,r,n){jr(function(){var e=ve(t,r,n);e.target.removeEventListener(e.event,e.listener)});return k(r)?r:n}var me=re().createElement("output");function pe(e,t){var r=ne(e,t);if(r){if(r==="this"){return[xe(e,t)]}else{var n=Z(e,r);if(n.length===0){b('The selector "'+r+'" on '+t+" returned no matches!");return[me]}else{return n}}}}function xe(e,t){return c(e,function(e){return te(e,t)!=null})}function ye(e){var t=ne(e,"hx-target");if(t){if(t==="this"){return xe(e,"hx-target")}else{return ue(e,t)}}else{var r=ae(e);if(r.boosted){return re().body}else{return e}}}function be(e){var t=Q.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=re().querySelectorAll(t);if(r){oe(r,function(e){var t;var r=i.cloneNode(true);t=re().createDocumentFragment();t.appendChild(r);if(!Se(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!ce(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){Fe(o,e,e,t,a)}oe(a.elts,function(e){ce(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);fe(re().body,"htmx:oobErrorNoTarget",{content:i})}return e}function Ce(e,t,r){var n=ne(e,"hx-select-oob");if(n){var i=n.split(",");for(var a=0;a0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();we(e,i);s.tasks.push(function(){we(e,a)})}}})}function Oe(e){return function(){n(e,Q.config.addedClass);zt(e);Nt(e);qe(e);ce(e,"htmx:load")}}function qe(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function m(e,t,r,n){Te(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;z(i,Q.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Oe(i))}}}function He(e,t){var r=0;while(r-1){var t=e.replace(H,"");var r=t.match(q);if(r){return r[2]}}}function je(e,t,r,n,i,a){i.title=Ve(n);var o=l(n);if(o){Ce(r,o,i);o=Be(r,o,a);Re(o);return Fe(e,r,t,o,i)}}function _e(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=E(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!P(o)){o={value:o}}ce(r,a,o)}}}else{var s=n.split(",");for(var l=0;l0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=Tr(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){fe(re().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if(Qe(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function y(e,t){var r="";while(e.length>0&&!t.test(e[0])){r+=e.shift()}return r}function tt(e){var t;if(e.length>0&&Ze.test(e[0])){e.shift();t=y(e,Ke).trim();e.shift()}else{t=y(e,x)}return t}var rt="input, textarea, select";function nt(e,t,r){var n=[];var i=Ye(t);do{y(i,Je);var a=i.length;var o=y(i,/[,\[\s]/);if(o!==""){if(o==="every"){var s={trigger:"every"};y(i,Je);s.pollInterval=d(y(i,/[,\[\s]/));y(i,Je);var l=et(e,i,"event");if(l){s.eventFilter=l}n.push(s)}else if(o.indexOf("sse:")===0){n.push({trigger:"sse",sseEvent:o.substr(4)})}else{var u={trigger:o};var l=et(e,i,"event");if(l){u.eventFilter=l}while(i.length>0&&i[0]!==","){y(i,Je);var f=i.shift();if(f==="changed"){u.changed=true}else if(f==="once"){u.once=true}else if(f==="consume"){u.consume=true}else if(f==="delay"&&i[0]===":"){i.shift();u.delay=d(y(i,x))}else if(f==="from"&&i[0]===":"){i.shift();if(Ze.test(i[0])){var c=tt(i)}else{var c=y(i,x);if(c==="closest"||c==="find"||c==="next"||c==="previous"){i.shift();var h=tt(i);if(h.length>0){c+=" "+h}}}u.from=c}else if(f==="target"&&i[0]===":"){i.shift();u.target=tt(i)}else if(f==="throttle"&&i[0]===":"){i.shift();u.throttle=d(y(i,x))}else if(f==="queue"&&i[0]===":"){i.shift();u.queue=y(i,x)}else if(f==="root"&&i[0]===":"){i.shift();u[f]=tt(i)}else if(f==="threshold"&&i[0]===":"){i.shift();u[f]=y(i,x)}else{fe(e,"htmx:syntax:error",{token:i.shift()})}}n.push(u)}}if(i.length===a){fe(e,"htmx:syntax:error",{token:i.shift()})}y(i,Je)}while(i[0]===","&&i.shift());if(r){r[t]=n}return n}function it(e){var t=te(e,"hx-trigger");var r=[];if(t){var n=Q.config.triggerSpecsCache;r=n&&n[t]||nt(e,t,n)}if(r.length>0){return r}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,rt)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function at(e){ae(e).cancelled=true}function ot(e,t,r){var n=ae(e);n.timeout=setTimeout(function(){if(se(e)&&n.cancelled!==true){if(!ct(r,e,Wt("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}ot(e,t,r)}},r.pollInterval)}function st(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function lt(t,r,e){if(t.tagName==="A"&&st(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=ee(t,"href")}else{var a=ee(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=ee(t,"action")}e.forEach(function(e){ht(t,function(e,t){if(v(e,Q.config.disableSelector)){p(e);return}he(n,i,e,t)},r,e,true)})}}function ut(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(h(t,'input[type="submit"], button')&&v(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function ft(e,t){return ae(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function ct(e,t,r){var n=e.eventFilter;if(n){try{return n.call(t,r)!==true}catch(e){fe(re().body,"htmx:eventFilter:error",{error:e,source:n.source});return true}}return false}function ht(a,o,e,s,l){var u=ae(a);var t;if(s.from){t=Z(a,s.from)}else{t=[a]}if(s.changed){t.forEach(function(e){var t=ae(e);t.lastValue=e.value})}oe(t,function(n){var i=function(e){if(!se(a)){n.removeEventListener(s.trigger,i);return}if(ft(a,e)){return}if(l||ut(e,a)){e.preventDefault()}if(ct(s,a,e)){return}var t=ae(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!h(e.target,s.target)){return}}if(s.once){if(u.triggeredOnce){return}else{u.triggeredOnce=true}}if(s.changed){var r=ae(n);if(r.lastValue===n.value){return}r.lastValue=n.value}if(u.delayed){clearTimeout(u.delayed)}if(u.throttle){return}if(s.throttle>0){if(!u.throttle){o(a,e);u.throttle=setTimeout(function(){u.throttle=null},s.throttle)}}else if(s.delay>0){u.delayed=setTimeout(function(){o(a,e)},s.delay)}else{ce(a,"htmx:trigger");o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var vt=false;var dt=null;function gt(){if(!dt){dt=function(){vt=true};window.addEventListener("scroll",dt);setInterval(function(){if(vt){vt=false;oe(re().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){mt(e)})}},200)}}function mt(t){if(!o(t,"data-hx-revealed")&&X(t)){t.setAttribute("data-hx-revealed","true");var e=ae(t);if(e.initHash){ce(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){ce(t,"revealed")},{once:true})}}}function pt(e,t,r){var n=D(r);for(var i=0;i=0){var t=wt(n);setTimeout(function(){xt(s,r,n+1)},t)}};t.onopen=function(e){n=0};ae(s).webSocket=t;t.addEventListener("message",function(e){if(yt(s)){return}var t=e.data;R(s,function(e){t=e.transformResponse(t,null,s)});var r=T(s);var n=l(t);var i=M(n.children);for(var a=0;a0){ce(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(ut(e,u)){e.preventDefault()}})}else{fe(u,"htmx:noWebSocketSourceError")}}function wt(e){var t=Q.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}b('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function St(e,t,r){var n=D(r);for(var i=0;i0){setTimeout(i,n)}else{i()}}function Ht(t,i,e){var a=false;oe(w,function(r){if(o(t,"hx-"+r)){var n=te(t,"hx-"+r);a=true;i.path=n;i.verb=r;e.forEach(function(e){Lt(t,e,i,function(e,t){if(v(e,Q.config.disableSelector)){p(e);return}he(r,n,e,t)})})}});return a}function Lt(n,e,t,r){if(e.sseEvent){Rt(n,r,e.sseEvent)}else if(e.trigger==="revealed"){gt();ht(n,r,t,e);mt(n)}else if(e.trigger==="intersect"){var i={};if(e.root){i.root=ue(n,e.root)}if(e.threshold){i.threshold=parseFloat(e.threshold)}var a=new IntersectionObserver(function(e){for(var t=0;t0){t.polling=true;ot(n,r,e)}else{ht(n,r,t,e)}}function At(e){if(Q.config.allowScriptTags&&(e.type==="text/javascript"||e.type==="module"||e.type==="")){var t=re().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}var r=e.parentElement;try{r.insertBefore(t,e)}catch(e){b(e)}finally{if(e.parentElement){e.parentElement.removeChild(e)}}}}function Nt(e){if(h(e,"script")){At(e)}oe(f(e,"script"),function(e){At(e)})}function It(e){var t=e.attributes;for(var r=0;r0){var o=n.shift();var s=o.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);if(a===0&&s){o.split(":");i=s[1].slice(0,-1);r[i]=s[2]}else{r[i]+=o}a+=Bt(o)}for(var l in r){Ft(e,l,r[l])}}}function jt(e){Ae(e);for(var t=0;tQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(re().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Yt(e){if(!U()){return null}e=B(e);var t=E(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r=200&&this.status<400){ce(re().body,"htmx:historyCacheMissLoad",o);var e=l(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=Zt();var r=T(t);var n=Ve(this.response);if(n){var i=C("title");if(i){i.innerHTML=n}else{window.document.title=n}}Ue(t,e,r);nr(r.tasks);Jt=a;ce(re().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{fe(re().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function ar(e){er();e=e||location.pathname+location.search;var t=Yt(e);if(t){var r=l(t.content);var n=Zt();var i=T(n);Ue(n,r,i);nr(i.tasks);document.title=t.title;setTimeout(function(){window.scrollTo(0,t.scroll)},0);Jt=e;ce(re().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{ir(e)}}}function or(e){var t=pe(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,Q.config.requestClass)});return t}function sr(e){var t=pe(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function lr(e,t){oe(e,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,Q.config.requestClass)}});oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function ur(e,t){for(var r=0;r=0}function wr(e,t){var r=t?t:ne(e,"hx-swap");var n={swapStyle:ae(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ae(e).boosted&&!br(e)){n["show"]="top"}if(r){var i=D(r);if(i.length>0){for(var a=0;a0?l.join(":"):null;n["scroll"]=u;n["scrollTarget"]=f}else if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var f=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=f}else if(o.indexOf("focus-scroll:")===0){var v=o.substr("focus-scroll:".length);n["focusScroll"]=v=="true"}else if(a==0){n["swapStyle"]=o}else{b("Unknown modifier in hx-swap: "+o)}}}}return n}function Sr(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function Er(t,r,n){var i=null;R(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(Sr(r)){return pr(n)}else{return mr(n)}}}function T(e){return{tasks:[],elts:[e]}}function Cr(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=ue(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=ue(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Rr(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=te(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=Tr(e,function(){return Function("return ("+a+")")()},{})}else{s=E(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return Rr(u(e),t,r,n)}function Tr(e,t,r){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return r}}function Or(e,t){return Rr(e,"hx-vars",true,t)}function qr(e,t){return Rr(e,"hx-vals",false,t)}function Hr(e){return le(Or(e),qr(e))}function Lr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function Ar(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(re().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Nr(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||I(r,"String")){return he(e,t,null,null,{targetOverride:g(r),returnPromise:true})}else{return he(e,t,g(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:g(r.target),swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(e,t,null,null,{returnPromise:true})}}function Ir(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function kr(e,t,r){var n;var i;if(typeof URL==="function"){i=new URL(t,document.location.href);var a=document.location.origin;n=a===i.origin}else{i=t;n=s(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!n){return false}}return ce(e,"htmx:validateUrl",le({url:i,sameHost:n},r))}function he(t,r,n,i,a,e){var o=null;var s=null;a=a!=null?a:{};if(a.returnPromise&&typeof Promise!=="undefined"){var l=new Promise(function(e,t){o=e;s=t})}if(n==null){n=re().body}var M=a.handler||Mr;var X=a.select||null;if(!se(n)){ie(o);return l}var u=a.targetOverride||ye(n);if(u==null||u==me){fe(n,"htmx:targetError",{target:te(n,"hx-target")});ie(s);return l}var f=ae(n);var c=f.lastButtonClicked;if(c){var h=ee(c,"formaction");if(h!=null){r=h}var v=ee(c,"formmethod");if(v!=null){if(v.toLowerCase()!=="dialog"){t=v}}}var d=ne(n,"hx-confirm");if(e===undefined){var D=function(e){return he(t,r,n,i,a,!!e)};var U={target:u,elt:n,path:r,verb:t,triggeringEvent:i,etc:a,issueRequest:D,question:d};if(ce(n,"htmx:confirm",U)===false){ie(o);return l}}var g=n;var m=ne(n,"hx-sync");var p=null;var x=false;if(m){var B=m.split(":");var F=B[0].trim();if(F==="this"){g=xe(n,"hx-sync")}else{g=ue(n,F)}m=(B[1]||"drop").trim();f=ae(g);if(m==="drop"&&f.xhr&&f.abortable!==true){ie(o);return l}else if(m==="abort"){if(f.xhr){ie(o);return l}else{x=true}}else if(m==="replace"){ce(g,"htmx:abort")}else if(m.indexOf("queue")===0){var V=m.split(" ");p=(V[1]||"last").trim()}}if(f.xhr){if(f.abortable){ce(g,"htmx:abort")}else{if(p==null){if(i){var y=ae(i);if(y&&y.triggerSpec&&y.triggerSpec.queue){p=y.triggerSpec.queue}}if(p==null){p="last"}}if(f.queuedRequests==null){f.queuedRequests=[]}if(p==="first"&&f.queuedRequests.length===0){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(p==="all"){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(p==="last"){f.queuedRequests=[];f.queuedRequests.push(function(){he(t,r,n,i,a)})}ie(o);return l}}var b=new XMLHttpRequest;f.xhr=b;f.abortable=x;var w=function(){f.xhr=null;f.abortable=false;if(f.queuedRequests!=null&&f.queuedRequests.length>0){var e=f.queuedRequests.shift();e()}};var j=ne(n,"hx-prompt");if(j){var S=prompt(j);if(S===null||!ce(n,"htmx:prompt",{prompt:S,target:u})){ie(o);w();return l}}if(d&&!e){if(!confirm(d)){ie(o);w();return l}}var E=xr(n,u,S);if(t!=="get"&&!Sr(n)){E["Content-Type"]="application/x-www-form-urlencoded"}if(a.headers){E=le(E,a.headers)}var _=dr(n,t);var C=_.errors;var R=_.values;if(a.values){R=le(R,a.values)}var z=Hr(n);var $=le(R,z);var T=yr($,n);if(Q.config.getCacheBusterParam&&t==="get"){T["org.htmx.cache-buster"]=ee(u,"id")||"true"}if(r==null||r===""){r=re().location.href}var O=Rr(n,"hx-request");var W=ae(n).boosted;var q=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;var H={boosted:W,useUrlParams:q,parameters:T,unfilteredParameters:$,headers:E,target:u,verb:t,errors:C,withCredentials:a.credentials||O.credentials||Q.config.withCredentials,timeout:a.timeout||O.timeout||Q.config.timeout,path:r,triggeringEvent:i};if(!ce(n,"htmx:configRequest",H)){ie(o);w();return l}r=H.path;t=H.verb;E=H.headers;T=H.parameters;C=H.errors;q=H.useUrlParams;if(C&&C.length>0){ce(n,"htmx:validation:halted",H);ie(o);w();return l}var G=r.split("#");var J=G[0];var L=G[1];var A=r;if(q){A=J;var Z=Object.keys(T).length!==0;if(Z){if(A.indexOf("?")<0){A+="?"}else{A+="&"}A+=mr(T);if(L){A+="#"+L}}}if(!kr(n,A,H)){fe(n,"htmx:invalidPath",H);ie(s);return l}b.open(t.toUpperCase(),A,true);b.overrideMimeType("text/html");b.withCredentials=H.withCredentials;b.timeout=H.timeout;if(O.noHeaders){}else{for(var N in E){if(E.hasOwnProperty(N)){var K=E[N];Lr(b,N,K)}}}var I={xhr:b,target:u,requestConfig:H,etc:a,boosted:W,select:X,pathInfo:{requestPath:r,finalRequestPath:A,anchor:L}};b.onload=function(){try{var e=Ir(n);I.pathInfo.responsePath=Ar(b);M(n,I);lr(k,P);ce(n,"htmx:afterRequest",I);ce(n,"htmx:afterOnLoad",I);if(!se(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(se(r)){t=r}}if(t){ce(t,"htmx:afterRequest",I);ce(t,"htmx:afterOnLoad",I)}}ie(o);w()}catch(e){fe(n,"htmx:onLoadError",le({error:e},I));throw e}};b.onerror=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendError",I);ie(s);w()};b.onabort=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendAbort",I);ie(s);w()};b.ontimeout=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:timeout",I);ie(s);w()};if(!ce(n,"htmx:beforeRequest",I)){ie(o);w();return l}var k=or(n);var P=sr(n);oe(["loadstart","loadend","progress","abort"],function(t){oe([b,b.upload],function(e){e.addEventListener(t,function(e){ce(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ce(n,"htmx:beforeSend",I);var Y=q?null:Er(b,n,T);b.send(Y);return l}function Pr(e,t){var r=t.xhr;var n=null;var i=null;if(O(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(O(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(O(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=ne(e,"hx-push-url");var l=ne(e,"hx-replace-url");var u=ae(e).boosted;var f=null;var c=null;if(s){f="push";c=s}else if(l){f="replace";c=l}else if(u){f="push";c=o||a}if(c){if(c==="false"){return{}}if(c==="true"){c=o||a}if(t.pathInfo.anchor&&c.indexOf("#")===-1){c=c+"#"+t.pathInfo.anchor}return{type:f,path:c}}else{return{}}}function Mr(l,u){var f=u.xhr;var c=u.target;var e=u.etc;var t=u.requestConfig;var h=u.select;if(!ce(l,"htmx:beforeOnLoad",u))return;if(O(f,/HX-Trigger:/i)){_e(f,"HX-Trigger",l)}if(O(f,/HX-Location:/i)){er();var r=f.getResponseHeader("HX-Location");var v;if(r.indexOf("{")===0){v=E(r);r=v["path"];delete v["path"]}Nr("GET",r,v).then(function(){tr(r)});return}var n=O(f,/HX-Refresh:/i)&&"true"===f.getResponseHeader("HX-Refresh");if(O(f,/HX-Redirect:/i)){location.href=f.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){location.reload();return}if(O(f,/HX-Retarget:/i)){if(f.getResponseHeader("HX-Retarget")==="this"){u.target=l}else{u.target=ue(l,f.getResponseHeader("HX-Retarget"))}}var d=Pr(l,u);var i=f.status>=200&&f.status<400&&f.status!==204;var g=f.response;var a=f.status>=400;var m=Q.config.ignoreTitle;var o=le({shouldSwap:i,serverResponse:g,isError:a,ignoreTitle:m},u);if(!ce(c,"htmx:beforeSwap",o))return;c=o.target;g=o.serverResponse;a=o.isError;m=o.ignoreTitle;u.target=c;u.failed=a;u.successful=!a;if(o.shouldSwap){if(f.status===286){at(l)}R(l,function(e){g=e.transformResponse(g,f,l)});if(d.type){er()}var s=e.swapOverride;if(O(f,/HX-Reswap:/i)){s=f.getResponseHeader("HX-Reswap")}var v=wr(l,s);if(v.hasOwnProperty("ignoreTitle")){m=v.ignoreTitle}c.classList.add(Q.config.swappingClass);var p=null;var x=null;var y=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r;if(h){r=h}if(O(f,/HX-Reselect:/i)){r=f.getResponseHeader("HX-Reselect")}if(d.type){ce(re().body,"htmx:beforeHistoryUpdate",le({history:d},u));if(d.type==="push"){tr(d.path);ce(re().body,"htmx:pushedIntoHistory",{path:d.path})}else{rr(d.path);ce(re().body,"htmx:replacedInHistory",{path:d.path})}}var n=T(c);je(v.swapStyle,c,l,g,n,r);if(t.elt&&!se(t.elt)&&ee(t.elt,"id")){var i=document.getElementById(ee(t.elt,"id"));var a={preventScroll:v.focusScroll!==undefined?!v.focusScroll:!Q.config.defaultFocusScroll};if(i){if(t.start&&i.setSelectionRange){try{i.setSelectionRange(t.start,t.end)}catch(e){}}i.focus(a)}}c.classList.remove(Q.config.swappingClass);oe(n.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}ce(e,"htmx:afterSwap",u)});if(O(f,/HX-Trigger-After-Swap:/i)){var o=l;if(!se(l)){o=re().body}_e(f,"HX-Trigger-After-Swap",o)}var s=function(){oe(n.tasks,function(e){e.call()});oe(n.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}ce(e,"htmx:afterSettle",u)});if(u.pathInfo.anchor){var e=re().getElementById(u.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title&&!m){var t=C("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}Cr(n.elts,v);if(O(f,/HX-Trigger-After-Settle:/i)){var r=l;if(!se(l)){r=re().body}_e(f,"HX-Trigger-After-Settle",r)}ie(p)};if(v.settleDelay>0){setTimeout(s,v.settleDelay)}else{s()}}catch(e){fe(l,"htmx:swapError",u);ie(x);throw e}};var b=Q.config.globalViewTransitions;if(v.hasOwnProperty("transition")){b=v.transition}if(b&&ce(l,"htmx:beforeTransition",u)&&typeof Promise!=="undefined"&&document.startViewTransition){var w=new Promise(function(e,t){p=e;x=t});var S=y;y=function(){document.startViewTransition(function(){S();return w})}}if(v.swapDelay>0){setTimeout(y,v.swapDelay)}else{y()}}if(a){fe(l,"htmx:responseError",le({error:"Response Status Error Code "+f.status+" from "+u.pathInfo.requestPath},u))}}var Xr={};function Dr(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function Ur(e,t){if(t.init){t.init(r)}Xr[e]=le(Dr(),t)}function Br(e){delete Xr[e]}function Fr(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=te(e,"hx-ext");if(t){oe(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Xr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return Fr(u(e),r,n)}var Vr=false;re().addEventListener("DOMContentLoaded",function(){Vr=true});function jr(e){if(Vr||re().readyState==="complete"){e()}else{re().addEventListener("DOMContentLoaded",e)}}function _r(){if(Q.config.includeIndicatorStyles!==false){re().head.insertAdjacentHTML("beforeend","")}}function zr(){var e=re().querySelector('meta[name="htmx-config"]');if(e){return E(e.content)}else{return null}}function $r(){var e=zr();if(e){Q.config=le(Q.config,e)}}jr(function(){$r();_r();var e=re().body;zt(e);var t=re().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=ae(t);if(r&&r.xhr){r.xhr.abort()}});const r=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){ar();oe(t,function(e){ce(e,"htmx:restored",{document:re(),triggerEvent:ce})})}else{if(r){r(e)}}};setTimeout(function(){ce(e,"htmx:load",{});e=null},0)});return Q}()}); --------------------------------------------------------------------------------