├── .DS_Store ├── .editorconfig ├── .env.example ├── .gitignore ├── Makefile ├── app ├── http │ ├── controllers │ │ ├── articles_controller.go │ │ ├── auth_controller.go │ │ ├── base_controller.go │ │ ├── categories_controller.go │ │ ├── pages_controller.go │ │ └── user_controller.go │ └── middlewares │ │ ├── auth.go │ │ ├── force_html.go │ │ ├── guest.go │ │ ├── middleware.go │ │ ├── remove_trailing_slash.go │ │ └── start_session.go ├── models │ ├── article │ │ ├── article.go │ │ └── crud.go │ ├── category │ │ ├── category.go │ │ └── crud.go │ ├── model.go │ └── user │ │ ├── crud.go │ │ ├── hooks.go │ │ └── user.go ├── policies │ └── topic_policy.go └── requests │ ├── article_form.go │ ├── category_form.go │ ├── request.go │ └── user_registration.go ├── bootstrap ├── db.go ├── route.go └── template.go ├── config ├── app.go ├── config.go ├── database.go ├── pagination.go └── session.go ├── go.mod ├── go.sum ├── main.go ├── pkg ├── auth │ └── auth.go ├── config │ └── config.go ├── database │ └── database.go ├── flash │ └── flash.go ├── logger │ └── logger.go ├── model │ └── model.go ├── pagination │ └── pagination.go ├── password │ └── password.go ├── route │ └── router.go ├── session │ └── session.go ├── types │ └── converter.go └── view │ └── view.go ├── public ├── .DS_Store ├── css │ ├── app.css │ └── bootstrap.min.css └── js │ └── bootstrap.min.js ├── readme.md ├── resources └── views │ ├── articles │ ├── _article_meta.gohtml │ ├── _form_field.gohtml │ ├── create.gohtml │ ├── edit.gohtml │ ├── index.gohtml │ └── show.gohtml │ ├── auth │ ├── login.gohtml │ └── register.gohtml │ ├── categories │ └── create.gohtml │ └── layouts │ ├── _form_error_feedback.gohtml │ ├── _messages.gohtml │ ├── _pagination.gohtml │ ├── app.gohtml │ ├── sidebar.gohtml │ └── simple.gohtml ├── routes └── web.go └── tests └── pages_test.go /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/summerblue/goblog/d0ca7cddc86f0b2adae9d70410c431962cb5cec2/.DS_Store -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*.gohtml] 6 | ; 设定文件编码 7 | charset = utf-8 8 | ; 移除文件尾部多余的空格 9 | trim_trailing_whitespace = true 10 | ; 使用空格来做缩进 11 | indent_style = space 12 | ; 缩进长度为两个空格 13 | indent_size = 2 -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME=myapp 2 | APP_ENV=local 3 | APP_KEY=33446a9dcf9ea060a0a6532b166da32f304af0de 4 | APP_DEBUG=true 5 | APP_URL=http://localhost:3000 6 | APP_LOG_LEVEL=debug 7 | APP_PORT=3000 8 | 9 | DB_CONNECTION=mysql 10 | DB_HOST=127.0.0.1 11 | DB_PORT=3306 12 | DB_DATABASE=goblog 13 | DB_USERNAME=root 14 | DB_PASSWORD=secret 15 | 16 | SESSION_DRIVER=cookie 17 | SESSION_NAME=goblog-session -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp 2 | .env -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | REMOTE=117.你的.IP.地址 2 | APPNAME=goblog 3 | 4 | .PHONY: deploy 5 | deploy: 6 | @echo "\n--- 开始构建可执行文件 ---" 7 | GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -v -o tmp/$(APPNAME)_tmp 8 | 9 | @echo "\n--- 上传可执行文件 ---" 10 | scp tmp/$(APPNAME)_tmp root@$(REMOTE):/data/www/goblog.com/ 11 | 12 | @echo "\n--- 停止服务 ---" 13 | ssh root@$(REMOTE) "supervisorctl stop $(APPNAME)" 14 | 15 | @echo "\n--- 替换新文件 ---" 16 | ssh root@$(REMOTE) "cd /data/www/goblog.com/ \ 17 | && rm $(APPNAME) \ 18 | && mv $(APPNAME)_tmp $(APPNAME) \ 19 | && chown www-data:www-data $(APPNAME)" 20 | 21 | @echo "\n--- 开始服务 ---" 22 | ssh root@$(REMOTE) "supervisorctl start $(APPNAME)" 23 | 24 | @echo "\n--- 部署完毕 ---\n" -------------------------------------------------------------------------------- /app/http/controllers/articles_controller.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "goblog/app/models/article" 6 | "goblog/app/policies" 7 | "goblog/app/requests" 8 | "goblog/pkg/auth" 9 | "goblog/pkg/route" 10 | "goblog/pkg/view" 11 | "net/http" 12 | ) 13 | 14 | // ArticlesController 处理静态页面 15 | type ArticlesController struct { 16 | BaseController 17 | } 18 | 19 | // Show 文章详情页面 20 | func (ac *ArticlesController) Show(w http.ResponseWriter, r *http.Request) { 21 | 22 | // 1. 获取 URL 参数 23 | id := route.GetRouteVariable("id", r) 24 | 25 | // 2. 读取对应的文章数据 26 | article, err := article.Get(id) 27 | 28 | // 3. 如果出现错误 29 | if err != nil { 30 | ac.ResponseForSQLError(w, err) 31 | } else { 32 | // --- 4. 读取成功,显示文章 --- 33 | view.Render(w, view.D{ 34 | "Article": article, 35 | "CanModifyArticle": policies.CanModifyArticle(article), 36 | }, "articles.show", "articles._article_meta") 37 | } 38 | } 39 | 40 | // Index 文章列表页 41 | func (ac *ArticlesController) Index(w http.ResponseWriter, r *http.Request) { 42 | 43 | // 1. 获取结果集 44 | articles, pagerData, err := article.GetAll(r, 2) 45 | 46 | if err != nil { 47 | ac.ResponseForSQLError(w, err) 48 | } else { 49 | 50 | // --- 2. 加载模板 --- 51 | view.Render(w, view.D{ 52 | "Articles": articles, 53 | "PagerData": pagerData, 54 | }, "articles.index", "articles._article_meta") 55 | } 56 | } 57 | 58 | // Create 文章创建页面 59 | func (*ArticlesController) Create(w http.ResponseWriter, r *http.Request) { 60 | view.Render(w, view.D{}, "articles.create", "articles._form_field") 61 | } 62 | 63 | // Store 文章创建页面 64 | func (*ArticlesController) Store(w http.ResponseWriter, r *http.Request) { 65 | // 1. 初始化数据 66 | currentUser := auth.User() 67 | _article := article.Article{ 68 | Title: r.PostFormValue("title"), 69 | Body: r.PostFormValue("body"), 70 | UserID: currentUser.ID, 71 | } 72 | 73 | // 2. 表单验证 74 | errors := requests.ValidateArticleForm(_article) 75 | 76 | // 3. 检测错误 77 | if len(errors) == 0 { 78 | // 创建文章 79 | _article.Create() 80 | if _article.ID > 0 { 81 | indexURL := route.Name2URL("articles.show", "id", _article.GetStringID()) 82 | http.Redirect(w, r, indexURL, http.StatusFound) 83 | } else { 84 | w.WriteHeader(http.StatusInternalServerError) 85 | fmt.Fprint(w, "创建文章失败,请联系管理员") 86 | } 87 | } else { 88 | view.Render(w, view.D{ 89 | "Article": _article, 90 | "Errors": errors, 91 | }, "articles.create", "articles._form_field") 92 | } 93 | } 94 | 95 | // Edit 文章更新页面 96 | func (ac *ArticlesController) Edit(w http.ResponseWriter, r *http.Request) { 97 | 98 | // 1. 获取 URL 参数 99 | id := route.GetRouteVariable("id", r) 100 | 101 | // 2. 读取对应的文章数据 102 | _article, err := article.Get(id) 103 | 104 | // 3. 如果出现错误 105 | if err != nil { 106 | ac.ResponseForSQLError(w, err) 107 | } else { 108 | 109 | // 检查权限 110 | if !policies.CanModifyArticle(_article) { 111 | ac.ResponseForUnauthorized(w, r) 112 | } else { 113 | // 4. 读取成功,显示编辑文章表单 114 | view.Render(w, view.D{ 115 | "Article": _article, 116 | "Errors": view.D{}, 117 | }, "articles.edit", "articles._form_field") 118 | } 119 | } 120 | } 121 | 122 | // Update 更新文章 123 | func (ac *ArticlesController) Update(w http.ResponseWriter, r *http.Request) { 124 | 125 | // 1. 获取 URL 参数 126 | id := route.GetRouteVariable("id", r) 127 | 128 | // 2. 读取对应的文章数据 129 | _article, err := article.Get(id) 130 | 131 | // 3. 如果出现错误 132 | if err != nil { 133 | ac.ResponseForSQLError(w, err) 134 | } else { 135 | // 4. 未出现错误 136 | 137 | // 检查权限 138 | if !policies.CanModifyArticle(_article) { 139 | ac.ResponseForUnauthorized(w, r) 140 | } else { 141 | 142 | // 4.1 表单验证 143 | _article.Title = r.PostFormValue("title") 144 | _article.Body = r.PostFormValue("body") 145 | 146 | errors := requests.ValidateArticleForm(_article) 147 | 148 | if len(errors) == 0 { 149 | 150 | // 4.2 表单验证通过,更新数据 151 | rowsAffected, err := _article.Update() 152 | 153 | if err != nil { 154 | // 数据库错误 155 | w.WriteHeader(http.StatusInternalServerError) 156 | fmt.Fprint(w, "500 服务器内部错误") 157 | return 158 | } 159 | 160 | // √ 更新成功,跳转到文章详情页 161 | if rowsAffected > 0 { 162 | showURL := route.Name2URL("articles.show", "id", id) 163 | http.Redirect(w, r, showURL, http.StatusFound) 164 | } else { 165 | fmt.Fprint(w, "您没有做任何更改!") 166 | } 167 | } else { 168 | 169 | // 4.3 表单验证不通过,显示理由 170 | view.Render(w, view.D{ 171 | "Article": _article, 172 | "Errors": errors, 173 | }, "articles.edit", "articles._form_field") 174 | } 175 | } 176 | } 177 | } 178 | 179 | // Delete 删除文章 180 | func (ac *ArticlesController) Delete(w http.ResponseWriter, r *http.Request) { 181 | 182 | // 1. 获取 URL 参数 183 | id := route.GetRouteVariable("id", r) 184 | 185 | // 2. 读取对应的文章数据 186 | _article, err := article.Get(id) 187 | 188 | // 3. 如果出现错误 189 | if err != nil { 190 | ac.ResponseForSQLError(w, err) 191 | } else { 192 | 193 | // 检查权限 194 | if !policies.CanModifyArticle(_article) { 195 | ac.ResponseForUnauthorized(w, r) 196 | } else { 197 | // 4. 未出现错误,执行删除操作 198 | rowsAffected, err := _article.Delete() 199 | 200 | // 4.1 发生错误 201 | if err != nil { 202 | // 应该是 SQL 报错了 203 | w.WriteHeader(http.StatusInternalServerError) 204 | fmt.Fprint(w, "500 服务器内部错误") 205 | } else { 206 | // 4.2 未发生错误 207 | if rowsAffected > 0 { 208 | // 重定向到文章列表页 209 | indexURL := route.Name2URL("articles.index") 210 | http.Redirect(w, r, indexURL, http.StatusFound) 211 | } else { 212 | // Edge case 213 | w.WriteHeader(http.StatusNotFound) 214 | fmt.Fprint(w, "404 文章未找到") 215 | } 216 | } 217 | } 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /app/http/controllers/auth_controller.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "goblog/app/models/user" 6 | "goblog/app/requests" 7 | "goblog/pkg/auth" 8 | "goblog/pkg/flash" 9 | "goblog/pkg/view" 10 | "net/http" 11 | ) 12 | 13 | // AuthController 处理用户认证 14 | type AuthController struct { 15 | } 16 | 17 | // Register 注册页面 18 | func (*AuthController) Register(w http.ResponseWriter, r *http.Request) { 19 | view.RenderSimple(w, view.D{}, "auth.register") 20 | } 21 | 22 | // DoRegister 处理注册逻辑 23 | func (*AuthController) DoRegister(w http.ResponseWriter, r *http.Request) { 24 | 25 | // 1. 初始化数据 26 | _user := user.User{ 27 | Name: r.PostFormValue("name"), 28 | Email: r.PostFormValue("email"), 29 | Password: r.PostFormValue("password"), 30 | PasswordConfirm: r.PostFormValue("password_confirm"), 31 | } 32 | 33 | // 2. 表单规则 34 | errs := requests.ValidateRegistrationForm(_user) 35 | 36 | if len(errs) > 0 { 37 | // 3. 表单不通过 —— 重新显示表单 38 | view.RenderSimple(w, view.D{ 39 | "Errors": errs, 40 | "User": _user, 41 | }, "auth.register") 42 | } else { 43 | // 4. 验证成功,创建数据 44 | _user.Create() 45 | 46 | if _user.ID > 0 { 47 | // 登录用户并跳转到首页 48 | flash.Success("恭喜您注册成功!") 49 | auth.Login(_user) 50 | http.Redirect(w, r, "/", http.StatusFound) 51 | } else { 52 | w.WriteHeader(http.StatusInternalServerError) 53 | fmt.Fprint(w, "注册失败,请联系管理员") 54 | } 55 | } 56 | } 57 | 58 | // Login 显示登录表单 59 | func (*AuthController) Login(w http.ResponseWriter, r *http.Request) { 60 | view.RenderSimple(w, view.D{}, "auth.login") 61 | } 62 | 63 | // DoLogin 处理登录表单提交 64 | func (*AuthController) DoLogin(w http.ResponseWriter, r *http.Request) { 65 | 66 | // 1. 初始化表单数据 67 | email := r.PostFormValue("email") 68 | password := r.PostFormValue("password") 69 | 70 | // 2. 尝试登录 71 | if err := auth.Attempt(email, password); err == nil { 72 | // 登录成功 73 | flash.Success("欢迎回来!") 74 | http.Redirect(w, r, "/", http.StatusFound) 75 | } else { 76 | // 3. 失败,显示错误提示 77 | view.RenderSimple(w, view.D{ 78 | "Error": err.Error(), 79 | "Email": email, 80 | "Password": password, 81 | }, "auth.login") 82 | } 83 | } 84 | 85 | // Logout 退出登录 86 | func (*AuthController) Logout(w http.ResponseWriter, r *http.Request) { 87 | auth.Logout() 88 | flash.Success("您已退出登录") 89 | http.Redirect(w, r, "/", http.StatusFound) 90 | } 91 | -------------------------------------------------------------------------------- /app/http/controllers/base_controller.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "goblog/pkg/flash" 6 | "goblog/pkg/logger" 7 | "net/http" 8 | 9 | "gorm.io/gorm" 10 | ) 11 | 12 | // BaseController 基础控制器 13 | type BaseController struct { 14 | } 15 | 16 | // ResponseForSQLError 处理 SQL 错误并返回 17 | func (bc BaseController) ResponseForSQLError(w http.ResponseWriter, err error) { 18 | if err == gorm.ErrRecordNotFound { 19 | // 3.1 数据未找到 20 | w.WriteHeader(http.StatusNotFound) 21 | fmt.Fprint(w, "404 文章未找到") 22 | } else { 23 | // 3.2 数据库错误 24 | logger.LogError(err) 25 | w.WriteHeader(http.StatusInternalServerError) 26 | fmt.Fprint(w, "500 服务器内部错误") 27 | } 28 | } 29 | 30 | // ResponseForUnauthorized 处理未授权的访问 31 | func (bc BaseController) ResponseForUnauthorized(w http.ResponseWriter, r *http.Request) { 32 | flash.Warning("未授权操作!") 33 | http.Redirect(w, r, "/", http.StatusFound) 34 | } 35 | -------------------------------------------------------------------------------- /app/http/controllers/categories_controller.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "goblog/app/models/article" 6 | "goblog/app/models/category" 7 | "goblog/app/requests" 8 | "goblog/pkg/flash" 9 | "goblog/pkg/route" 10 | "goblog/pkg/view" 11 | "net/http" 12 | ) 13 | 14 | // CategoriesController 文章分类控制器 15 | type CategoriesController struct { 16 | BaseController 17 | } 18 | 19 | // Create 文章分类创建页面 20 | func (*CategoriesController) Create(w http.ResponseWriter, r *http.Request) { 21 | view.Render(w, view.D{}, "categories.create") 22 | } 23 | 24 | // Store 保存文章分类 25 | func (*CategoriesController) Store(w http.ResponseWriter, r *http.Request) { 26 | 27 | // 1. 初始化数据 28 | _category := category.Category{ 29 | Name: r.PostFormValue("name"), 30 | } 31 | 32 | // 2. 表单验证 33 | errors := requests.ValidateCategoryForm(_category) 34 | 35 | // 3. 检测错误 36 | if len(errors) == 0 { 37 | // 创建文章分类 38 | _category.Create() 39 | if _category.ID > 0 { 40 | flash.Success("分类创建成功") 41 | indexURL := route.Name2URL("home") 42 | http.Redirect(w, r, indexURL, http.StatusFound) 43 | } else { 44 | w.WriteHeader(http.StatusInternalServerError) 45 | fmt.Fprint(w, "创建文章分类失败,请联系管理员") 46 | } 47 | } else { 48 | view.Render(w, view.D{ 49 | "Category": _category, 50 | "Errors": errors, 51 | }, "categories.create") 52 | } 53 | } 54 | 55 | // Show 显示分类下的文章列表 56 | func (cc *CategoriesController) Show(w http.ResponseWriter, r *http.Request) { 57 | 58 | // 1. 获取 URL 参数 59 | id := route.GetRouteVariable("id", r) 60 | 61 | // 2. 读取对应的数据 62 | _category, err := category.Get(id) 63 | 64 | // 3. 获取结果集 65 | articles, pagerData, err := article.GetByCategoryID(_category.GetStringID(), r, 2) 66 | 67 | if err != nil { 68 | cc.ResponseForSQLError(w, err) 69 | } else { 70 | 71 | // --- 2. 加载模板 --- 72 | view.Render(w, view.D{ 73 | "Articles": articles, 74 | "PagerData": pagerData, 75 | }, "articles.index", "articles._article_meta") 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/http/controllers/pages_controller.go: -------------------------------------------------------------------------------- 1 | // Package controllers 应用控制层 2 | package controllers 3 | 4 | import ( 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | // PagesController 处理静态页面 10 | type PagesController struct { 11 | } 12 | 13 | // Home 首页 14 | func (*PagesController) Home(w http.ResponseWriter, r *http.Request) { 15 | fmt.Fprint(w, "

Hello, 欢迎来到 goblog!

") 16 | } 17 | 18 | // About 关于我们页面 19 | func (*PagesController) About(w http.ResponseWriter, r *http.Request) { 20 | fmt.Fprint(w, "此博客是用以记录编程笔记,如您有反馈或建议,请联系 "+ 21 | "summer@example.com") 22 | } 23 | 24 | // NotFound 404 页面 25 | func (*PagesController) NotFound(w http.ResponseWriter, r *http.Request) { 26 | w.WriteHeader(http.StatusNotFound) 27 | fmt.Fprint(w, "

请求页面未找到 :(

如有疑惑,请联系我们。

") 28 | } 29 | -------------------------------------------------------------------------------- /app/http/controllers/user_controller.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "goblog/app/models/article" 6 | "goblog/app/models/user" 7 | "goblog/pkg/logger" 8 | "goblog/pkg/route" 9 | "goblog/pkg/view" 10 | "net/http" 11 | ) 12 | 13 | // UserController 用户控制器 14 | type UserController struct { 15 | BaseController 16 | } 17 | 18 | // Show 用户个人页面 19 | func (uc *UserController) Show(w http.ResponseWriter, r *http.Request) { 20 | 21 | // 1. 获取 URL 参数 22 | id := route.GetRouteVariable("id", r) 23 | 24 | // 2. 读取对应的文章数据 25 | _user, err := user.Get(id) 26 | 27 | // 3. 如果出现错误 28 | if err != nil { 29 | uc.ResponseForSQLError(w, err) 30 | } else { 31 | // --- 4. 读取成功,显示用户文章列表 --- 32 | articles, err := article.GetByUserID(_user.GetStringID()) 33 | if err != nil { 34 | logger.LogError(err) 35 | w.WriteHeader(http.StatusInternalServerError) 36 | fmt.Fprint(w, "500 服务器内部错误") 37 | } else { 38 | view.Render(w, view.D{ 39 | "Articles": articles, 40 | }, "articles.index", "articles._article_meta") 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/http/middlewares/auth.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "goblog/pkg/auth" 5 | "goblog/pkg/flash" 6 | "net/http" 7 | ) 8 | 9 | // Auth 登录用户才可访问 10 | func Auth(next HTTPHandlerFunc) HTTPHandlerFunc { 11 | return func(w http.ResponseWriter, r *http.Request) { 12 | 13 | if !auth.Check() { 14 | flash.Warning("登录用户才能访问此页面") 15 | http.Redirect(w, r, "/", http.StatusFound) 16 | return 17 | } 18 | 19 | next(w, r) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/http/middlewares/force_html.go: -------------------------------------------------------------------------------- 1 | // Package middlewares 存放应用中间件 2 | package middlewares 3 | 4 | import "net/http" 5 | 6 | // ForceHTML 强制标头返回 HTML 内容类型 7 | func ForceHTML(next http.Handler) http.Handler { 8 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 9 | // 1. 设置标头 10 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 11 | // 2. 继续处理请求 12 | next.ServeHTTP(w, r) 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /app/http/middlewares/guest.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "goblog/pkg/auth" 5 | "goblog/pkg/flash" 6 | "net/http" 7 | ) 8 | 9 | // Guest 只允许未登录用户访问 10 | func Guest(next HTTPHandlerFunc) HTTPHandlerFunc { 11 | return func(w http.ResponseWriter, r *http.Request) { 12 | 13 | if auth.Check() { 14 | flash.Warning("登录用户无法访问此页面") 15 | http.Redirect(w, r, "/", http.StatusFound) 16 | return 17 | } 18 | 19 | // 继续处理接下去的请求 20 | next(w, r) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/http/middlewares/middleware.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import "net/http" 4 | 5 | // HTTPHandlerFunc 简写 —— func(http.ResponseWriter, *http.Request) 6 | type HTTPHandlerFunc func(http.ResponseWriter, *http.Request) 7 | -------------------------------------------------------------------------------- /app/http/middlewares/remove_trailing_slash.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | ) 7 | 8 | // RemoveTrailingSlash 除首页以外,移除所有请求路径后面的斜杆 9 | func RemoveTrailingSlash(next http.Handler) http.Handler { 10 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 11 | // 1. 除首页以外,移除所有请求路径后面的斜杆 12 | if r.URL.Path != "/" { 13 | r.URL.Path = strings.TrimSuffix(r.URL.Path, "/") 14 | } 15 | 16 | // 2. 将请求传递下去 17 | next.ServeHTTP(w, r) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /app/http/middlewares/start_session.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "goblog/pkg/session" 5 | "net/http" 6 | ) 7 | 8 | // StartSession 开启 session 会话控制 9 | func StartSession(next http.Handler) http.Handler { 10 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 11 | 12 | // 1. 启动会话 13 | session.StartSession(w, r) 14 | 15 | // 2. . 继续处理接下去的请求 16 | next.ServeHTTP(w, r) 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /app/models/article/article.go: -------------------------------------------------------------------------------- 1 | // Package article 应用的文章模型 2 | package article 3 | 4 | import ( 5 | "goblog/app/models" 6 | "goblog/app/models/user" 7 | "goblog/pkg/route" 8 | "strconv" 9 | ) 10 | 11 | // Article 文章模型 12 | type Article struct { 13 | models.BaseModel 14 | 15 | Title string `gorm:"type:varchar(255);not null;" valid:"title"` 16 | Body string `gorm:"type:longtext;not null;" valid:"body"` 17 | 18 | UserID uint64 `gorm:"not null;index"` 19 | User user.User 20 | 21 | CategoryID uint64 `gorm:"not null;default:4;index"` 22 | } 23 | 24 | // Link 方法用来生成文章链接 25 | func (article Article) Link() string { 26 | return route.Name2URL("articles.show", "id", strconv.FormatUint(article.ID, 10)) 27 | } 28 | 29 | // CreatedAtDate 创建日期 30 | func (article Article) CreatedAtDate() string { 31 | return article.CreatedAt.Format("2006-01-02") 32 | } 33 | -------------------------------------------------------------------------------- /app/models/article/crud.go: -------------------------------------------------------------------------------- 1 | package article 2 | 3 | import ( 4 | "goblog/pkg/logger" 5 | "goblog/pkg/model" 6 | "goblog/pkg/pagination" 7 | "goblog/pkg/route" 8 | "goblog/pkg/types" 9 | "net/http" 10 | ) 11 | 12 | // Get 通过 ID 获取文章 13 | func Get(idstr string) (Article, error) { 14 | var article Article 15 | id := types.StringToUint64(idstr) 16 | if err := model.DB.Preload("User").First(&article, id).Error; err != nil { 17 | return article, err 18 | } 19 | 20 | return article, nil 21 | } 22 | 23 | // GetAll 获取全部文章 24 | func GetAll(r *http.Request, perPage int) ([]Article, pagination.ViewData, error) { 25 | 26 | // 1. 初始化分页实例 27 | db := model.DB.Model(Article{}).Order("created_at desc") 28 | _pager := pagination.New(r, db, route.Name2URL("home"), perPage) 29 | 30 | // 2. 获取视图数据 31 | viewData := _pager.Paging() 32 | 33 | // 3. 获取数据 34 | var articles []Article 35 | _pager.Results(&articles) 36 | 37 | return articles, viewData, nil 38 | } 39 | 40 | // Create 创建文章,通过 article.ID 来判断是否创建成功 41 | func (article *Article) Create() (err error) { 42 | if err = model.DB.Create(&article).Error; err != nil { 43 | logger.LogError(err) 44 | return err 45 | } 46 | 47 | return nil 48 | } 49 | 50 | // Update 更新文章 51 | func (article *Article) Update() (rowsAffected int64, err error) { 52 | result := model.DB.Save(&article) 53 | if err = result.Error; err != nil { 54 | logger.LogError(err) 55 | return 0, err 56 | } 57 | 58 | return result.RowsAffected, nil 59 | } 60 | 61 | // Delete 删除文章 62 | func (article *Article) Delete() (rowsAffected int64, err error) { 63 | result := model.DB.Delete(&article) 64 | if err = result.Error; err != nil { 65 | logger.LogError(err) 66 | return 0, err 67 | } 68 | 69 | return result.RowsAffected, nil 70 | } 71 | 72 | // GetByUserID 获取全部文章 73 | func GetByUserID(uid string) ([]Article, error) { 74 | var articles []Article 75 | if err := model.DB.Where("user_id = ?", uid).Preload("User").Find(&articles).Error; err != nil { 76 | return articles, err 77 | } 78 | return articles, nil 79 | } 80 | 81 | // GetByCategoryID 获取分类相关的文章 82 | func GetByCategoryID(cid string, r *http.Request, perPage int) ([]Article, pagination.ViewData, error) { 83 | 84 | // 1. 初始化分页实例 85 | db := model.DB.Model(Article{}).Where("category_id = ?", cid).Order("created_at desc") 86 | _pager := pagination.New(r, db, route.Name2URL("categories.show", "id", cid), perPage) 87 | 88 | // 2. 获取视图数据 89 | viewData := _pager.Paging() 90 | 91 | // 3. 获取数据 92 | var articles []Article 93 | _pager.Results(&articles) 94 | 95 | return articles, viewData, nil 96 | } 97 | -------------------------------------------------------------------------------- /app/models/category/category.go: -------------------------------------------------------------------------------- 1 | // Package category 存放应用的分类数据模型 2 | package category 3 | 4 | import ( 5 | "goblog/app/models" 6 | "goblog/pkg/route" 7 | ) 8 | 9 | // Category 文章分类 10 | type Category struct { 11 | models.BaseModel 12 | 13 | Name string `gorm:"type:varchar(255);not null;" valid:"name"` 14 | } 15 | 16 | // Link 方法用来生成文章链接 17 | func (category Category) Link() string { 18 | return route.Name2URL("categories.show", "id", category.GetStringID()) 19 | } 20 | -------------------------------------------------------------------------------- /app/models/category/crud.go: -------------------------------------------------------------------------------- 1 | package category 2 | 3 | import ( 4 | "goblog/pkg/logger" 5 | "goblog/pkg/model" 6 | "goblog/pkg/types" 7 | ) 8 | 9 | // Create 创建分类,通过 category.ID 来判断是否创建成功 10 | func (category *Category) Create() (err error) { 11 | if err = model.DB.Create(&category).Error; err != nil { 12 | logger.LogError(err) 13 | return err 14 | } 15 | 16 | return nil 17 | } 18 | 19 | // All 获取分类数据 20 | func All() ([]Category, error) { 21 | var categories []Category 22 | if err := model.DB.Find(&categories).Error; err != nil { 23 | return categories, err 24 | } 25 | return categories, nil 26 | } 27 | 28 | // Get 通过 ID 获取分类 29 | func Get(idstr string) (Category, error) { 30 | var category Category 31 | id := types.StringToUint64(idstr) 32 | if err := model.DB.First(&category, id).Error; err != nil { 33 | return category, err 34 | } 35 | 36 | return category, nil 37 | } 38 | -------------------------------------------------------------------------------- /app/models/model.go: -------------------------------------------------------------------------------- 1 | // Package models 模型基类 2 | package models 3 | 4 | import ( 5 | "goblog/pkg/types" 6 | "time" 7 | ) 8 | 9 | // BaseModel 模型基类 10 | type BaseModel struct { 11 | ID uint64 `gorm:"column:id;primaryKey;autoIncrement;not null"` 12 | 13 | CreatedAt time.Time `gorm:"column:created_at;index"` 14 | UpdatedAt time.Time `gorm:"column:updated_at;index"` 15 | } 16 | 17 | // GetStringID 获取 ID 的字符串格式 18 | func (a BaseModel) GetStringID() string { 19 | return types.Uint64ToString(a.ID) 20 | } 21 | -------------------------------------------------------------------------------- /app/models/user/crud.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "goblog/pkg/logger" 5 | "goblog/pkg/model" 6 | "goblog/pkg/types" 7 | ) 8 | 9 | // Create 创建用户,通过 User.ID 来判断是否创建成功 10 | func (user *User) Create() (err error) { 11 | if err = model.DB.Create(&user).Error; err != nil { 12 | logger.LogError(err) 13 | return err 14 | } 15 | 16 | return nil 17 | } 18 | 19 | // Get 通过 ID 获取用户 20 | func Get(idstr string) (User, error) { 21 | var user User 22 | id := types.StringToUint64(idstr) 23 | if err := model.DB.First(&user, id).Error; err != nil { 24 | return user, err 25 | } 26 | return user, nil 27 | } 28 | 29 | // GetByEmail 通过 Email 来获取用户 30 | func GetByEmail(email string) (User, error) { 31 | var user User 32 | if err := model.DB.Where("email = ?", email).First(&user).Error; err != nil { 33 | return user, err 34 | } 35 | return user, nil 36 | } 37 | 38 | // All 获取所有用户数据 39 | func All() ([]User, error) { 40 | var users []User 41 | if err := model.DB.Find(&users).Error; err != nil { 42 | return users, err 43 | } 44 | return users, nil 45 | } 46 | -------------------------------------------------------------------------------- /app/models/user/hooks.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "goblog/pkg/password" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | // BeforeSave GORM 的模型钩子,在保存和更新模型前调用 10 | func (user *User) BeforeSave(tx *gorm.DB) (err error) { 11 | 12 | if !password.IsHashed(user.Password) { 13 | user.Password = password.Hash(user.Password) 14 | } 15 | return 16 | } 17 | -------------------------------------------------------------------------------- /app/models/user/user.go: -------------------------------------------------------------------------------- 1 | // Package user 用户模型 2 | package user 3 | 4 | import ( 5 | "goblog/app/models" 6 | "goblog/pkg/password" 7 | "goblog/pkg/route" 8 | ) 9 | 10 | // User 用户模型 11 | type User struct { 12 | models.BaseModel 13 | 14 | Name string `gorm:"type:varchar(255);not null;unique" valid:"name"` 15 | Email string `gorm:"type:varchar(255);unique;" valid:"email"` 16 | Password string `gorm:"type:varchar(255)" valid:"password"` 17 | 18 | // gorm:"-" —— 设置 GORM 在读写时略过此字段,仅用于表单验证 19 | PasswordConfirm string `gorm:"-" valid:"password_confirm"` 20 | } 21 | 22 | // ComparePassword 对比密码是否匹配 23 | func (user *User) ComparePassword(_password string) bool { 24 | return password.CheckHash(_password, user.Password) 25 | } 26 | 27 | // Link 方法用来生成用户链接 28 | func (user User) Link() string { 29 | return route.Name2URL("users.show", "id", user.GetStringID()) 30 | } 31 | -------------------------------------------------------------------------------- /app/policies/topic_policy.go: -------------------------------------------------------------------------------- 1 | // Package policies 存放应用的授权策略 2 | package policies 3 | 4 | import ( 5 | "goblog/app/models/article" 6 | "goblog/pkg/auth" 7 | ) 8 | 9 | // CanModifyArticle 是否允许修改话题 10 | func CanModifyArticle(_article article.Article) bool { 11 | return auth.User().ID == _article.UserID 12 | } 13 | -------------------------------------------------------------------------------- /app/requests/article_form.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "goblog/app/models/article" 5 | 6 | "github.com/thedevsaddam/govalidator" 7 | ) 8 | 9 | // ValidateArticleForm 验证表单,返回 errs 长度等于零即通过 10 | func ValidateArticleForm(data article.Article) map[string][]string { 11 | 12 | // 1. 定制认证规则 13 | rules := govalidator.MapData{ 14 | "title": []string{"required", "min_cn:3", "max_cn:40"}, 15 | "body": []string{"required", "min_cn:10"}, 16 | } 17 | 18 | // 2. 定制错误消息 19 | messages := govalidator.MapData{ 20 | "title": []string{ 21 | "required:标题为必填项", 22 | "min_cn:标题长度需大于 3", 23 | "max_cn:标题长度需小于 40", 24 | }, 25 | "body": []string{ 26 | "required:文章内容为必填项", 27 | "min_cn:长度需大于 10", 28 | }, 29 | } 30 | 31 | // 3. 配置初始化 32 | opts := govalidator.Options{ 33 | Data: &data, 34 | Rules: rules, 35 | TagIdentifier: "valid", // 模型中的 Struct 标签标识符 36 | Messages: messages, 37 | } 38 | 39 | // 4. 开始验证 40 | return govalidator.New(opts).ValidateStruct() 41 | } 42 | -------------------------------------------------------------------------------- /app/requests/category_form.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "goblog/app/models/category" 5 | 6 | "github.com/thedevsaddam/govalidator" 7 | ) 8 | 9 | // ValidateCategoryForm 验证表单,返回 errs 长度等于零即通过 10 | func ValidateCategoryForm(data category.Category) map[string][]string { 11 | 12 | // 1. 定制认证规则 13 | rules := govalidator.MapData{ 14 | "name": []string{"required", "min_cn:2", "max_cn:8", "not_exists:categories,name"}, 15 | } 16 | 17 | // 2. 定制错误消息 18 | messages := govalidator.MapData{ 19 | "name": []string{ 20 | "required:分类名称为必填项", 21 | "min_cn:分类名称长度需至少 2 个字", 22 | "max_cn:分类名称长度不能超过 8 个字", 23 | }, 24 | } 25 | 26 | // 3. 配置初始化 27 | opts := govalidator.Options{ 28 | Data: &data, 29 | Rules: rules, 30 | TagIdentifier: "valid", // 模型中的 Struct 标签标识符 31 | Messages: messages, 32 | } 33 | 34 | // 4. 开始验证 35 | return govalidator.New(opts).ValidateStruct() 36 | } 37 | -------------------------------------------------------------------------------- /app/requests/request.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "goblog/pkg/model" 7 | "strconv" 8 | "strings" 9 | "unicode/utf8" 10 | 11 | "github.com/thedevsaddam/govalidator" 12 | ) 13 | 14 | // 此方法会在初始化时执行 15 | func init() { 16 | // not_exists:users,email 17 | govalidator.AddCustomRule("not_exists", func(field string, rule string, message string, value interface{}) error { 18 | rng := strings.Split(strings.TrimPrefix(rule, "not_exists:"), ",") 19 | 20 | tableName := rng[0] 21 | dbFiled := rng[1] 22 | val := value.(string) 23 | 24 | var count int64 25 | model.DB.Table(tableName).Where(dbFiled+" = ?", val).Count(&count) 26 | 27 | if count != 0 { 28 | 29 | if message != "" { 30 | return errors.New(message) 31 | } 32 | 33 | return fmt.Errorf("%v 已被占用", val) 34 | } 35 | return nil 36 | }) 37 | 38 | // max_cn:8 39 | govalidator.AddCustomRule("max_cn", func(field string, rule string, message string, value interface{}) error { 40 | valLength := utf8.RuneCountInString(value.(string)) 41 | l, _ := strconv.Atoi(strings.TrimPrefix(rule, "max_cn:")) //handle other error 42 | if valLength > l { 43 | if message != "" { 44 | return errors.New(message) 45 | } 46 | return fmt.Errorf("长度不能超过 %d 个字", l) 47 | } 48 | return nil 49 | }) 50 | 51 | // min_cn:2 52 | govalidator.AddCustomRule("min_cn", func(field string, rule string, message string, value interface{}) error { 53 | valLength := utf8.RuneCountInString(value.(string)) 54 | l, _ := strconv.Atoi(strings.TrimPrefix(rule, "min_cn:")) //handle other error 55 | if valLength < l { 56 | if message != "" { 57 | return errors.New(message) 58 | } 59 | return fmt.Errorf("长度需大于 %d 个字", l) 60 | } 61 | return nil 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /app/requests/user_registration.go: -------------------------------------------------------------------------------- 1 | // Package requests 请求处理 2 | package requests 3 | 4 | import ( 5 | "goblog/app/models/user" 6 | 7 | "github.com/thedevsaddam/govalidator" 8 | ) 9 | 10 | // ValidateRegistrationForm 验证表单,返回 errs 长度等于零即通过 11 | func ValidateRegistrationForm(data user.User) map[string][]string { 12 | 13 | // 1. 定制认证规则 14 | rules := govalidator.MapData{ 15 | "name": []string{"required", "alpha_num", "between:3,20", "not_exists:users,name"}, 16 | "email": []string{"required", "min:4", "max:30", "email", "not_exists:users,email"}, 17 | "password": []string{"required", "min:6"}, 18 | "password_confirm": []string{"required"}, 19 | } 20 | 21 | // 2. 定制错误消息 22 | messages := govalidator.MapData{ 23 | "name": []string{ 24 | "required:用户名为必填项", 25 | "alpha_num:格式错误,只允许数字和英文", 26 | "between:用户名长度需在 3~20 之间", 27 | }, 28 | "email": []string{ 29 | "required:Email 为必填项", 30 | "min:Email 长度需大于 4", 31 | "max:Email 长度需小于 30", 32 | "email:Email 格式不正确,请提供有效的邮箱地址", 33 | }, 34 | "password": []string{ 35 | "required:密码为必填项", 36 | "min:长度需大于 6", 37 | }, 38 | "password_confirm": []string{ 39 | "required:确认密码框为必填项", 40 | }, 41 | } 42 | 43 | // 3. 配置初始化 44 | opts := govalidator.Options{ 45 | Data: &data, 46 | Rules: rules, 47 | TagIdentifier: "valid", // 模型中的 Struct 标签标识符 48 | Messages: messages, 49 | } 50 | 51 | // 4. 开始验证 52 | errs := govalidator.New(opts).ValidateStruct() 53 | 54 | // 5. 因 govalidator 不支持 password_confirm 验证,我们自己写一个 55 | if data.Password != data.PasswordConfirm { 56 | errs["password_confirm"] = append(errs["password_confirm"], "两次输入密码不匹配!") 57 | } 58 | 59 | return errs 60 | } 61 | -------------------------------------------------------------------------------- /bootstrap/db.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "goblog/app/models/article" 5 | "goblog/app/models/category" 6 | "goblog/app/models/user" 7 | "goblog/pkg/config" 8 | "goblog/pkg/model" 9 | "time" 10 | 11 | "gorm.io/gorm" 12 | ) 13 | 14 | // SetupDB 初始化数据库和 ORM 15 | func SetupDB() { 16 | 17 | // 建立数据库连接池 18 | db := model.ConnectDB() 19 | 20 | // 命令行打印数据库请求的信息 21 | sqlDB, _ := db.DB() 22 | 23 | // 设置最大连接数 24 | sqlDB.SetMaxOpenConns(config.GetInt("database.mysql.max_open_connections")) 25 | // 设置最大空闲连接数 26 | sqlDB.SetMaxIdleConns(config.GetInt("database.mysql.max_idle_connections")) 27 | // 设置每个链接的过期时间 28 | sqlDB.SetConnMaxLifetime(time.Duration(config.GetInt("database.mysql.max_life_seconds")) * time.Second) 29 | 30 | // 创建和维护数据表结构 31 | migration(db) 32 | } 33 | 34 | func migration(db *gorm.DB) { 35 | 36 | // 自动迁移 37 | db.AutoMigrate( 38 | &user.User{}, 39 | &article.Article{}, 40 | &category.Category{}, 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /bootstrap/route.go: -------------------------------------------------------------------------------- 1 | // Package bootstrap 负责应用初始化相关工作,比如初始化路由。 2 | package bootstrap 3 | 4 | import ( 5 | "embed" 6 | "goblog/pkg/route" 7 | "goblog/routes" 8 | "io/fs" 9 | "net/http" 10 | 11 | "github.com/gorilla/mux" 12 | ) 13 | 14 | // SetupRoute 路由初始化 15 | func SetupRoute(staticFS embed.FS) *mux.Router { 16 | router := mux.NewRouter() 17 | routes.RegisterWebRoutes(router) 18 | 19 | route.SetRoute(router) 20 | 21 | // 静态资源 22 | sub, _ := fs.Sub(staticFS, "public") 23 | router.PathPrefix("/").Handler(http.FileServer(http.FS(sub))) 24 | 25 | return router 26 | } 27 | -------------------------------------------------------------------------------- /bootstrap/template.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "embed" 5 | "goblog/pkg/view" 6 | ) 7 | 8 | // SetupTemplate 模板初始化 9 | func SetupTemplate(tmplFS embed.FS) { 10 | 11 | view.TplFS = tmplFS 12 | 13 | } 14 | -------------------------------------------------------------------------------- /config/app.go: -------------------------------------------------------------------------------- 1 | // Package config 应用的配置 2 | package config 3 | 4 | import "goblog/pkg/config" 5 | 6 | func init() { 7 | config.Add("app", config.StrMap{ 8 | 9 | // 应用名称,暂时没有使用到 10 | "name": config.Env("APP_NAME", "GoBlog"), 11 | 12 | // 当前环境,用以区分多环境 13 | "env": config.Env("APP_ENV", "production"), 14 | 15 | // 是否进入调试模式 16 | "debug": config.Env("APP_DEBUG", false), 17 | 18 | // 应用服务端口 19 | "port": config.Env("APP_PORT", "3000"), 20 | 21 | // gorilla/sessions 在 Cookie 中加密数据时使用 22 | "key": config.Env("APP_KEY", "33446a9dcf9ea060a0a6532b166da32f304af0de"), 23 | 24 | // 用以生成链接 25 | "url": config.Env("APP_URL", "http://localhost:3000"), 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // Initialize 配置信息初始化 4 | func Initialize() { 5 | // 触发加载本目录下其他文件中的 init 方法 6 | } 7 | -------------------------------------------------------------------------------- /config/database.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "goblog/pkg/config" 5 | ) 6 | 7 | func init() { 8 | 9 | config.Add("database", config.StrMap{ 10 | "mysql": map[string]interface{}{ 11 | 12 | // 数据库连接信息 13 | "host": config.Env("DB_HOST", "127.0.0.1"), 14 | "port": config.Env("DB_PORT", "3306"), 15 | "database": config.Env("DB_DATABASE", "goblog"), 16 | "username": config.Env("DB_USERNAME", ""), 17 | "password": config.Env("DB_PASSWORD", ""), 18 | "charset": "utf8mb4", 19 | 20 | // 连接池配置 21 | "max_idle_connections": config.Env("DB_MAX_IDLE_CONNECTIONS", 25), 22 | "max_open_connections": config.Env("DB_MAX_OPEN_CONNECTIONS", 100), 23 | "max_life_seconds": config.Env("DB_MAX_LIFE_SECONDS", 5*60), 24 | }, 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /config/pagination.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "goblog/pkg/config" 4 | 5 | func init() { 6 | config.Add("pagination", config.StrMap{ 7 | 8 | // 默认每页条数 9 | "perpage": 10, 10 | 11 | // URL 中用以分辨多少页的参数 12 | "url_query": "page", 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /config/session.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "goblog/pkg/config" 4 | 5 | func init() { 6 | config.Add("session", config.StrMap{ 7 | 8 | // 目前只支持 Cookie 9 | "default": config.Env("SESSION_DRIVER", "cookie"), 10 | 11 | // 会话的 Cookie 名称 12 | "session_name": config.Env("SESSION_NAME", "goblog-session"), 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module goblog 2 | 3 | go 1.22.1 4 | 5 | require ( 6 | github.com/go-sql-driver/mysql v1.8.0 7 | github.com/gorilla/mux v1.8.1 8 | github.com/gorilla/sessions v1.2.2 9 | github.com/spf13/cast v1.6.0 10 | github.com/spf13/viper v1.18.2 11 | github.com/stretchr/testify v1.9.0 12 | github.com/thedevsaddam/govalidator v1.9.10 13 | golang.org/x/crypto v0.21.0 14 | gorm.io/driver/mysql v1.5.4 15 | gorm.io/gorm v1.25.7 16 | ) 17 | 18 | require ( 19 | filippo.io/edwards25519 v1.1.0 // indirect 20 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 21 | github.com/fsnotify/fsnotify v1.7.0 // indirect 22 | github.com/gorilla/securecookie v1.1.2 // indirect 23 | github.com/hashicorp/hcl v1.0.0 // indirect 24 | github.com/jinzhu/inflection v1.0.0 // indirect 25 | github.com/jinzhu/now v1.1.5 // indirect 26 | github.com/magiconair/properties v1.8.7 // indirect 27 | github.com/mitchellh/mapstructure v1.5.0 // indirect 28 | github.com/pelletier/go-toml/v2 v2.1.0 // indirect 29 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 30 | github.com/sagikazarmark/locafero v0.4.0 // indirect 31 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 32 | github.com/sourcegraph/conc v0.3.0 // indirect 33 | github.com/spf13/afero v1.11.0 // indirect 34 | github.com/spf13/pflag v1.0.5 // indirect 35 | github.com/subosito/gotenv v1.6.0 // indirect 36 | go.uber.org/atomic v1.9.0 // indirect 37 | go.uber.org/multierr v1.9.0 // indirect 38 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 39 | golang.org/x/sys v0.18.0 // indirect 40 | golang.org/x/text v0.14.0 // indirect 41 | gopkg.in/ini.v1 v1.67.0 // indirect 42 | gopkg.in/yaml.v3 v3.0.1 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 6 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 8 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 9 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 10 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 11 | github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= 12 | github.com/go-sql-driver/mysql v1.8.0 h1:UtktXaU2Nb64z/pLiGIxY4431SJ4/dR5cjMmlVHgnT4= 13 | github.com/go-sql-driver/mysql v1.8.0/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 14 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 15 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 16 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 17 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 18 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 19 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 20 | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 21 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 22 | github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= 23 | github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= 24 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 25 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 26 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 27 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 28 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 29 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 30 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 31 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 32 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 33 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 34 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 35 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 36 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 37 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 38 | github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= 39 | github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= 40 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 41 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 42 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 43 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 44 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 45 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= 46 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 47 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 48 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 49 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 50 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 51 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 52 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 53 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= 54 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 55 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 56 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 57 | github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= 58 | github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= 59 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 60 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 61 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 62 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 63 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 64 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 65 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 66 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 67 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 68 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 69 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 70 | github.com/thedevsaddam/govalidator v1.9.10 h1:m3dLRbSZ5Hts3VUWYe+vxLMG+FdyQuWOjzTeQRiMCvU= 71 | github.com/thedevsaddam/govalidator v1.9.10/go.mod h1:Ilx8u7cg5g3LXbSS943cx5kczyNuUn7LH/cK5MYuE90= 72 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 73 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 74 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 75 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 76 | golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= 77 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 78 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= 79 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= 80 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 81 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 82 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 83 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 84 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 85 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 86 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 87 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 88 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 89 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 90 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 91 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 92 | gorm.io/driver/mysql v1.5.4 h1:igQmHfKcbaTVyAIHNhhB888vvxh8EdQ2uSUT0LPcBso= 93 | gorm.io/driver/mysql v1.5.4/go.mod h1:9rYxJph/u9SWkWc9yY4XJ1F/+xO0S/ChOmbk3+Z5Tvs= 94 | gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= 95 | gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A= 96 | gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= 97 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "goblog/app/http/middlewares" 6 | "goblog/bootstrap" 7 | "goblog/config" 8 | c "goblog/pkg/config" 9 | "goblog/pkg/logger" 10 | "net/http" 11 | ) 12 | 13 | //go:embed resources/views/articles/* 14 | //go:embed resources/views/auth/* 15 | //go:embed resources/views/categories/* 16 | //go:embed resources/views/layouts/* 17 | var tplFS embed.FS 18 | 19 | //go:embed public/* 20 | var staticFS embed.FS 21 | 22 | func init() { 23 | // 初始化配置信息 24 | config.Initialize() 25 | } 26 | 27 | func main() { 28 | // 初始化 SQL 29 | bootstrap.SetupDB() 30 | 31 | // 初始化模板加载 32 | bootstrap.SetupTemplate(tplFS) 33 | 34 | // 初始化路由绑定 35 | router := bootstrap.SetupRoute(staticFS) 36 | 37 | err := http.ListenAndServe(":"+c.GetString("app.port"), middlewares.RemoveTrailingSlash(router)) 38 | logger.LogError(err) 39 | } 40 | -------------------------------------------------------------------------------- /pkg/auth/auth.go: -------------------------------------------------------------------------------- 1 | // Package auth 授权模块 2 | package auth 3 | 4 | import ( 5 | "errors" 6 | "goblog/app/models/user" 7 | "goblog/pkg/session" 8 | 9 | "gorm.io/gorm" 10 | ) 11 | 12 | func _getUID() string { 13 | _uid := session.Get("uid") 14 | uid, ok := _uid.(string) 15 | if ok && len(uid) > 0 { 16 | return uid 17 | } 18 | return "" 19 | } 20 | 21 | // User 获取登录用户信息 22 | func User() user.User { 23 | uid := _getUID() 24 | if len(uid) > 0 { 25 | _user, err := user.Get(uid) 26 | if err == nil { 27 | return _user 28 | } 29 | } 30 | return user.User{} 31 | } 32 | 33 | // Attempt 尝试登录 34 | func Attempt(email string, password string) error { 35 | // 1. 根据 Email 获取用户 36 | _user, err := user.GetByEmail(email) 37 | 38 | // 2. 如果出现错误 39 | if err != nil { 40 | if err == gorm.ErrRecordNotFound { 41 | return errors.New("账号不存在或密码错误") 42 | } else { 43 | return errors.New("内部错误,请稍后尝试") 44 | } 45 | } 46 | 47 | // 3. 匹配密码 48 | if !_user.ComparePassword(password) { 49 | return errors.New("账号不存在或密码错误") 50 | } 51 | 52 | // 4. 登录用户,保存会话 53 | session.Put("uid", _user.GetStringID()) 54 | 55 | return nil 56 | } 57 | 58 | // Login 登录指定用户 59 | func Login(_user user.User) { 60 | session.Put("uid", _user.GetStringID()) 61 | } 62 | 63 | // Logout 退出用户 64 | func Logout() { 65 | session.Forget("uid") 66 | } 67 | 68 | // Check 检测是否登录 69 | func Check() bool { 70 | return len(_getUID()) > 0 71 | } 72 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | // Package config 配置信息管理 2 | package config 3 | 4 | import ( 5 | "goblog/pkg/logger" 6 | 7 | "github.com/spf13/cast" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | // Viper Viper 库实例 12 | var Viper *viper.Viper 13 | 14 | // StrMap 简写 —— map[string]interface{} 15 | type StrMap map[string]interface{} 16 | 17 | // init() 函数在 import 的时候立刻被加载 18 | func init() { 19 | // 1. 初始化 Viper 库 20 | Viper = viper.New() 21 | // 2. 设置文件名称 22 | Viper.SetConfigName(".env") 23 | // 3. 配置类型,支持 "json", "toml", "yaml", "yml", "properties", 24 | // "props", "prop", "env", "dotenv" 25 | Viper.SetConfigType("env") 26 | // 4. 环境变量配置文件查找的路径,相对于 main.go 27 | Viper.AddConfigPath(".") 28 | 29 | // 5. 开始读根目录下的 .env 文件,读不到会报错 30 | err := Viper.ReadInConfig() 31 | logger.LogError(err) 32 | 33 | // 6. 设置环境变量前缀,用以区分 Go 的系统环境变量 34 | Viper.SetEnvPrefix("appenv") 35 | // 7. Viper.Get() 时,优先读取环境变量 36 | Viper.AutomaticEnv() 37 | } 38 | 39 | // Env 读取环境变量,支持默认值 40 | func Env(envName string, defaultValue ...interface{}) interface{} { 41 | if len(defaultValue) > 0 { 42 | return Get(envName, defaultValue[0]) 43 | } 44 | return Get(envName) 45 | } 46 | 47 | // Add 新增配置项 48 | func Add(name string, configuration map[string]interface{}) { 49 | Viper.Set(name, configuration) 50 | } 51 | 52 | // Get 获取配置项,允许使用点式获取,如:app.name 53 | func Get(path string, defaultValue ...interface{}) interface{} { 54 | // 不存在的情况 55 | if !Viper.IsSet(path) { 56 | if len(defaultValue) > 0 { 57 | return defaultValue[0] 58 | } 59 | return nil 60 | } 61 | return Viper.Get(path) 62 | } 63 | 64 | // GetString 获取 String 类型的配置信息 65 | func GetString(path string, defaultValue ...interface{}) string { 66 | return cast.ToString(Get(path, defaultValue...)) 67 | } 68 | 69 | // GetInt 获取 Int 类型的配置信息 70 | func GetInt(path string, defaultValue ...interface{}) int { 71 | return cast.ToInt(Get(path, defaultValue...)) 72 | } 73 | 74 | // GetInt64 获取 Int64 类型的配置信息 75 | func GetInt64(path string, defaultValue ...interface{}) int64 { 76 | return cast.ToInt64(Get(path, defaultValue...)) 77 | } 78 | 79 | // GetUint 获取 Uint 类型的配置信息 80 | func GetUint(path string, defaultValue ...interface{}) uint { 81 | return cast.ToUint(Get(path, defaultValue...)) 82 | } 83 | 84 | // GetBool 获取 Bool 类型的配置信息 85 | func GetBool(path string, defaultValue ...interface{}) bool { 86 | return cast.ToBool(Get(path, defaultValue...)) 87 | } 88 | -------------------------------------------------------------------------------- /pkg/database/database.go: -------------------------------------------------------------------------------- 1 | // Package database 数据库相关 2 | package database 3 | 4 | import ( 5 | "database/sql" 6 | "goblog/pkg/logger" 7 | "time" 8 | 9 | "github.com/go-sql-driver/mysql" 10 | ) 11 | 12 | // DB 数据库对象 13 | var DB *sql.DB 14 | 15 | // Initialize 初始化数据库 16 | func Initialize() { 17 | initDB() 18 | createTables() 19 | } 20 | 21 | func initDB() { 22 | 23 | var err error 24 | 25 | // 设置数据库连接信息 26 | config := mysql.Config{ 27 | User: "root", 28 | Passwd: "secret", 29 | Addr: "127.0.0.1:3306", 30 | Net: "tcp", 31 | DBName: "goblog", 32 | AllowNativePasswords: true, 33 | } 34 | 35 | // 准备数据库连接池 36 | DB, err = sql.Open("mysql", config.FormatDSN()) 37 | logger.LogError(err) 38 | 39 | // 设置最大连接数 40 | DB.SetMaxOpenConns(100) 41 | // 设置最大空闲连接数 42 | DB.SetMaxIdleConns(25) 43 | // 设置每个链接的过期时间 44 | DB.SetConnMaxLifetime(5 * time.Minute) 45 | 46 | // 尝试连接,失败会报错 47 | err = DB.Ping() 48 | logger.LogError(err) 49 | } 50 | 51 | func createTables() { 52 | createArticlesSQL := `CREATE TABLE IF NOT EXISTS articles( 53 | id bigint(20) PRIMARY KEY AUTO_INCREMENT NOT NULL, 54 | title varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, 55 | body longtext COLLATE utf8mb4_unicode_ci 56 | ); ` 57 | 58 | _, err := DB.Exec(createArticlesSQL) 59 | logger.LogError(err) 60 | } 61 | -------------------------------------------------------------------------------- /pkg/flash/flash.go: -------------------------------------------------------------------------------- 1 | // Package flash 用以支持在会话中存储消息提示 2 | package flash 3 | 4 | import ( 5 | "encoding/gob" 6 | "goblog/pkg/session" 7 | ) 8 | 9 | // Flashes Flash 消息数组类型,用以在会话中存储 map 10 | type Flashes map[string]interface{} 11 | 12 | // 存入会话数据里的 key 13 | var flashKey = "_flashes" 14 | 15 | func init() { 16 | // 在 gorilla/sessions 中存储 map 和 struct 数据需 17 | // 要提前注册 gob,方便后续 gob 序列化编码、解码 18 | gob.Register(Flashes{}) 19 | } 20 | 21 | // Info 添加 Info 类型的消息提示 22 | func Info(message string) { 23 | addFlash("info", message) 24 | } 25 | 26 | // Warning 添加 Warning 类型的消息提示 27 | func Warning(message string) { 28 | addFlash("warning", message) 29 | } 30 | 31 | // Success 添加 Success 类型的消息提示 32 | func Success(message string) { 33 | addFlash("success", message) 34 | } 35 | 36 | // Danger 添加 Danger 类型的消息提示 37 | func Danger(message string) { 38 | addFlash("danger", message) 39 | } 40 | 41 | // All 获取所有消息 42 | func All() Flashes { 43 | val := session.Get(flashKey) 44 | // 读取时必须做类型检测 45 | flashMessages, ok := val.(Flashes) 46 | if !ok { 47 | return nil 48 | } 49 | // 读取即销毁,直接删除 50 | session.Forget(flashKey) 51 | return flashMessages 52 | } 53 | 54 | // 私有方法,新增一条提示 55 | func addFlash(key string, message string) { 56 | flashes := Flashes{} 57 | flashes[key] = message 58 | session.Put(flashKey, flashes) 59 | session.Save() 60 | } 61 | -------------------------------------------------------------------------------- /pkg/logger/logger.go: -------------------------------------------------------------------------------- 1 | // Package logger 日志相关 2 | package logger 3 | 4 | import "log" 5 | 6 | // LogError 当存在错误时记录日志 7 | func LogError(err error) { 8 | if err != nil { 9 | log.Println(err) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /pkg/model/model.go: -------------------------------------------------------------------------------- 1 | // Package model 应用模型数据层 2 | package model 3 | 4 | import ( 5 | "fmt" 6 | "goblog/pkg/config" 7 | "goblog/pkg/logger" 8 | 9 | // GORM 的 MSYQL 数据库驱动导入 10 | 11 | "gorm.io/driver/mysql" 12 | "gorm.io/gorm" 13 | gormlogger "gorm.io/gorm/logger" 14 | ) 15 | 16 | // DB gorm.DB 对象 17 | var DB *gorm.DB 18 | 19 | // ConnectDB 初始化模型 20 | func ConnectDB() *gorm.DB { 21 | 22 | var err error 23 | 24 | // 初始化 MySQL 连接信息 25 | gormConfig := mysql.New(mysql.Config{ 26 | DSN: fmt.Sprintf("%v:%v@tcp(%v:%v)/%v?charset=%v&parseTime=True&loc=Local", 27 | config.GetString("database.mysql.username"), 28 | config.GetString("database.mysql.password"), 29 | config.GetString("database.mysql.host"), 30 | config.GetString("database.mysql.port"), 31 | config.GetString("database.mysql.database"), 32 | config.GetString("database.mysql.charset")), 33 | }) 34 | 35 | var level gormlogger.LogLevel 36 | if config.GetBool("app.debug") { 37 | // 读取不到数据也会显示 38 | level = gormlogger.Warn 39 | } else { 40 | // 只有错误才会显示 41 | level = gormlogger.Error 42 | } 43 | 44 | // 准备数据库连接池 45 | DB, err = gorm.Open(gormConfig, &gorm.Config{ 46 | Logger: gormlogger.Default.LogMode(level), 47 | }) 48 | 49 | logger.LogError(err) 50 | 51 | return DB 52 | } 53 | -------------------------------------------------------------------------------- /pkg/pagination/pagination.go: -------------------------------------------------------------------------------- 1 | package pagination 2 | 3 | import ( 4 | "goblog/pkg/config" 5 | "goblog/pkg/types" 6 | "math" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | 11 | "gorm.io/gorm" 12 | "gorm.io/gorm/clause" 13 | ) 14 | 15 | // Page 单个分页元素 16 | type Page struct { 17 | // 链接 18 | URL string 19 | // 页码 20 | Number int 21 | } 22 | 23 | // ViewData 同视图渲染的数据 24 | type ViewData struct { 25 | // 是否需要显示分页 26 | HasPages bool 27 | 28 | // 下一页 29 | Next Page 30 | HasNext bool 31 | 32 | // 上一页 33 | Prev Page 34 | HasPrev bool 35 | 36 | Current Page 37 | 38 | // 数据库的内容总数量 39 | TotalCount int64 40 | // 总页数 41 | TotalPage int 42 | } 43 | 44 | // Pagination 分页对象 45 | type Pagination struct { 46 | BaseURL string 47 | PerPage int 48 | Page int 49 | Count int64 50 | db *gorm.DB 51 | } 52 | 53 | // New 分页对象构建器 54 | // r —— 用来获取分页的 URL 参数,默认是 page,可通过 config/pagination.go 修改 55 | // db —— GORM 查询句柄,用以查询数据集和获取数据总数 56 | // baseURL —— 用以分页链接 57 | // PerPage —— 每页条数,传参为小于或者等于 0 时为默认值 10,可通过 config/pagination.go 修改 58 | func New(r *http.Request, db *gorm.DB, baseURL string, PerPage int) *Pagination { 59 | 60 | // 默认每页数量 61 | if PerPage <= 0 { 62 | PerPage = config.GetInt("pagination.perpage") 63 | } 64 | 65 | // 实例对象 66 | p := &Pagination{ 67 | db: db, 68 | PerPage: PerPage, 69 | Page: 1, 70 | Count: -1, 71 | } 72 | 73 | // 拼接 URL 74 | if strings.Contains(baseURL, "?") { 75 | p.BaseURL = baseURL + "&" + config.GetString("pagination.url_query") + "=" 76 | } else { 77 | p.BaseURL = baseURL + "?" + config.GetString("pagination.url_query") + "=" 78 | } 79 | 80 | // 设置当前页码 81 | p.SetPage(p.GetPageFromRequest(r)) 82 | 83 | return p 84 | } 85 | 86 | // Paging 返回渲染分页所需的数据 87 | func (p *Pagination) Paging() ViewData { 88 | 89 | return ViewData{ 90 | HasPages: p.HasPages(), 91 | 92 | Next: p.NewPage(p.NextPage()), 93 | HasNext: p.HasNext(), 94 | 95 | Prev: p.NewPage(p.PrevPage()), 96 | HasPrev: p.HasPrev(), 97 | 98 | Current: p.NewPage(p.CurrentPage()), 99 | TotalPage: p.TotalPage(), 100 | 101 | TotalCount: p.Count, 102 | } 103 | } 104 | 105 | // NewPage 设置当前页 106 | func (p Pagination) NewPage(page int) Page { 107 | return Page{ 108 | Number: page, 109 | URL: p.BaseURL + strconv.Itoa(page), 110 | } 111 | } 112 | 113 | // SetPage 设置当前页 114 | func (p *Pagination) SetPage(page int) { 115 | if page <= 0 { 116 | page = 1 117 | } 118 | 119 | p.Page = page 120 | } 121 | 122 | // CurrentPage 返回当前页码 123 | func (p Pagination) CurrentPage() int { 124 | totalPage := p.TotalPage() 125 | if totalPage == 0 { 126 | return 0 127 | } 128 | 129 | if p.Page > totalPage { 130 | return totalPage 131 | } 132 | 133 | return p.Page 134 | } 135 | 136 | // Results 返回请求数据,请注意 data 参数必须为 GROM 模型的 Slice 对象 137 | func (p Pagination) Results(data interface{}) error { 138 | var err error 139 | var offset int 140 | page := p.CurrentPage() 141 | if page == 0 { 142 | return err 143 | } 144 | 145 | if page > 1 { 146 | offset = (page - 1) * p.PerPage 147 | } 148 | 149 | return p.db.Preload(clause.Associations).Limit(p.PerPage).Offset(offset).Find(data).Error 150 | } 151 | 152 | // TotalCount 返回的是数据库里的条数 153 | func (p *Pagination) TotalCount() int64 { 154 | if p.Count == -1 { 155 | var count int64 156 | if err := p.db.Count(&count).Error; err != nil { 157 | return 0 158 | } 159 | p.Count = count 160 | } 161 | 162 | return p.Count 163 | } 164 | 165 | // HasPages 总页数大于 1 时会返回 true 166 | func (p *Pagination) HasPages() bool { 167 | n := p.TotalCount() 168 | return n > int64(p.PerPage) 169 | } 170 | 171 | // HasNext returns true if current page is not the last page 172 | func (p Pagination) HasNext() bool { 173 | totalPage := p.TotalPage() 174 | if totalPage == 0 { 175 | return false 176 | } 177 | 178 | page := p.CurrentPage() 179 | if page == 0 { 180 | return false 181 | } 182 | 183 | return page < totalPage 184 | } 185 | 186 | // PrevPage 前一页码,0 意味着这就是第一页 187 | func (p Pagination) PrevPage() int { 188 | hasPrev := p.HasPrev() 189 | 190 | if !hasPrev { 191 | return 0 192 | } 193 | 194 | page := p.CurrentPage() 195 | if page == 0 { 196 | return 0 197 | } 198 | 199 | return page - 1 200 | } 201 | 202 | // NextPage 下一页码,0 的话就是最后一页 203 | func (p Pagination) NextPage() int { 204 | hasNext := p.HasNext() 205 | if !hasNext { 206 | return 0 207 | } 208 | 209 | page := p.CurrentPage() 210 | if page == 0 { 211 | return 0 212 | } 213 | 214 | return page + 1 215 | } 216 | 217 | // HasPrev 如果当前页不为第一页,就返回 true 218 | func (p Pagination) HasPrev() bool { 219 | page := p.CurrentPage() 220 | if page == 0 { 221 | return false 222 | } 223 | 224 | return page > 1 225 | } 226 | 227 | // TotalPage 返回总页数 228 | func (p Pagination) TotalPage() int { 229 | count := p.TotalCount() 230 | if count == 0 { 231 | return 0 232 | } 233 | 234 | nums := int64(math.Ceil(float64(count) / float64(p.PerPage))) 235 | if nums == 0 { 236 | nums = 1 237 | } 238 | 239 | return int(nums) 240 | } 241 | 242 | // GetPageFromRequest 从 URL 中获取 page 参数 243 | func (p Pagination) GetPageFromRequest(r *http.Request) int { 244 | page := r.URL.Query().Get(config.GetString("pagination.url_query")) 245 | 246 | if len(page) > 0 { 247 | pageInt := types.StringToInt(page) 248 | if pageInt <= 0 { 249 | return 1 250 | } 251 | return pageInt 252 | } 253 | return 0 254 | } 255 | -------------------------------------------------------------------------------- /pkg/password/password.go: -------------------------------------------------------------------------------- 1 | // Package password 密码加密与校验 2 | package password 3 | 4 | import ( 5 | "goblog/pkg/logger" 6 | 7 | "golang.org/x/crypto/bcrypt" 8 | ) 9 | 10 | // Hash 使用 bcrypt 对密码进行加密 11 | func Hash(password string) string { 12 | // GenerateFromPassword 的第二个参数是 cost 值。建议大于 12,数值越大耗费时间越长 13 | bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) 14 | logger.LogError(err) 15 | 16 | return string(bytes) 17 | } 18 | 19 | // CheckHash 对比明文密码和数据库的哈希值 20 | func CheckHash(password, hash string) bool { 21 | err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) 22 | logger.LogError(err) 23 | return err == nil 24 | } 25 | 26 | // IsHashed 判断字符串是否是哈希过的数据 27 | func IsHashed(str string) bool { 28 | // bcrypt 加密后的长度等于 60 29 | return len(str) == 60 30 | } 31 | -------------------------------------------------------------------------------- /pkg/route/router.go: -------------------------------------------------------------------------------- 1 | // Package route 路由相关 2 | package route 3 | 4 | import ( 5 | "goblog/pkg/config" 6 | "goblog/pkg/logger" 7 | "net/http" 8 | 9 | "github.com/gorilla/mux" 10 | ) 11 | 12 | var route *mux.Router 13 | 14 | // SetRoute 设置路由实例,以供 Name2URL 等函数使用 15 | func SetRoute(r *mux.Router) { 16 | route = r 17 | } 18 | 19 | // Name2URL 通过路由名称来获取 URL 20 | func Name2URL(routeName string, pairs ...string) string { 21 | url, err := route.Get(routeName).URL(pairs...) 22 | if err != nil { 23 | logger.LogError(err) 24 | return "" 25 | } 26 | 27 | return config.GetString("app.url") + url.String() 28 | } 29 | 30 | // GetRouteVariable 获取 URI 路由参数 31 | func GetRouteVariable(parameterName string, r *http.Request) string { 32 | vars := mux.Vars(r) 33 | return vars[parameterName] 34 | } 35 | -------------------------------------------------------------------------------- /pkg/session/session.go: -------------------------------------------------------------------------------- 1 | // Package session 会话管理 2 | package session 3 | 4 | import ( 5 | "goblog/pkg/config" 6 | "goblog/pkg/logger" 7 | "net/http" 8 | 9 | "github.com/gorilla/sessions" 10 | ) 11 | 12 | // Store gorilla sessions 的存储库 13 | var Store = sessions.NewCookieStore([]byte(config.GetString("app.key"))) 14 | 15 | // Session 当前会话 16 | var Session *sessions.Session 17 | 18 | // Request 用以获取会话 19 | var Request *http.Request 20 | 21 | // Response 用以写入会话 22 | var Response http.ResponseWriter 23 | 24 | // StartSession 初始化会话,在中间件中调用 25 | func StartSession(w http.ResponseWriter, r *http.Request) { 26 | var err error 27 | 28 | // Store.Get() 的第二个参数是 Cookie 的名称 29 | // gorilla/sessions 支持多会话,本项目我们只使用单一会话即可 30 | Session, err = Store.Get(r, config.GetString("session.session_name")) 31 | logger.LogError(err) 32 | 33 | Request = r 34 | Response = w 35 | } 36 | 37 | // Put 写入键值对应的会话数据 38 | func Put(key string, value interface{}) { 39 | Session.Values[key] = value 40 | Save() 41 | } 42 | 43 | // Get 获取会话数据,获取数据时请做类型检测 44 | func Get(key string) interface{} { 45 | return Session.Values[key] 46 | } 47 | 48 | // Forget 删除某个会话项 49 | func Forget(key string) { 50 | delete(Session.Values, key) 51 | Save() 52 | } 53 | 54 | // Flush 删除当前会话 55 | func Flush() { 56 | Session.Options.MaxAge = -1 57 | Save() 58 | } 59 | 60 | // Save 保持会话 61 | func Save() { 62 | // 非 HTTPS 的链接无法使用 Secure 和 HttpOnly,浏览器会报错 63 | // Session.Options.Secure = true 64 | // Session.Options.HttpOnly = true 65 | err := Session.Save(Request, Response) 66 | logger.LogError(err) 67 | } 68 | -------------------------------------------------------------------------------- /pkg/types/converter.go: -------------------------------------------------------------------------------- 1 | // Package types 提供了一些类型转换的方法 2 | package types 3 | 4 | import ( 5 | "goblog/pkg/logger" 6 | "strconv" 7 | ) 8 | 9 | // Int64ToString 将 int64 转换为 string 10 | func Int64ToString(num int64) string { 11 | return strconv.FormatInt(num, 10) 12 | } 13 | 14 | // StringToUint64 将字符串转换为 uint64 15 | func StringToUint64(str string) uint64 { 16 | i, err := strconv.ParseUint(str, 10, 64) 17 | if err != nil { 18 | logger.LogError(err) 19 | } 20 | return i 21 | } 22 | 23 | // Uint64ToString 将 uint64 转换为 string 24 | func Uint64ToString(num uint64) string { 25 | return strconv.FormatUint(num, 10) 26 | } 27 | 28 | // StringToInt 将字符串转换为 int 29 | func StringToInt(str string) int { 30 | i, err := strconv.Atoi(str) 31 | if err != nil { 32 | logger.LogError(err) 33 | } 34 | return i 35 | } 36 | -------------------------------------------------------------------------------- /pkg/view/view.go: -------------------------------------------------------------------------------- 1 | // Package view 视图渲染 2 | package view 3 | 4 | import ( 5 | "embed" 6 | "goblog/app/models/category" 7 | "goblog/app/models/user" 8 | "goblog/pkg/auth" 9 | "goblog/pkg/flash" 10 | "goblog/pkg/logger" 11 | "goblog/pkg/route" 12 | "html/template" 13 | "io" 14 | "io/fs" 15 | "strings" 16 | ) 17 | 18 | // D 是 map[string]interface{} 的简写 19 | type D map[string]interface{} 20 | 21 | var TplFS embed.FS 22 | 23 | // Render 渲染通用视图 24 | func Render(w io.Writer, data D, tplFiles ...string) { 25 | RenderTemplate(w, "app", data, tplFiles...) 26 | } 27 | 28 | // RenderSimple 渲染简单的视图 29 | func RenderSimple(w io.Writer, data D, tplFiles ...string) { 30 | RenderTemplate(w, "simple", data, tplFiles...) 31 | } 32 | 33 | // RenderTemplate 渲染视图 34 | func RenderTemplate(w io.Writer, name string, data D, tplFiles ...string) { 35 | 36 | // 1. 通用模板数据 37 | data["isLogined"] = auth.Check() 38 | data["flash"] = flash.All() 39 | data["Users"], _ = user.All() 40 | data["Categories"], _ = category.All() 41 | 42 | // 2. 生成模板文件 43 | allFiles := getTemplateFiles(tplFiles...) 44 | 45 | // 3. 解析所有模板文件 46 | tmpl, err := template.New(""). 47 | Funcs(template.FuncMap{ 48 | "RouteName2URL": route.Name2URL, 49 | }).ParseFS(TplFS, allFiles...) 50 | logger.LogError(err) 51 | 52 | // 4. 渲染模板 53 | err = tmpl.ExecuteTemplate(w, name, data) 54 | logger.LogError(err) 55 | } 56 | 57 | func getTemplateFiles(tplFiles ...string) []string { 58 | // 1 设置模板相对路径 59 | viewDir := "resources/views/" 60 | 61 | // 2. 遍历传参文件列表 Slice,设置正确的路径,支持 dir.filename 语法糖 62 | for i, f := range tplFiles { 63 | tplFiles[i] = viewDir + strings.Replace(f, ".", "/", -1) + ".gohtml" 64 | } 65 | 66 | // 3. 所有布局模板文件 Slice 67 | layoutFiles, err := fs.Glob(TplFS, viewDir+"layouts/*.gohtml") 68 | logger.LogError(err) 69 | 70 | // 4. 合并所有文件 71 | return append(layoutFiles, tplFiles...) 72 | } 73 | -------------------------------------------------------------------------------- /public/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/summerblue/goblog/d0ca7cddc86f0b2adae9d70410c431962cb5cec2/public/.DS_Store -------------------------------------------------------------------------------- /public/css/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #F0F2F5; 3 | } -------------------------------------------------------------------------------- /public/js/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v5.3.3 (https://getbootstrap.com/) 3 | * Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 5 | */ 6 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("@popperjs/core")):"function"==typeof define&&define.amd?define(["@popperjs/core"],e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e(t.Popper)}(this,(function(t){"use strict";function e(t){const e=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(t)for(const i in t)if("default"!==i){const s=Object.getOwnPropertyDescriptor(t,i);Object.defineProperty(e,i,s.get?s:{enumerable:!0,get:()=>t[i]})}return e.default=t,Object.freeze(e)}const i=e(t),s=new Map,n={set(t,e,i){s.has(t)||s.set(t,new Map);const n=s.get(t);n.has(e)||0===n.size?n.set(e,i):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(n.keys())[0]}.`)},get:(t,e)=>s.has(t)&&s.get(t).get(e)||null,remove(t,e){if(!s.has(t))return;const i=s.get(t);i.delete(e),0===i.size&&s.delete(t)}},o="transitionend",r=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),a=t=>{t.dispatchEvent(new Event(o))},l=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),c=t=>l(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(r(t)):null,h=t=>{if(!l(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},d=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),u=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?u(t.parentNode):null},_=()=>{},g=t=>{t.offsetHeight},f=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,m=[],p=()=>"rtl"===document.documentElement.dir,b=t=>{var e;e=()=>{const e=f();if(e){const i=t.NAME,s=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=s,t.jQueryInterface)}},"loading"===document.readyState?(m.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of m)t()})),m.push(e)):e()},v=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,y=(t,e,i=!0)=>{if(!i)return void v(t);const s=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const s=Number.parseFloat(e),n=Number.parseFloat(i);return s||n?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let n=!1;const r=({target:i})=>{i===e&&(n=!0,e.removeEventListener(o,r),v(t))};e.addEventListener(o,r),setTimeout((()=>{n||a(e)}),s)},w=(t,e,i,s)=>{const n=t.length;let o=t.indexOf(e);return-1===o?!i&&s?t[n-1]:t[0]:(o+=i?1:-1,s&&(o=(o+n)%n),t[Math.max(0,Math.min(o,n-1))])},A=/[^.]*(?=\..*)\.|.*/,E=/\..*/,C=/::\d+$/,T={};let k=1;const $={mouseenter:"mouseover",mouseleave:"mouseout"},S=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function L(t,e){return e&&`${e}::${k++}`||t.uidEvent||k++}function O(t){const e=L(t);return t.uidEvent=e,T[e]=T[e]||{},T[e]}function I(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function D(t,e,i){const s="string"==typeof e,n=s?i:e||i;let o=M(t);return S.has(o)||(o=t),[s,n,o]}function N(t,e,i,s,n){if("string"!=typeof e||!t)return;let[o,r,a]=D(e,i,s);if(e in $){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=O(t),c=l[a]||(l[a]={}),h=I(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&n);const d=L(r,e.replace(A,"")),u=o?function(t,e,i){return function s(n){const o=t.querySelectorAll(e);for(let{target:r}=n;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return F(n,{delegateTarget:r}),s.oneOff&&j.off(t,n.type,e,i),i.apply(r,[n])}}(t,i,r):function(t,e){return function i(s){return F(s,{delegateTarget:t}),i.oneOff&&j.off(t,s.type,e),e.apply(t,[s])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=n,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function P(t,e,i,s,n){const o=I(e[i],s,n);o&&(t.removeEventListener(i,o,Boolean(n)),delete e[i][o.uidEvent])}function x(t,e,i,s){const n=e[i]||{};for(const[o,r]of Object.entries(n))o.includes(s)&&P(t,e,i,r.callable,r.delegationSelector)}function M(t){return t=t.replace(E,""),$[t]||t}const j={on(t,e,i,s){N(t,e,i,s,!1)},one(t,e,i,s){N(t,e,i,s,!0)},off(t,e,i,s){if("string"!=typeof e||!t)return;const[n,o,r]=D(e,i,s),a=r!==e,l=O(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))x(t,l,i,e.slice(1));for(const[i,s]of Object.entries(c)){const n=i.replace(C,"");a&&!e.includes(n)||P(t,l,r,s.callable,s.delegationSelector)}}else{if(!Object.keys(c).length)return;P(t,l,r,o,n?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const s=f();let n=null,o=!0,r=!0,a=!1;e!==M(e)&&s&&(n=s.Event(e,i),s(t).trigger(n),o=!n.isPropagationStopped(),r=!n.isImmediatePropagationStopped(),a=n.isDefaultPrevented());const l=F(new Event(e,{bubbles:o,cancelable:!0}),i);return a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&n&&n.preventDefault(),l}};function F(t,e={}){for(const[i,s]of Object.entries(e))try{t[i]=s}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>s})}return t}function z(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function H(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}const B={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${H(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${H(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const s of i){let i=s.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),e[i]=z(t.dataset[s])}return e},getDataAttribute:(t,e)=>z(t.getAttribute(`data-bs-${H(e)}`))};class q{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=l(e)?B.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...l(e)?B.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const[s,n]of Object.entries(e)){const e=t[s],o=l(e)?"element":null==(i=e)?`${i}`:Object.prototype.toString.call(i).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(n).test(o))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${s}" provided type "${o}" but expected type "${n}".`)}var i}}class W extends q{constructor(t,e){super(),(t=c(t))&&(this._element=t,this._config=this._getConfig(e),n.set(this._element,this.constructor.DATA_KEY,this))}dispose(){n.remove(this._element,this.constructor.DATA_KEY),j.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){y(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return n.get(c(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.3"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const R=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e?e.split(",").map((t=>r(t))).join(","):null},K={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let s=t.parentNode.closest(e);for(;s;)i.push(s),s=s.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(",");return this.find(e,t).filter((t=>!d(t)&&h(t)))},getSelectorFromElement(t){const e=R(t);return e&&K.findOne(e)?e:null},getElementFromSelector(t){const e=R(t);return e?K.findOne(e):null},getMultipleElementsFromSelector(t){const e=R(t);return e?K.find(e):[]}},V=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,s=t.NAME;j.on(document,i,`[data-bs-dismiss="${s}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),d(this))return;const n=K.getElementFromSelector(this)||this.closest(`.${s}`);t.getOrCreateInstance(n)[e]()}))},Q=".bs.alert",X=`close${Q}`,Y=`closed${Q}`;class U extends W{static get NAME(){return"alert"}close(){if(j.trigger(this._element,X).defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),j.trigger(this._element,Y),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=U.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}V(U,"close"),b(U);const G='[data-bs-toggle="button"]';class J extends W{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=J.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}j.on(document,"click.bs.button.data-api",G,(t=>{t.preventDefault();const e=t.target.closest(G);J.getOrCreateInstance(e).toggle()})),b(J);const Z=".bs.swipe",tt=`touchstart${Z}`,et=`touchmove${Z}`,it=`touchend${Z}`,st=`pointerdown${Z}`,nt=`pointerup${Z}`,ot={endCallback:null,leftCallback:null,rightCallback:null},rt={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class at extends q{constructor(t,e){super(),this._element=t,t&&at.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return ot}static get DefaultType(){return rt}static get NAME(){return"swipe"}dispose(){j.off(this._element,Z)}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),v(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&v(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(j.on(this._element,st,(t=>this._start(t))),j.on(this._element,nt,(t=>this._end(t))),this._element.classList.add("pointer-event")):(j.on(this._element,tt,(t=>this._start(t))),j.on(this._element,et,(t=>this._move(t))),j.on(this._element,it,(t=>this._end(t))))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const lt=".bs.carousel",ct=".data-api",ht="next",dt="prev",ut="left",_t="right",gt=`slide${lt}`,ft=`slid${lt}`,mt=`keydown${lt}`,pt=`mouseenter${lt}`,bt=`mouseleave${lt}`,vt=`dragstart${lt}`,yt=`load${lt}${ct}`,wt=`click${lt}${ct}`,At="carousel",Et="active",Ct=".active",Tt=".carousel-item",kt=Ct+Tt,$t={ArrowLeft:_t,ArrowRight:ut},St={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},Lt={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class Ot extends W{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=K.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===At&&this.cycle()}static get Default(){return St}static get DefaultType(){return Lt}static get NAME(){return"carousel"}next(){this._slide(ht)}nextWhenVisible(){!document.hidden&&h(this._element)&&this.next()}prev(){this._slide(dt)}pause(){this._isSliding&&a(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval((()=>this.nextWhenVisible()),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?j.one(this._element,ft,(()=>this.cycle())):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void j.one(this._element,ft,(()=>this.to(t)));const i=this._getItemIndex(this._getActive());if(i===t)return;const s=t>i?ht:dt;this._slide(s,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&j.on(this._element,mt,(t=>this._keydown(t))),"hover"===this._config.pause&&(j.on(this._element,pt,(()=>this.pause())),j.on(this._element,bt,(()=>this._maybeEnableCycle()))),this._config.touch&&at.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of K.find(".carousel-item img",this._element))j.on(t,vt,(t=>t.preventDefault()));const t={leftCallback:()=>this._slide(this._directionToOrder(ut)),rightCallback:()=>this._slide(this._directionToOrder(_t)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((()=>this._maybeEnableCycle()),500+this._config.interval))}};this._swipeHelper=new at(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=$t[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=K.findOne(Ct,this._indicatorsElement);e.classList.remove(Et),e.removeAttribute("aria-current");const i=K.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(Et),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),s=t===ht,n=e||w(this._getItems(),i,s,this._config.wrap);if(n===i)return;const o=this._getItemIndex(n),r=e=>j.trigger(this._element,e,{relatedTarget:n,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r(gt).defaultPrevented)return;if(!i||!n)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=n;const l=s?"carousel-item-start":"carousel-item-end",c=s?"carousel-item-next":"carousel-item-prev";n.classList.add(c),g(n),i.classList.add(l),n.classList.add(l),this._queueCallback((()=>{n.classList.remove(l,c),n.classList.add(Et),i.classList.remove(Et,c,l),this._isSliding=!1,r(ft)}),i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return K.findOne(kt,this._element)}_getItems(){return K.find(Tt,this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return p()?t===ut?dt:ht:t===ut?ht:dt}_orderToDirection(t){return p()?t===dt?ut:_t:t===dt?_t:ut}static jQueryInterface(t){return this.each((function(){const e=Ot.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)}))}}j.on(document,wt,"[data-bs-slide], [data-bs-slide-to]",(function(t){const e=K.getElementFromSelector(this);if(!e||!e.classList.contains(At))return;t.preventDefault();const i=Ot.getOrCreateInstance(e),s=this.getAttribute("data-bs-slide-to");return s?(i.to(s),void i._maybeEnableCycle()):"next"===B.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())})),j.on(window,yt,(()=>{const t=K.find('[data-bs-ride="carousel"]');for(const e of t)Ot.getOrCreateInstance(e)})),b(Ot);const It=".bs.collapse",Dt=`show${It}`,Nt=`shown${It}`,Pt=`hide${It}`,xt=`hidden${It}`,Mt=`click${It}.data-api`,jt="show",Ft="collapse",zt="collapsing",Ht=`:scope .${Ft} .${Ft}`,Bt='[data-bs-toggle="collapse"]',qt={parent:null,toggle:!0},Wt={parent:"(null|element)",toggle:"boolean"};class Rt extends W{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const i=K.find(Bt);for(const t of i){const e=K.getSelectorFromElement(t),i=K.find(e).filter((t=>t===this._element));null!==e&&i.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return qt}static get DefaultType(){return Wt}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>Rt.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(j.trigger(this._element,Dt).defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(Ft),this._element.classList.add(zt),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(zt),this._element.classList.add(Ft,jt),this._element.style[e]="",j.trigger(this._element,Nt)}),this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(j.trigger(this._element,Pt).defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,g(this._element),this._element.classList.add(zt),this._element.classList.remove(Ft,jt);for(const t of this._triggerArray){const e=K.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(zt),this._element.classList.add(Ft),j.trigger(this._element,xt)}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(jt)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=c(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(Bt);for(const e of t){const t=K.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=K.find(Ht,this._config.parent);return K.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const i=Rt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}j.on(document,Mt,Bt,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of K.getMultipleElementsFromSelector(this))Rt.getOrCreateInstance(t,{toggle:!1}).toggle()})),b(Rt);const Kt="dropdown",Vt=".bs.dropdown",Qt=".data-api",Xt="ArrowUp",Yt="ArrowDown",Ut=`hide${Vt}`,Gt=`hidden${Vt}`,Jt=`show${Vt}`,Zt=`shown${Vt}`,te=`click${Vt}${Qt}`,ee=`keydown${Vt}${Qt}`,ie=`keyup${Vt}${Qt}`,se="show",ne='[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled)',oe=`${ne}.${se}`,re=".dropdown-menu",ae=p()?"top-end":"top-start",le=p()?"top-start":"top-end",ce=p()?"bottom-end":"bottom-start",he=p()?"bottom-start":"bottom-end",de=p()?"left-start":"right-start",ue=p()?"right-start":"left-start",_e={autoClose:!0,boundary:"clippingParents",display:"dynamic",offset:[0,2],popperConfig:null,reference:"toggle"},ge={autoClose:"(boolean|string)",boundary:"(string|element)",display:"string",offset:"(array|string|function)",popperConfig:"(null|object|function)",reference:"(string|element|object)"};class fe extends W{constructor(t,e){super(t,e),this._popper=null,this._parent=this._element.parentNode,this._menu=K.next(this._element,re)[0]||K.prev(this._element,re)[0]||K.findOne(re,this._parent),this._inNavbar=this._detectNavbar()}static get Default(){return _e}static get DefaultType(){return ge}static get NAME(){return Kt}toggle(){return this._isShown()?this.hide():this.show()}show(){if(d(this._element)||this._isShown())return;const t={relatedTarget:this._element};if(!j.trigger(this._element,Jt,t).defaultPrevented){if(this._createPopper(),"ontouchstart"in document.documentElement&&!this._parent.closest(".navbar-nav"))for(const t of[].concat(...document.body.children))j.on(t,"mouseover",_);this._element.focus(),this._element.setAttribute("aria-expanded",!0),this._menu.classList.add(se),this._element.classList.add(se),j.trigger(this._element,Zt,t)}}hide(){if(d(this._element)||!this._isShown())return;const t={relatedTarget:this._element};this._completeHide(t)}dispose(){this._popper&&this._popper.destroy(),super.dispose()}update(){this._inNavbar=this._detectNavbar(),this._popper&&this._popper.update()}_completeHide(t){if(!j.trigger(this._element,Ut,t).defaultPrevented){if("ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))j.off(t,"mouseover",_);this._popper&&this._popper.destroy(),this._menu.classList.remove(se),this._element.classList.remove(se),this._element.setAttribute("aria-expanded","false"),B.removeDataAttribute(this._menu,"popper"),j.trigger(this._element,Gt,t)}}_getConfig(t){if("object"==typeof(t=super._getConfig(t)).reference&&!l(t.reference)&&"function"!=typeof t.reference.getBoundingClientRect)throw new TypeError(`${Kt.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`);return t}_createPopper(){if(void 0===i)throw new TypeError("Bootstrap's dropdowns require Popper (https://popper.js.org)");let t=this._element;"parent"===this._config.reference?t=this._parent:l(this._config.reference)?t=c(this._config.reference):"object"==typeof this._config.reference&&(t=this._config.reference);const e=this._getPopperConfig();this._popper=i.createPopper(t,this._menu,e)}_isShown(){return this._menu.classList.contains(se)}_getPlacement(){const t=this._parent;if(t.classList.contains("dropend"))return de;if(t.classList.contains("dropstart"))return ue;if(t.classList.contains("dropup-center"))return"top";if(t.classList.contains("dropdown-center"))return"bottom";const e="end"===getComputedStyle(this._menu).getPropertyValue("--bs-position").trim();return t.classList.contains("dropup")?e?le:ae:e?he:ce}_detectNavbar(){return null!==this._element.closest(".navbar")}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(B.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,...v(this._config.popperConfig,[t])}}_selectMenuItem({key:t,target:e}){const i=K.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter((t=>h(t)));i.length&&w(i,e,t===Yt,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=fe.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=K.find(oe);for(const i of e){const e=fe.getInstance(i);if(!e||!1===e._config.autoClose)continue;const s=t.composedPath(),n=s.includes(e._menu);if(s.includes(e._element)||"inside"===e._config.autoClose&&!n||"outside"===e._config.autoClose&&n)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,s=[Xt,Yt].includes(t.key);if(!s&&!i)return;if(e&&!i)return;t.preventDefault();const n=this.matches(ne)?this:K.prev(this,ne)[0]||K.next(this,ne)[0]||K.findOne(ne,t.delegateTarget.parentNode),o=fe.getOrCreateInstance(n);if(s)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),n.focus())}}j.on(document,ee,ne,fe.dataApiKeydownHandler),j.on(document,ee,re,fe.dataApiKeydownHandler),j.on(document,te,fe.clearMenus),j.on(document,ie,fe.clearMenus),j.on(document,te,ne,(function(t){t.preventDefault(),fe.getOrCreateInstance(this).toggle()})),b(fe);const me="backdrop",pe="show",be=`mousedown.bs.${me}`,ve={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},ye={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class we extends q{constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return ve}static get DefaultType(){return ye}static get NAME(){return me}show(t){if(!this._config.isVisible)return void v(t);this._append();const e=this._getElement();this._config.isAnimated&&g(e),e.classList.add(pe),this._emulateAnimation((()=>{v(t)}))}hide(t){this._config.isVisible?(this._getElement().classList.remove(pe),this._emulateAnimation((()=>{this.dispose(),v(t)}))):v(t)}dispose(){this._isAppended&&(j.off(this._element,be),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=c(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),j.on(t,be,(()=>{v(this._config.clickCallback)})),this._isAppended=!0}_emulateAnimation(t){y(t,this._getElement(),this._config.isAnimated)}}const Ae=".bs.focustrap",Ee=`focusin${Ae}`,Ce=`keydown.tab${Ae}`,Te="backward",ke={autofocus:!0,trapElement:null},$e={autofocus:"boolean",trapElement:"element"};class Se extends q{constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return ke}static get DefaultType(){return $e}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),j.off(document,Ae),j.on(document,Ee,(t=>this._handleFocusin(t))),j.on(document,Ce,(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,j.off(document,Ae))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=K.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===Te?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?Te:"forward")}}const Le=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",Oe=".sticky-top",Ie="padding-right",De="margin-right";class Ne{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,Ie,(e=>e+t)),this._setElementAttributes(Le,Ie,(e=>e+t)),this._setElementAttributes(Oe,De,(e=>e-t))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,Ie),this._resetElementAttributes(Le,Ie),this._resetElementAttributes(Oe,De)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const s=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+s)return;this._saveInitialAttribute(t,e);const n=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(n))}px`)}))}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&B.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=B.getDataAttribute(t,e);null!==i?(B.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)}))}_applyManipulationCallback(t,e){if(l(t))e(t);else for(const i of K.find(t,this._element))e(i)}}const Pe=".bs.modal",xe=`hide${Pe}`,Me=`hidePrevented${Pe}`,je=`hidden${Pe}`,Fe=`show${Pe}`,ze=`shown${Pe}`,He=`resize${Pe}`,Be=`click.dismiss${Pe}`,qe=`mousedown.dismiss${Pe}`,We=`keydown.dismiss${Pe}`,Re=`click${Pe}.data-api`,Ke="modal-open",Ve="show",Qe="modal-static",Xe={backdrop:!0,focus:!0,keyboard:!0},Ye={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class Ue extends W{constructor(t,e){super(t,e),this._dialog=K.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new Ne,this._addEventListeners()}static get Default(){return Xe}static get DefaultType(){return Ye}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||j.trigger(this._element,Fe,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(Ke),this._adjustDialog(),this._backdrop.show((()=>this._showElement(t))))}hide(){this._isShown&&!this._isTransitioning&&(j.trigger(this._element,xe).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(Ve),this._queueCallback((()=>this._hideModal()),this._element,this._isAnimated())))}dispose(){j.off(window,Pe),j.off(this._dialog,Pe),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new we({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new Se({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=K.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),g(this._element),this._element.classList.add(Ve),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,j.trigger(this._element,ze,{relatedTarget:t})}),this._dialog,this._isAnimated())}_addEventListeners(){j.on(this._element,We,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())})),j.on(window,He,(()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()})),j.on(this._element,qe,(t=>{j.one(this._element,Be,(e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())}))}))}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(Ke),this._resetAdjustments(),this._scrollBar.reset(),j.trigger(this._element,je)}))}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(j.trigger(this._element,Me).defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(Qe)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(Qe),this._queueCallback((()=>{this._element.classList.remove(Qe),this._queueCallback((()=>{this._element.style.overflowY=e}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=p()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=p()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=Ue.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}j.on(document,Re,'[data-bs-toggle="modal"]',(function(t){const e=K.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),j.one(e,Fe,(t=>{t.defaultPrevented||j.one(e,je,(()=>{h(this)&&this.focus()}))}));const i=K.findOne(".modal.show");i&&Ue.getInstance(i).hide(),Ue.getOrCreateInstance(e).toggle(this)})),V(Ue),b(Ue);const Ge=".bs.offcanvas",Je=".data-api",Ze=`load${Ge}${Je}`,ti="show",ei="showing",ii="hiding",si=".offcanvas.show",ni=`show${Ge}`,oi=`shown${Ge}`,ri=`hide${Ge}`,ai=`hidePrevented${Ge}`,li=`hidden${Ge}`,ci=`resize${Ge}`,hi=`click${Ge}${Je}`,di=`keydown.dismiss${Ge}`,ui={backdrop:!0,keyboard:!0,scroll:!1},_i={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class gi extends W{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return ui}static get DefaultType(){return _i}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||j.trigger(this._element,ni,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new Ne).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(ei),this._queueCallback((()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(ti),this._element.classList.remove(ei),j.trigger(this._element,oi,{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(j.trigger(this._element,ri).defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add(ii),this._backdrop.hide(),this._queueCallback((()=>{this._element.classList.remove(ti,ii),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new Ne).reset(),j.trigger(this._element,li)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new we({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():j.trigger(this._element,ai)}:null})}_initializeFocusTrap(){return new Se({trapElement:this._element})}_addEventListeners(){j.on(this._element,di,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():j.trigger(this._element,ai))}))}static jQueryInterface(t){return this.each((function(){const e=gi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}j.on(document,hi,'[data-bs-toggle="offcanvas"]',(function(t){const e=K.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),d(this))return;j.one(e,li,(()=>{h(this)&&this.focus()}));const i=K.findOne(si);i&&i!==e&&gi.getInstance(i).hide(),gi.getOrCreateInstance(e).toggle(this)})),j.on(window,Ze,(()=>{for(const t of K.find(si))gi.getOrCreateInstance(t).show()})),j.on(window,ci,(()=>{for(const t of K.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&gi.getOrCreateInstance(t).hide()})),V(gi),b(gi);const fi={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},mi=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),pi=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,bi=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!mi.has(i)||Boolean(pi.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(i)))},vi={allowList:fi,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},yi={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},wi={entry:"(string|element|function|null)",selector:"(string|element)"};class Ai extends q{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return vi}static get DefaultType(){return yi}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},wi)}_setContent(t,e,i){const s=K.findOne(i,t);s&&((e=this._resolvePossibleFunction(e))?l(e)?this._putElementInTemplate(c(e),s):this._config.html?s.innerHTML=this._maybeSanitize(e):s.textContent=e:s.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const s=(new window.DOMParser).parseFromString(t,"text/html"),n=[].concat(...s.body.querySelectorAll("*"));for(const t of n){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const s=[].concat(...t.attributes),n=[].concat(e["*"]||[],e[i]||[]);for(const e of s)bi(e,n)||t.removeAttribute(e.nodeName)}return s.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return v(t,[this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Ei=new Set(["sanitize","allowList","sanitizeFn"]),Ci="fade",Ti="show",ki=".modal",$i="hide.bs.modal",Si="hover",Li="focus",Oi={AUTO:"auto",TOP:"top",RIGHT:p()?"left":"right",BOTTOM:"bottom",LEFT:p()?"right":"left"},Ii={allowList:fi,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},Di={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class Ni extends W{constructor(t,e){if(void 0===i)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return Ii}static get DefaultType(){return Di}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),j.off(this._element.closest(ki),$i,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=j.trigger(this._element,this.constructor.eventName("show")),e=(u(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:s}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(s.append(i),j.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(i),i.classList.add(Ti),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))j.on(t,"mouseover",_);this._queueCallback((()=>{j.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(this._isShown()&&!j.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove(Ti),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))j.off(t,"mouseover",_);this._activeTrigger.click=!1,this._activeTrigger[Li]=!1,this._activeTrigger[Si]=!1,this._isHovered=null,this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),j.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(Ci,Ti),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(Ci),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new Ai({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(Ci)}_isShown(){return this.tip&&this.tip.classList.contains(Ti)}_createPopper(t){const e=v(this._config.placement,[this,t,this._element]),s=Oi[e.toUpperCase()];return i.createPopper(this._element,t,this._getPopperConfig(s))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return v(t,[this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...v(this._config.popperConfig,[e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)j.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===Si?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===Si?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");j.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?Li:Si]=!0,e._enter()})),j.on(this._element,i,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?Li:Si]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},j.on(this._element.closest(ki),$i,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=B.getDataAttributes(this._element);for(const t of Object.keys(e))Ei.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:c(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,i]of Object.entries(this._config))this.constructor.Default[e]!==i&&(t[e]=i);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=Ni.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}b(Ni);const Pi={...Ni.Default,content:"",offset:[0,8],placement:"right",template:'',trigger:"click"},xi={...Ni.DefaultType,content:"(null|string|element|function)"};class Mi extends Ni{static get Default(){return Pi}static get DefaultType(){return xi}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{".popover-header":this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each((function(){const e=Mi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}b(Mi);const ji=".bs.scrollspy",Fi=`activate${ji}`,zi=`click${ji}`,Hi=`load${ji}.data-api`,Bi="active",qi="[href]",Wi=".nav-link",Ri=`${Wi}, .nav-item > ${Wi}, .list-group-item`,Ki={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},Vi={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class Qi extends W{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return Ki}static get DefaultType(){return Vi}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=c(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map((t=>Number.parseFloat(t)))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(j.off(this._config.target,zi),j.on(this._config.target,zi,qi,(t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,s=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:s,behavior:"smooth"});i.scrollTop=s}})))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver((t=>this._observerCallback(t)),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},s=(this._rootElement||document.documentElement).scrollTop,n=s>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=s;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(n&&t){if(i(o),!s)return}else n||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=K.find(qi,this._config.target);for(const e of t){if(!e.hash||d(e))continue;const t=K.findOne(decodeURI(e.hash),this._element);h(t)&&(this._targetLinks.set(decodeURI(e.hash),e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(Bi),this._activateParents(t),j.trigger(this._element,Fi,{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))K.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(Bi);else for(const e of K.parents(t,".nav, .list-group"))for(const t of K.prev(e,Ri))t.classList.add(Bi)}_clearActiveClass(t){t.classList.remove(Bi);const e=K.find(`${qi}.${Bi}`,t);for(const t of e)t.classList.remove(Bi)}static jQueryInterface(t){return this.each((function(){const e=Qi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}j.on(window,Hi,(()=>{for(const t of K.find('[data-bs-spy="scroll"]'))Qi.getOrCreateInstance(t)})),b(Qi);const Xi=".bs.tab",Yi=`hide${Xi}`,Ui=`hidden${Xi}`,Gi=`show${Xi}`,Ji=`shown${Xi}`,Zi=`click${Xi}`,ts=`keydown${Xi}`,es=`load${Xi}`,is="ArrowLeft",ss="ArrowRight",ns="ArrowUp",os="ArrowDown",rs="Home",as="End",ls="active",cs="fade",hs="show",ds=".dropdown-toggle",us=`:not(${ds})`,_s='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',gs=`.nav-link${us}, .list-group-item${us}, [role="tab"]${us}, ${_s}`,fs=`.${ls}[data-bs-toggle="tab"], .${ls}[data-bs-toggle="pill"], .${ls}[data-bs-toggle="list"]`;class ms extends W{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),j.on(this._element,ts,(t=>this._keydown(t))))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?j.trigger(e,Yi,{relatedTarget:t}):null;j.trigger(t,Gi,{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(ls),this._activate(K.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),j.trigger(t,Ji,{relatedTarget:e})):t.classList.add(hs)}),t,t.classList.contains(cs)))}_deactivate(t,e){t&&(t.classList.remove(ls),t.blur(),this._deactivate(K.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),j.trigger(t,Ui,{relatedTarget:e})):t.classList.remove(hs)}),t,t.classList.contains(cs)))}_keydown(t){if(![is,ss,ns,os,rs,as].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=this._getChildren().filter((t=>!d(t)));let i;if([rs,as].includes(t.key))i=e[t.key===rs?0:e.length-1];else{const s=[ss,os].includes(t.key);i=w(e,t.target,s,!0)}i&&(i.focus({preventScroll:!0}),ms.getOrCreateInstance(i).show())}_getChildren(){return K.find(gs,this._parent)}_getActiveElem(){return this._getChildren().find((t=>this._elemIsActive(t)))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=K.getElementFromSelector(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const s=(t,s)=>{const n=K.findOne(t,i);n&&n.classList.toggle(s,e)};s(ds,ls),s(".dropdown-menu",hs),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(ls)}_getInnerElement(t){return t.matches(gs)?t:K.findOne(gs,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each((function(){const e=ms.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}j.on(document,Zi,_s,(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),d(this)||ms.getOrCreateInstance(this).show()})),j.on(window,es,(()=>{for(const t of K.find(fs))ms.getOrCreateInstance(t)})),b(ms);const ps=".bs.toast",bs=`mouseover${ps}`,vs=`mouseout${ps}`,ys=`focusin${ps}`,ws=`focusout${ps}`,As=`hide${ps}`,Es=`hidden${ps}`,Cs=`show${ps}`,Ts=`shown${ps}`,ks="hide",$s="show",Ss="showing",Ls={animation:"boolean",autohide:"boolean",delay:"number"},Os={animation:!0,autohide:!0,delay:5e3};class Is extends W{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return Os}static get DefaultType(){return Ls}static get NAME(){return"toast"}show(){j.trigger(this._element,Cs).defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(ks),g(this._element),this._element.classList.add($s,Ss),this._queueCallback((()=>{this._element.classList.remove(Ss),j.trigger(this._element,Ts),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this.isShown()&&(j.trigger(this._element,As).defaultPrevented||(this._element.classList.add(Ss),this._queueCallback((()=>{this._element.classList.add(ks),this._element.classList.remove(Ss,$s),j.trigger(this._element,Es)}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove($s),super.dispose()}isShown(){return this._element.classList.contains($s)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){j.on(this._element,bs,(t=>this._onInteraction(t,!0))),j.on(this._element,vs,(t=>this._onInteraction(t,!1))),j.on(this._element,ys,(t=>this._onInteraction(t,!0))),j.on(this._element,ws,(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=Is.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return V(Is),b(Is),{Alert:U,Button:J,Carousel:Ot,Collapse:Rt,Dropdown:fe,Modal:Ue,Offcanvas:gi,Popover:Mi,ScrollSpy:Qi,Tab:ms,Toast:Is,Tooltip:Ni}})); 7 | //# sourceMappingURL=bootstrap.min.js.map -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## 说明 2 | 3 | Go 非常适用于开发 Web 应用程序,但是因其简单的语言特性,入门经常会遇到问题,以至于让人觉得不知所措。 4 | 5 | 你有没有这样的情况: 6 | 7 | > 在阅读了无数悠长的课程 如 [《Go 入门指南》](https://learnku.com/docs/the-way-to-go) 、大量的免费快速入门的博文(语法、数据库连接、标准库教程等…),以及看了无数代码示例后,仍在疑惑自己是否真正掌握 Go 编程? 8 | 9 | 问题在于 **我们阅读的每个教程都过于简化**,我们需要的是一门综合课程,展示所有部分如何协同工作。 10 | 11 | ## 盖房子 12 | 13 | 学习 Go 语法或简单的示例无法让你学会构建 Web 应用。这有点像有人递给你一个工具箱,教我如何使用每种工具,你仍然无法掌握『盖房子』一样。 14 | 15 | 开发 Web 应用需要复杂的系统性知识。涉及的知识点非常广泛,例如表单验证、登录授权验证、共享数据库连接、密码哈希、代码组织(MVC/RESTful)等,我们需要利用一个项目,把这些知识点组合在一起。 16 | 17 | 本课程,我们会开发 goblog 这个项目,从打地基开始,一起感受盖房子的所有过程。 18 | 19 | ## 最佳实践 20 | 21 | 本课程虽是构建 Web 程序,但也可作为学习 Go 编程的入门课程。 22 | 23 | 编码上我们遵循 Go 官方推荐的编码规范和最佳实践。 24 | 25 | 功能的开发上,我们会优先使用 Go 标准库来解决一些特定问题,且会告诉你标准库的局限性,然后我们会使用第三方库来做重构,以期在掌握 Go 基础知识的同时告知你解决此问题的最佳方案。Go 标准库好用,但是大部分情况下我们会选择构建在其之上的第三方库来完成任务,因为工作中我们就是这么干的。 26 | 27 | 我们构建的不是一个玩具项目,而是可直接拿来用在生产环境中,或者作为某个大型项目的地基项目。而构建此项目的知识,你可以放心的在工作生产中使用。 28 | 29 | > 讨论请前往:[公告:最适合 Laravel 开发者学习的 Go Web 实战课程](https://learnku.com/go/t/51595) 30 | 31 | 32 | ## 运行代码 33 | 34 | ### 1. 下载代码 35 | 36 | ``` 37 | git clone https://github.com/summerblue/goblog.git 38 | ``` 39 | 40 | ### 2. 配置环境变量 41 | 42 | ``` 43 | cd goblog 44 | cp .env.example .env 45 | ``` 46 | 47 | 使用编辑器打开 .env 文件,并对里面的信息做相应配置,尤其是数据库信息。 48 | 49 | ### 3. 运行代码 50 | 51 | ``` 52 | go run . 53 | ``` 54 | 55 | ### 4. 访问 goblog 56 | 57 | http://localhost:3000/ 58 | -------------------------------------------------------------------------------- /resources/views/articles/_article_meta.gohtml: -------------------------------------------------------------------------------- 1 | {{define "article-meta"}} 2 |

3 | 发布于 {{ .CreatedAtDate }} 4 | by {{ .User.Name }} 5 |

6 | {{ end }} -------------------------------------------------------------------------------- /resources/views/articles/_form_field.gohtml: -------------------------------------------------------------------------------- 1 | 2 | {{define "form-fields"}} 3 |
4 | 5 | 6 | {{ with .Errors.title }} 7 |
8 | {{ . }} 9 |
10 | {{ end }} 11 |
12 | 13 |
14 | 15 | 16 | {{ with .Errors.body }} 17 |
18 | {{ . }} 19 |
20 | {{ end }} 21 |
22 | {{ end }} -------------------------------------------------------------------------------- /resources/views/articles/create.gohtml: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | 创建文章 3 | {{end}} 4 | 5 | {{define "main"}} 6 |
7 |
8 | 9 |

新建文章

10 | 11 |
12 | 13 | {{template "form-fields" . }} 14 | 15 | 16 | 17 |
18 | 19 |
20 |
21 | 22 | {{end}} -------------------------------------------------------------------------------- /resources/views/articles/edit.gohtml: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | 编辑文章 3 | {{end}} 4 | 5 | {{define "main"}} 6 |
7 |
8 | 9 |

编辑文章

10 | 11 |
12 | 13 | {{template "form-fields" . }} 14 | 15 | 16 | 17 |
18 | 19 |
20 |
21 | 22 | {{end}} -------------------------------------------------------------------------------- /resources/views/articles/index.gohtml: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | 所有文章 —— 我的技术博客 3 | {{end}} 4 | 5 | {{define "main"}} 6 |
7 | 8 | {{ if .Articles }} 9 | 10 | {{ range $key, $article := .Articles }} 11 |
12 |

{{ $article.Title }}

13 | {{template "article-meta" $article }} 14 |
15 | {{ $article.Body }} 16 |
17 | {{ end }} 18 | 19 | {{ else }} 20 | 21 |
22 |

暂无文章!

23 |
24 | 25 | {{ end }} 26 | 27 | 28 | {{template "pagination" .PagerData }} 29 | 30 |
31 | {{end}} -------------------------------------------------------------------------------- /resources/views/articles/show.gohtml: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | {{ .Article.Title }} 3 | {{end}} 4 | 5 | {{define "main"}} 6 |
7 | 8 |
9 |

{{ .Article.Title }}

10 | 11 | {{template "article-meta" .Article }} 12 | 13 |
14 | {{ .Article.Body }} 15 | 16 | {{ if .CanModifyArticle }} 17 |
18 | 19 | 编辑 20 |
21 | {{end}} 22 | 23 |
24 |
25 | 26 | {{end}} -------------------------------------------------------------------------------- /resources/views/auth/login.gohtml: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | 登录 3 | {{end}} 4 | 5 | {{define "main"}} 6 |
7 | 8 |

用户登录

9 | 10 |
11 | 12 |
13 | 14 |
15 | 16 | {{ with .Error }} 17 |
18 |

{{ . }}

19 |
20 | {{ end }} 21 |
22 |
23 | 24 |
25 | 26 |
27 | 28 |
29 |
30 | 31 |
32 |
33 | 36 |
37 |
38 | 39 |
40 | 41 |
42 | 43 | 44 |
45 | 返回首页 46 | 找回密码 47 |
48 | 49 | {{end}} -------------------------------------------------------------------------------- /resources/views/auth/register.gohtml: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | 注册 3 | {{end}} 4 | 5 | {{define "main"}} 6 |
7 | 8 |

用户注册

9 | 10 |
11 | 12 |
13 | 14 |
15 | 16 | {{ with .Errors.name }} 17 | {{ template "invalid-feedback" . }} 18 | {{ end }} 19 |
20 |
21 | 22 |
23 | 24 |
25 | 26 | {{ with .Errors.email }} 27 | {{ template "invalid-feedback" . }} 28 | {{ end }} 29 |
30 |
31 | 32 |
33 | 34 |
35 | 36 | {{ with .Errors.password }} 37 | {{ template "invalid-feedback" . }} 38 | {{ end }} 39 |
40 |
41 | 42 |
43 | 44 |
45 | 46 | {{ with .Errors.password_confirm }} 47 | {{ template "invalid-feedback" . }} 48 | {{ end }} 49 |
50 |
51 | 52 |
53 |
54 | 57 |
58 |
59 | 60 |
61 | 62 |
63 | 64 | 65 |
66 | 返回首页 67 | 登录 68 |
69 | 70 | {{end}} -------------------------------------------------------------------------------- /resources/views/categories/create.gohtml: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | 创建文章分类 3 | {{end}} 4 | 5 | {{define "main"}} 6 |
7 |
8 | 9 |

新建文章分类

10 | 11 |
12 | 13 |
14 | 15 | 16 | {{ with .Errors.name }} 17 |
18 | {{ . }} 19 |
20 | {{ end }} 21 |
22 | 23 | 24 | 25 |
26 | 27 |
28 |
29 | 30 | {{end}} -------------------------------------------------------------------------------- /resources/views/layouts/_form_error_feedback.gohtml: -------------------------------------------------------------------------------- 1 | {{define "invalid-feedback"}} 2 |
3 | {{ range $message := . }} 4 |

{{ $message }}

5 | {{ end }} 6 |
7 | {{end}} -------------------------------------------------------------------------------- /resources/views/layouts/_messages.gohtml: -------------------------------------------------------------------------------- 1 | {{define "messages"}} 2 | 3 | {{ if .flash.danger }} 4 |
5 |

6 | {{ .flash.danger }} 7 |

8 |
9 | {{ end }} 10 | 11 | {{ if .flash.warning }} 12 |
13 |

14 | {{ .flash.warning }} 15 |

16 |
17 | {{ end }} 18 | 19 | {{ if .flash.success }} 20 |
21 |

22 | {{ .flash.success }} 23 |

24 |
25 | {{ end }} 26 | 27 | {{ if .flash.info }} 28 |
29 |

30 | {{ .flash.info }} 31 |

32 |
33 | {{ end }} 34 | 35 | {{end}} -------------------------------------------------------------------------------- /resources/views/layouts/_pagination.gohtml: -------------------------------------------------------------------------------- 1 | {{ define "pagination" }} 2 | 3 | {{ if .HasPages }} 4 | 19 | {{ end }} 20 | 21 | {{ end }} -------------------------------------------------------------------------------- /resources/views/layouts/app.gohtml: -------------------------------------------------------------------------------- 1 | {{define "app"}} 2 | 3 | 4 | 5 | 6 | {{template "title" .}} 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 | 16 | {{template "messages" .}} 17 | 18 | {{template "sidebar" .}} 19 | 20 | {{template "main" .}} 21 | 22 |
23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | {{end}} -------------------------------------------------------------------------------- /resources/views/layouts/sidebar.gohtml: -------------------------------------------------------------------------------- 1 | {{define "sidebar"}} 2 |
3 |
4 |

GoBlog

5 |

摒弃世俗浮躁,追求技术精湛

6 |
7 | 8 |
9 |
分类
10 |
    11 | {{ range $key, $category := .Categories }} 12 |
  1. {{ $category.Name }}
  2. 13 | {{ end }} 14 |
  3. + 新建分类
  4. 15 |
16 |
17 | 18 | {{ if .Users }} 19 |
20 |
作者
21 |
    22 | {{ range $key, $user := .Users }} 23 |
  1. {{ $user.Name }}
  2. 24 | {{ end }} 25 |
26 |
27 | {{ end }} 28 | 29 |
30 |
链接
31 |
    32 |
  1. 关于我们
  2. 33 | {{ if .isLogined }} 34 |
  3. 开始写作
  4. 35 |
  5. 36 |
    37 | 38 |
    39 |
  6. 40 | {{ else }} 41 |
  7. 注册
  8. 42 |
  9. 登录
  10. 43 | {{ end }} 44 |
45 |
46 |
47 | {{end}} -------------------------------------------------------------------------------- /resources/views/layouts/simple.gohtml: -------------------------------------------------------------------------------- 1 | {{define "simple"}} 2 | 3 | 4 | 5 | 6 | {{template "title" .}} 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 | 16 |
17 | {{template "main" .}} 18 |
19 | 20 |
21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | {{end}} -------------------------------------------------------------------------------- /routes/web.go: -------------------------------------------------------------------------------- 1 | // Package routes 存放应用路由 2 | package routes 3 | 4 | import ( 5 | "goblog/app/http/controllers" 6 | "goblog/app/http/middlewares" 7 | "net/http" 8 | 9 | "github.com/gorilla/mux" 10 | ) 11 | 12 | // RegisterWebRoutes 注册网页相关路由 13 | func RegisterWebRoutes(r *mux.Router) { 14 | 15 | // 静态页面 16 | pc := new(controllers.PagesController) 17 | r.NotFoundHandler = http.HandlerFunc(pc.NotFound) 18 | r.HandleFunc("/about", pc.About).Methods("GET").Name("about") 19 | 20 | // 文章相关页面 21 | ac := new(controllers.ArticlesController) 22 | r.HandleFunc("/", ac.Index).Methods("GET").Name("home") 23 | r.HandleFunc("/articles/{id:[0-9]+}", ac.Show).Methods("GET").Name("articles.show") 24 | r.HandleFunc("/articles", ac.Index).Methods("GET").Name("articles.index") 25 | r.HandleFunc("/articles/create", middlewares.Auth(ac.Create)).Methods("GET").Name("articles.create") 26 | r.HandleFunc("/articles", middlewares.Auth(ac.Store)).Methods("POST").Name("articles.store") 27 | r.HandleFunc("/articles/{id:[0-9]+}/edit", middlewares.Auth(ac.Edit)).Methods("GET").Name("articles.edit") 28 | r.HandleFunc("/articles/{id:[0-9]+}", middlewares.Auth(ac.Update)).Methods("POST").Name("articles.update") 29 | r.HandleFunc("/articles/{id:[0-9]+}/delete", middlewares.Auth(ac.Delete)).Methods("POST").Name("articles.delete") 30 | 31 | // 文章分类 32 | cc := new(controllers.CategoriesController) 33 | r.HandleFunc("/categories/create", middlewares.Auth(cc.Create)).Methods("GET").Name("categories.create") 34 | r.HandleFunc("/categories", middlewares.Auth(cc.Store)).Methods("POST").Name("categories.store") 35 | r.HandleFunc("/categories/{id:[0-9]+}", cc.Show).Methods("GET").Name("categories.show") 36 | 37 | // 用户认证 38 | auc := new(controllers.AuthController) 39 | r.HandleFunc("/auth/register", middlewares.Guest(auc.Register)).Methods("GET").Name("auth.register") 40 | r.HandleFunc("/auth/do-register", middlewares.Guest(auc.DoRegister)).Methods("POST").Name("auth.doregister") 41 | r.HandleFunc("/auth/login", middlewares.Guest(auc.Login)).Methods("GET").Name("auth.login") 42 | r.HandleFunc("/auth/dologin", middlewares.Guest(auc.DoLogin)).Methods("POST").Name("auth.dologin") 43 | r.HandleFunc("/auth/logout", middlewares.Auth(auc.Logout)).Methods("POST").Name("auth.logout") 44 | 45 | // 用户相关 46 | uc := new(controllers.UserController) 47 | r.HandleFunc("/users/{id:[0-9]+}", uc.Show).Methods("GET").Name("users.show") 48 | 49 | // --- 全局中间件 --- 50 | 51 | // 开始会话 52 | r.Use(middlewares.StartSession) 53 | } 54 | -------------------------------------------------------------------------------- /tests/pages_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestAllPages(t *testing.T) { 12 | 13 | baseURL := "http://localhost:3000" 14 | 15 | // 1. 声明加初始化测试数据 16 | var tests = []struct { 17 | method string 18 | url string 19 | expected int 20 | }{ 21 | {"GET", "/", 200}, 22 | {"GET", "/about", 200}, 23 | {"GET", "/notfound", 404}, 24 | {"GET", "/articles", 200}, 25 | {"GET", "/articles/create", 200}, 26 | {"GET", "/articles/3", 200}, 27 | {"GET", "/articles/3/edit", 200}, 28 | {"POST", "/articles/3", 200}, 29 | {"POST", "/articles", 200}, 30 | {"POST", "/articles/1/delete", 404}, 31 | } 32 | 33 | // 2. 遍历所有测试 34 | for _, test := range tests { 35 | t.Logf("当前请求 URL: %v \n", test.url) 36 | var ( 37 | resp *http.Response 38 | err error 39 | ) 40 | // 2.1 请求以获取响应 41 | switch { 42 | case test.method == "POST": 43 | data := make(map[string][]string) 44 | resp, err = http.PostForm(baseURL+test.url, data) 45 | default: 46 | resp, err = http.Get(baseURL + test.url) 47 | } 48 | // 2.2 断言 49 | assert.NoError(t, err, "请求 "+test.url+" 时报错") 50 | assert.Equal(t, test.expected, resp.StatusCode, test.url+" 应返回状态码 "+strconv.Itoa(test.expected)) 51 | } 52 | } 53 | --------------------------------------------------------------------------------