Browse Source

Merge branch 'dev'

Vova Tkach 6 years ago
parent
commit
8b333d8391
98 changed files with 5138 additions and 1055 deletions
  1. 7 0
      .gitignore
  2. 2 2
      Dockerfile
  3. 13 1
      Makefile
  4. 8 4
      README.md
  5. 61 11
      assets/cp.styles.css
  6. 0 0
      assets/cp.styles.css.go
  7. 0 0
      assets/tmpl.cp.base.go
  8. 1 1
      assets/tmpl.cp.base.html
  9. 2 2
      consts/consts.go
  10. 4 0
      cypress.json
  11. 54 0
      cypress/integration/control-panel/001_module_index/001_install_mysql.spec.js
  12. 68 0
      cypress/integration/control-panel/001_module_index/002_top_nav_bar.js
  13. 81 0
      cypress/integration/control-panel/001_module_index/003_pages.js
  14. 80 0
      cypress/integration/control-panel/002_module_blog/001_posts.js
  15. 107 0
      cypress/integration/control-panel/002_module_blog/002_categories.js
  16. 96 0
      cypress/integration/control-panel/002_module_blog/003_categories_updates.js
  17. 83 0
      cypress/integration/control-panel/003_module_users/001_users.js
  18. 63 0
      cypress/integration/control-panel/004_module_settings/001_robots_txt.js
  19. 34 0
      cypress/integration/control-panel/004_module_settings/002_pagination.js
  20. 17 0
      cypress/plugins/index.js
  21. 99 0
      cypress/support/commands.js
  22. 20 0
      cypress/support/index.js
  23. 0 28
      database/schema.sql
  24. 16 3
      engine/builder/data_form.go
  25. 90 44
      engine/builder/data_table.go
  26. 0 2
      engine/builder/data_table_action.go
  27. 3 8
      engine/engine.go
  28. 381 0
      engine/fetdata/blog.go
  29. 66 0
      engine/fetdata/blog_category.go
  30. 82 0
      engine/fetdata/blog_post.go
  31. 0 53
      engine/fetdata/content.go
  32. 73 17
      engine/fetdata/fetdata.go
  33. 0 17
      engine/fetdata/http.go
  34. 89 0
      engine/fetdata/index.go
  35. 0 32
      engine/fetdata/meta_data.go
  36. 0 74
      engine/fetdata/user.go
  37. 43 0
      engine/mysqlpool/mysqlpool.go
  38. 34 0
      engine/sqlw/log.go
  39. 125 0
      engine/sqlw/sqlw.go
  40. 63 0
      engine/sqlw/txw.go
  41. 52 0
      engine/wrapper/config.go
  42. 99 20
      engine/wrapper/wrapper.go
  43. 2 2
      go.mod
  44. 8 6
      go.sum
  45. 50 0
      hosts/localhost/template/blog-category.html
  46. 13 0
      hosts/localhost/template/blog-post.html
  47. 54 0
      hosts/localhost/template/blog.html
  48. 7 1
      hosts/localhost/template/footer.html
  49. 41 12
      hosts/localhost/template/header.html
  50. 2 2
      hosts/localhost/template/index.html
  51. 3 3
      hosts/localhost/template/page.html
  52. 12 0
      hosts/localhost/template/sidebar-right.html
  53. 7 10
      logger/handler.go
  54. 12 11
      logger/logger.go
  55. 20 5
      main.go
  56. 598 0
      modules/module_blog.go
  57. 46 0
      modules/module_blog_act_delete.go
  58. 197 0
      modules/module_blog_act_modify.go
  59. 91 0
      modules/module_blog_categories.go
  60. 60 0
      modules/module_blog_categories_act_delete.go
  61. 227 0
      modules/module_blog_categories_act_modify.go
  62. 66 390
      modules/module_index.go
  63. 49 0
      modules/module_index_act_cypress.go
  64. 37 0
      modules/module_index_act_delete.go
  65. 76 0
      modules/module_index_act_first_user.go
  66. 22 0
      modules/module_index_act_logout.go
  67. 105 0
      modules/module_index_act_modify.go
  68. 281 0
      modules/module_index_act_mysql_setup.go
  69. 71 0
      modules/module_index_act_signin.go
  70. 74 0
      modules/module_index_act_update_profile.go
  71. 42 20
      modules/module_settings.go
  72. 57 0
      modules/module_settings_act_pagination.go
  73. 28 0
      modules/module_settings_act_robots_txt.go
  74. 61 199
      modules/module_users.go
  75. 60 0
      modules/module_users_act_delete.go
  76. 127 0
      modules/module_users_act_modify.go
  77. 19 16
      modules/modules.go
  78. 5 0
      package.json
  79. 0 3
      run.mac.sh
  80. 0 3
      run.unix.sh
  81. 0 2
      run.windows.bat
  82. 25 18
      session.go
  83. 61 0
      support/schema.sql
  84. 0 0
      support/some-file.txt
  85. BIN
      testdata/screenshots-1.gif
  86. 10 0
      utils/mysql_struct_blog_category.go
  87. 12 0
      utils/mysql_struct_blog_post.go
  88. 0 0
      utils/mysql_struct_page.go
  89. 0 0
      utils/mysql_struct_user.go
  90. 94 0
      utils/utils.go
  91. 147 16
      utils/utils_test.go
  92. 8 7
      vendor/github.com/vladimirok5959/golang-server-bootstrap/bootstrap/bootstrap.go
  93. 4 1
      vendor/github.com/vladimirok5959/golang-server-sessions/session/bool.go
  94. 1 3
      vendor/github.com/vladimirok5959/golang-server-sessions/session/clean.go
  95. 4 1
      vendor/github.com/vladimirok5959/golang-server-sessions/session/int.go
  96. 20 2
      vendor/github.com/vladimirok5959/golang-server-sessions/session/session.go
  97. 4 1
      vendor/github.com/vladimirok5959/golang-server-sessions/session/string.go
  98. 2 2
      vendor/modules.txt

+ 7 - 0
.gitignore

@@ -31,3 +31,10 @@
 
 # All else
 /hosts/localhost/config/.installed
+/hosts/localhost/config/config.json
+
+# Skip cypress libs folder
+/cypress/screenshots
+/cypress/videos
+/node_modules
+/yarn.lock

+ 2 - 2
Dockerfile

@@ -2,8 +2,8 @@ FROM debian:latest
 
 ENV FAVE_HOST=0.0.0.0 FAVE_PORT=8080 FAVE_DIR=/app/hosts FAVE_DEBUG=false FAVE_KEEPALIVE=false
 
-ADD https://github.com/vladimirok5959/golang-fave/releases/download/v1.0.2/fave.linux-amd64.tar.gz /app/fave.linux-amd64.tar.gz
-ADD https://github.com/vladimirok5959/golang-fave/releases/download/v1.0.2/localhost.tar.gz /app/hosts/localhost.tar.gz
+ADD https://github.com/vladimirok5959/golang-fave/releases/download/v1.0.3/fave.linux-amd64.tar.gz /app/fave.linux-amd64.tar.gz
+ADD https://github.com/vladimirok5959/golang-fave/releases/download/v1.0.3/localhost.tar.gz /app/hosts/localhost.tar.gz
 
 RUN tar -zxf /app/fave.linux-amd64.tar.gz -C /app && \
  tar -zxf /app/hosts/localhost.tar.gz -C /app/hosts && \

+ 13 - 1
Makefile

@@ -1,4 +1,4 @@
-VERSION="1.0.2"
+VERSION="1.0.3"
 
 default: debug test run
 
@@ -56,3 +56,15 @@ docker-clr:
 	@-docker stop fave-test
 	@-docker rm fave-test
 	@-docker rmi fave
+
+cy-dev:
+	yarn cypress open
+
+cy:
+	yarn cypress run
+
+ab:
+	ab -kc 10 -t 120 http://localhost:8080/
+	ab -kc 10 -t 120 http://localhost:8080/another/
+	ab -kc 10 -t 120 http://localhost:8080/not-existent-page/
+	ab -kc 10 -t 120 http://localhost:8080/blog/

+ 8 - 4
README.md

@@ -1,7 +1,5 @@
 # golang-fave
-CMS written on Go with MySQL as database. Dynamical, splitted by modules, user friendly and thanks bootstrap is fully adaptive for mobile devices and tablets. Thanks Go language it's fastern, all in one binary file, no need to install additional web servers. Go native template with vars allow to do almost all what are need.
-
-![](testdata/screenshots-1.gif)
+CMS written on Go with MySQL as database. Dynamical, splitted by modules, user friendly and thanks bootstrap is fully adaptive for mobile devices and tablets. All in one binary file, no need to install additional web servers. Go native templates with vars allow to do almost all what are need.
 
 ## Usage
 ```
@@ -30,9 +28,12 @@ hosts
 ├────── error.log          # Error log file
 ├──── template             # Engine templates
 ├────── 404.html           # Template for 404 page
+├────── blog-category.html # Template for blog category
+├────── blog-post.html     # Template for blog post
+├────── blog.html          # Template for blog home page
 ├────── footer.html        # Footer
 ├────── header.html        # Header
-├────── index.html         # Template for index page
+├────── index.html         # Template for home page
 ├────── page.html          # Template for any other pages
 ├────── robots.txt         # Host robots.txt file
 ├────── scripts.js         # Theme scripts file
@@ -50,3 +51,6 @@ Unlimited hosts count. Template variables in [Wiki](https://github.com/vladimiro
 * **make update** - get all dependencies and put to vendor folder
 * **make docker-test** - build image and start on port 8080
 * **make docker-img** - build docker image only
+* **make cy-dev** - cypress tests in browser
+* **make cy** - cypress tests in console
+* **make ab** - http stress test

+ 61 - 11
assets/cp.styles.css

@@ -113,17 +113,6 @@ body.cp nav.main a.navbar-brand {
 	font-weight: bold;
 }
 
-/*
-body.cp nav.main .navbar-nav .nav-item a img {
-	width: 35px;
-	height: 35px;
-	margin-right: 10px;
-	margin-top: -30px;
-	margin-bottom: -30px;
-	background-color: gray;
-}
-*/
-
 body.cp .wrap {
 	width: 100%;
 	height: 100%;
@@ -169,6 +158,10 @@ body.cp .wrap .sidebar.sidebar-left ul.nav {
 	padding: 1rem 0px;
 }
 
+body.cp .wrap .sidebar.sidebar-left ul.nav li.nav-separator {
+	height: 1rem;
+}
+
 body.cp .wrap .sidebar.sidebar-left ul.nav li.nav-item a {
 	color: #444;
 }
@@ -294,6 +287,26 @@ ul.pagination {
 	text-align: right;
 }
 
+/* Admin table: blog_posts */
+.data-table.table_blog_posts .col_datetime {
+	width: 8rem;
+}
+
+.data-table.table_blog_posts .col_active {
+	width: 5rem;
+}
+
+.data-table.table_blog_posts .col_action {
+	width: 6rem;
+	text-align: right;
+}
+
+/* Admin table: table_blog_cats */
+.data-table.table_blog_cats .col_action {
+	width: 6rem;
+	text-align: right;
+}
+
 /* Admin table: table_users */
 .data-table.table_users .col_active,
 .data-table.table_users .col_admin {
@@ -324,6 +337,10 @@ ul.pagination {
 	min-height: 5.4rem;
 }
 
+.data-form textarea.briefly {
+	min-height: 3.9rem;
+}
+
 /* Checkbox style iOS */
 .checkbox-ios {
 	display: inline-block;
@@ -399,6 +416,39 @@ ul.pagination {
 	body.cp.cp-sidebar-right .wrap .sidebar.sidebar-right.d-lg-table-cell {
 		display: table-cell !important;
 	}
+
+	.data-form .row .sys-messages .alert {
+		margin-top: 1rem;
+		margin-bottom: 0px;
+	}
+
+	.data-form .form-group.last {
+		margin-bottom: 0px;
+	}
+
+	.data-form.index-add .form-group.n8,
+	.data-form.index-modify .form-group.n8 {
+		margin-bottom: 0px;
+	}
+
+	.data-form.blog-add .form-group.n7,
+	.data-form.blog-modify .form-group.n7 {
+		margin-bottom: 0px;
+	}
+
+	.data-form.blog-categories-add .form-group.n4,
+	.data-form.blog-categories-modify .form-group.n4 {
+		margin-bottom: 0px;
+	}
+
+	.data-form.users-add .form-group.n7,
+	.data-form.users-modify .form-group.n7 {
+		margin-bottom: 0px;
+	}
+
+	.data-form.settings-pagination .form-group.n2 {
+		margin-bottom: 0px;
+	}
 }
 
 @media (max-width: 575px) {

File diff suppressed because it is too large
+ 0 - 0
assets/cp.styles.css.go


File diff suppressed because it is too large
+ 0 - 0
assets/tmpl.cp.base.go


+ 1 - 1
assets/tmpl.cp.base.html

@@ -46,7 +46,7 @@
 				<ul class="navbar-nav ml-auto">
 					<li class="nav-item dropdown">
 						<a class="nav-link dropdown-toggle" href="javascript:;" id="nbAccountDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-							<!--<img class="rounded-circle" src="{{$.Data.UserAvatarLink}}">-->{{$.Data.UserEmail}}
+							{{$.Data.UserEmail}}
 						</a>
 						<div class="dropdown-menu dropdown-menu-right" aria-labelledby="nbAccountDropdown">
 							<a class="dropdown-item" href="javascript:fave.ModalUserProfile();">My profile</a>

+ 2 - 2
consts/consts.go

@@ -4,8 +4,8 @@ import (
 	"html/template"
 )
 
-const ServerVersion = "1.0.2"
-const AssetsVersion = "16"
+const ServerVersion = "1.0.3"
+const AssetsVersion = "19"
 const AssetsPath = "assets"
 const DirIndexFile = "index.html"
 

+ 4 - 0
cypress.json

@@ -0,0 +1,4 @@
+{
+  "projectId": "hv532c",
+  "video": false
+}

+ 54 - 0
cypress/integration/control-panel/001_module_index/001_install_mysql.spec.js

@@ -0,0 +1,54 @@
+/// <reference types="Cypress" />
+
+context('Install MySQL, create first user and login', () => {
+  it('should do redirect to cp panel', () => {
+    cy.resetCMS();
+    cy.request({
+      url: cy.getBaseUrl() + '/',
+      followRedirect: false
+    }).then((response) => {
+      expect(response.status).to.eq(302);
+      expect(response.redirectedToUrl).to.eq(cy.getBaseUrl() + '/cp/');
+    });
+    cy.visitCMS('/cp/');
+    cy.url().should('eq', cy.getBaseUrl() + '/cp/');
+  });
+
+  it('should configure mysql config', () => {
+    cy.actionStart();
+    cy.get('.form-signin input[type=text]').should('have.length', 4);
+    cy.get('.form-signin input[type=password]').should('have.length', 1);
+    cy.get('.form-signin button').should('have.length', 1);
+    cy.get('.form-signin input[name=name]').type('fave');
+    cy.get('.form-signin input[name=user]').type('root');
+    cy.get('.form-signin input[name=password]').type('root');
+    cy.get('.form-signin button').click();
+    cy.actionWait();
+  });
+
+  it('should create first user', () => {
+    cy.actionStart();
+    cy.get('.form-signin input[type=text]').should('have.length', 2);
+    cy.get('.form-signin input[type=email]').should('have.length', 1);
+    cy.get('.form-signin input[type=password]').should('have.length', 1);
+    cy.get('.form-signin button').should('have.length', 1);
+    cy.get('.form-signin input[name=first_name]').type('First');
+    cy.get('.form-signin input[name=last_name]').type('Last');
+    cy.get('.form-signin input[name=email]').type('example@example.com');
+    cy.get('.form-signin input[name=password]').type('example@example.com');
+    cy.get('.form-signin button').click();
+    cy.actionWait();
+  });
+
+  it('should login to control panel', () => {
+    cy.actionStart();
+    cy.get('.form-signin input[type=email]').should('have.length', 1);
+    cy.get('.form-signin input[type=password]').should('have.length', 1);
+    cy.get('.form-signin button').should('have.length', 1);
+    cy.get('.form-signin input[name=email]').type('example@example.com');
+    cy.get('.form-signin input[name=password]').type('example@example.com');
+    cy.get('.form-signin button').click();
+    cy.actionWait();
+    cy.logoutCMS();
+  });
+});

+ 68 - 0
cypress/integration/control-panel/001_module_index/002_top_nav_bar.js

@@ -0,0 +1,68 @@
+/// <reference types="Cypress" />
+
+context('Top navigation bar', () => {
+  it('should reset', () => {
+    cy.installCMS();
+  });
+
+  it('should render top nav bar', () => {
+    cy.loginCMS();
+    cy.get('#navbarCollapse ul.navbar-nav').should('have.length', 2);
+    cy.logoutCMS();
+  });
+
+  it('should render modules menu', () => {
+    cy.loginCMS();
+    cy.get('#navbarCollapse ul.navbar-nav:nth-child(1) li.nav-item').should('have.length', 2);
+    cy.get('#navbarCollapse ul.navbar-nav li.nav-item a.nav-link').should('contain', 'Modules');
+    cy.contains('#navbarCollapse ul.navbar-nav li.nav-item a.nav-link', 'Modules').parent().find('.dropdown-menu .dropdown-item').should('contain', 'Pages');
+    cy.contains('#navbarCollapse ul.navbar-nav li.nav-item a.nav-link', 'Modules').parent().find('.dropdown-menu .dropdown-item').should('contain', 'Blog');
+    cy.get('#navbarCollapse ul.navbar-nav li.nav-item a.nav-link').should('contain', 'System');
+    cy.contains('#navbarCollapse ul.navbar-nav li.nav-item a.nav-link', 'System').parent().find('.dropdown-menu .dropdown-item').should('contain', 'Users');
+    cy.contains('#navbarCollapse ul.navbar-nav li.nav-item a.nav-link', 'System').parent().find('.dropdown-menu .dropdown-item').should('contain', 'Settings');
+    cy.logoutCMS();
+  });
+
+  it('should render user menu', () => {
+    cy.loginCMS();
+    cy.get('#navbarCollapse ul.navbar-nav:nth-child(2) li.nav-item').should('have.length', 1);
+    cy.get('#navbarCollapse ul.navbar-nav li.nav-item a.nav-link').should('contain', 'example@example.com');
+    cy.contains('#navbarCollapse ul.navbar-nav li.nav-item a.nav-link', 'example@example.com').parent().find('.dropdown-menu .dropdown-item').should('contain', 'My profile');
+    cy.contains('#navbarCollapse ul.navbar-nav li.nav-item a.nav-link', 'example@example.com').parent().find('.dropdown-menu .dropdown-item').should('contain', 'Logout');
+    cy.logoutCMS();
+  });
+
+  it('should render user profile modal dialog', () => {
+    cy.loginCMS();
+    cy.contains('#navbarCollapse ul.navbar-nav li.nav-item a.nav-link', 'example@example.com').click();
+    cy.contains('#navbarCollapse ul.navbar-nav li.nav-item a.nav-link', 'example@example.com').parent().find('.dropdown-menu').contains('a.dropdown-item', 'My profile').click();
+    cy.get('#sys-modal-user-settings').should('exist');
+    cy.get('#sys-modal-user-settings form input[type=text]').should('have.length', 2);
+    cy.get('#sys-modal-user-settings form input[type=email]').should('have.length', 1);
+    cy.get('#sys-modal-user-settings form input[type=password]').should('have.length', 1);
+    cy.logoutCMS();
+  });
+
+  it('should change user profile data in modal dialog', () => {
+    cy.loginCMS();
+    cy.contains('#navbarCollapse ul.navbar-nav li.nav-item a.nav-link', 'example@example.com').click();
+    cy.contains('#navbarCollapse ul.navbar-nav li.nav-item a.nav-link', 'example@example.com').parent().find('.dropdown-menu').contains('a.dropdown-item', 'My profile').click();
+    cy.get('#sys-modal-user-settings').should('exist');
+    cy.get('#sys-modal-user-settings form input[name=first_name]').clear().type('FirstNew');
+    cy.get('#sys-modal-user-settings form input[name=last_name]').clear().type('LastNew');
+    cy.get('#sys-modal-user-settings form button[type=submit].btn').click();
+    cy.actionWait();
+    cy.logoutCMS();
+  });
+
+  it('should render saved user profile data in modal dialog', () => {
+    cy.loginCMS();
+    cy.contains('#navbarCollapse ul.navbar-nav li.nav-item a.nav-link', 'example@example.com').click();
+    cy.contains('#navbarCollapse ul.navbar-nav li.nav-item a.nav-link', 'example@example.com').parent().find('.dropdown-menu').contains('a.dropdown-item', 'My profile').click();
+    cy.get('#sys-modal-user-settings').should('exist');
+    cy.get('#sys-modal-user-settings form input[name=first_name]').should('have.value', 'FirstNew');
+    cy.get('#sys-modal-user-settings form input[name=last_name]').should('have.value', 'LastNew');
+    cy.get('#sys-modal-user-settings form button[type=button].btn').click();
+    cy.logoutCMS();
+  });
+});

+ 81 - 0
cypress/integration/control-panel/001_module_index/003_pages.js

@@ -0,0 +1,81 @@
+/// <reference types="Cypress" />
+
+context('Module pages', () => {
+  it('should reset', () => {
+    cy.installCMS();
+  });
+
+  it('should render data table', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/');
+    cy.get('table.data-table thead tr').should('have.length', 1);
+    cy.get('table.data-table thead tr th').should('have.length', 4);
+    cy.get('table.data-table tbody tr').should('have.length', 3);
+    cy.get('table.data-table tbody tr:nth-child(1) td').should('have.length', 4);
+    cy.logoutCMS();
+  });
+
+  it('should render data form', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/index/add/');
+    cy.get('.data-form.index-add input[type=text]').should('have.length', 4);
+    cy.get('.data-form.index-add textarea').should('have.length', 2);
+    cy.get('.data-form.index-add input[type=checkbox]').should('have.length', 1);
+    cy.logoutCMS();
+  });
+
+  it('should not add new page', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/index/add/');
+    cy.get('#add-edit-button').click();
+    cy.actionWait();
+    cy.get('.data-form.index-add div.sys-messages').should('exist');
+    cy.logoutCMS();
+  });
+
+  it('should add new page', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/index/add/');
+    cy.get('.data-form.index-add input[name=name]').clear().type('Some test page');
+    cy.get('.data-form.index-add textarea[name=content]').clear().type('Some test content');
+    cy.get('.data-form.index-add input[name=meta_title]').clear().type('Page meta title');
+    cy.get('.data-form.index-add input[name=meta_keywords]').clear().type('Page meta keywords');
+    cy.get('.data-form.index-add textarea[name=meta_description]').clear().type('Page meta description');
+    cy.get('.data-form.index-add label[for=lbl_active]').click();
+    cy.get('#add-edit-button').click();
+    cy.actionWait();
+    cy.logoutCMS();
+  });
+
+  it('should render added page in list', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/');
+    cy.get('table.data-table tbody tr').should('have.length', 4);
+    cy.get('table.data-table tbody tr td').should('contain', 'Some test page');
+    cy.contains('table#cp-table-pages tbody tr td a', 'Some test page').parentsUntil('tr').parent().find('.svg-green').should('exist');
+    cy.logoutCMS();
+  });
+
+  it('should render added page in edit form', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/');
+    cy.contains('table.data-table tbody tr td a', 'Some test page').click();
+    cy.get('.data-form.index-modify input[name=name]').should('have.value', 'Some test page');
+    cy.get('.data-form.index-modify input[name=alias]').should('have.value', '/some-test-page/');
+    cy.get('.data-form.index-modify textarea[name=content]').should('have.value', 'Some test content');
+    cy.get('.data-form.index-modify input[name=meta_title]').should('have.value', 'Page meta title');
+    cy.get('.data-form.index-modify input[name=meta_keywords]').should('have.value', 'Page meta keywords');
+    cy.get('.data-form.index-modify textarea[name=meta_description]').should('have.value', 'Page meta description');
+    cy.get('.data-form.index-modify input[name=active]').should('be.checked');
+    cy.logoutCMS();
+  });
+
+  it('should delete added page', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/');
+    cy.contains('table.data-table tbody tr td a', 'Some test page').parentsUntil('tr').parent().find('td a.ico.delete').click();
+    cy.actionWait();
+    cy.get('table.data-table tbody tr').should('have.length', 3);
+    cy.logoutCMS();
+  });
+});

+ 80 - 0
cypress/integration/control-panel/002_module_blog/001_posts.js

@@ -0,0 +1,80 @@
+/// <reference types="Cypress" />
+
+context('Module blog posts', () => {
+  it('should reset', () => {
+    cy.installCMS();
+  });
+
+  it('should render data table', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/');
+    cy.get('table.data-table thead tr').should('have.length', 1);
+    cy.get('table.data-table thead tr th').should('have.length', 4);
+    cy.get('table.data-table tbody tr').should('have.length', 3);
+    cy.get('table.data-table tbody tr:nth-child(1) td').should('have.length', 4);
+    cy.logoutCMS();
+  });
+
+  it('should render data form', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/add/');
+    cy.get('.data-form.blog-add input[type=text]').should('have.length', 2);
+    cy.get('.data-form.blog-add select').should('have.length', 1);
+    cy.get('.data-form.blog-add textarea').should('have.length', 2);
+    cy.get('.data-form.blog-add input[type=checkbox]').should('have.length', 1);
+    cy.logoutCMS();
+  });
+
+  it('should not add new post', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/add/');
+    cy.get('#add-edit-button').click();
+    cy.actionWait();
+    cy.get('.data-form.blog-add div.sys-messages').should('exist');
+    cy.logoutCMS();
+  });
+
+  it('should add new post', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/add/');
+    cy.get('.data-form.blog-add input[name=name]').clear().type('Some test post');
+    cy.get('.data-form.blog-add select#lbl_cats').select(['Health and food', '— — Natural']).invoke('val').should('deep.equal', ['2', '7']);
+    cy.get('.data-form.blog-add textarea[name=briefly]').clear().type('Some brief content');
+    cy.get('.data-form.blog-add textarea[name=content]').clear().type('Some test content');
+    cy.get('.data-form.blog-add label[for=lbl_active]').click();
+    cy.get('#add-edit-button').click();
+    cy.actionWait();
+    cy.logoutCMS();
+  });
+
+  it('should render added post in list', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/');
+    cy.get('table.data-table tbody tr').should('have.length', 4);
+    cy.get('table.data-table tbody tr td').should('contain', 'Some test post');
+    cy.contains('table.data-table tbody tr td a', 'Some test post').parentsUntil('tr').parent().find('.svg-green').should('exist');
+    cy.logoutCMS();
+  });
+
+  it('should render added post in edit form', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/');
+    cy.contains('table.data-table tbody tr td a', 'Some test post').click();
+    cy.get('.data-form.blog-modify input[name=name]').should('have.value', 'Some test post');
+    cy.get('.data-form.blog-modify input[name=alias]').should('have.value', 'some-test-post');
+    cy.get('.data-form.blog-modify select#lbl_cats').invoke('val').should('deep.equal', ['2', '7']);
+    cy.get('.data-form.blog-modify textarea[name=briefly]').should('have.value', 'Some brief content');
+    cy.get('.data-form.blog-modify textarea[name=content]').should('have.value', 'Some test content');
+    cy.get('.data-form.blog-modify input[name=active]').should('be.checked');
+    cy.logoutCMS();
+  });
+
+  it('should delete added post', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/');
+    cy.contains('table.data-table tbody tr td a', 'Some test post').parentsUntil('tr').parent().find('td a.ico.delete').click();
+    cy.actionWait();
+    cy.get('table.data-table tbody tr').should('have.length', 3);
+    cy.logoutCMS();
+  });
+});

+ 107 - 0
cypress/integration/control-panel/002_module_blog/002_categories.js

@@ -0,0 +1,107 @@
+/// <reference types="Cypress" />
+
+context('Module blog categories', () => {
+  it('should reset', () => {
+    cy.installCMS();
+  });
+
+  it('should render data table', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/categories/');
+    cy.get('table.data-table thead tr').should('have.length', 1);
+    cy.get('table.data-table thead tr th').should('have.length', 2);
+    cy.get('table.data-table tbody tr').should('have.length', 11);
+    cy.get('table.data-table tbody tr:nth-child(1) td').should('have.length', 2);
+    cy.logoutCMS();
+  });
+
+  it('should render data form', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/categories-add/');
+    cy.get('.data-form.blog-categories-add select').should('have.length', 1);
+    cy.get('.data-form.blog-categories-add input[type=text]').should('have.length', 2);
+    cy.logoutCMS();
+  });
+
+  it('should not add new category', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/categories-add/');
+    cy.get('#add-edit-button').click();
+    cy.actionWait();
+    cy.get('.data-form.blog-categories-add div.sys-messages').should('exist');
+    cy.logoutCMS();
+  });
+
+  it('should add new category', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/categories-add/');
+    cy.get('.data-form.blog-categories-add input[name=name]').clear().type('Some test category');
+    cy.get('#add-edit-button').click();
+    cy.actionWait();
+    cy.logoutCMS();
+  });
+
+  it('should render added category in list', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/categories/');
+    cy.get('table.data-table tbody tr').should('have.length', 12);
+    cy.get('table.data-table tbody tr td').should('contain', 'Some test category');
+    cy.logoutCMS();
+  });
+
+  it('should render added category in edit form', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/categories/');
+    cy.contains('table.data-table tbody tr td a', 'Some test category').click();
+    cy.get('.data-form.blog-categories-modify select[name=parent]').should('have.value', '0');
+    cy.get('.data-form.blog-categories-modify input[name=name]').should('have.value', 'Some test category');
+    cy.get('.data-form.blog-categories-modify input[name=alias]').should('have.value', 'some-test-category');
+    cy.logoutCMS();
+  });
+
+  it('should add new child category', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/categories-add/');
+    cy.get('.data-form.blog-categories-add select[name=parent]').select('Some test category');
+    cy.get('.data-form.blog-categories-add input[name=name]').clear().type('Some test child category');
+    cy.get('#add-edit-button').click();
+    cy.actionWait();
+    cy.logoutCMS();
+  });
+
+  it('should render added child category in list', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/categories/');
+    cy.get('table.data-table tbody tr').should('have.length', 13);
+    cy.get('table.data-table tbody tr td').should('contain', '— Some test child category');
+    cy.logoutCMS();
+  });
+
+  it('should render added child category in edit form', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/categories/');
+    cy.contains('table.data-table tbody tr td a', '— Some test child category').click();
+    cy.get('.data-form.blog-categories-modify select[name=parent]').find(':selected').contains('Some test category')
+    cy.get('.data-form.blog-categories-modify input[name=name]').should('have.value', 'Some test child category');
+    cy.get('.data-form.blog-categories-modify input[name=alias]').should('have.value', 'some-test-child-category');
+    cy.logoutCMS();
+  });
+
+  it('should delete added child category', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/categories/');
+    cy.contains('table.data-table tbody tr td a', '— Some test child category').parentsUntil('tr').parent().find('td a.ico.delete').click();
+    cy.actionWait();
+    cy.get('table.data-table tbody tr').should('have.length', 12);
+    cy.logoutCMS();
+  });
+
+  it('should delete added category', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/categories/');
+    cy.contains('table.data-table tbody tr td a', 'Some test category').parentsUntil('tr').parent().find('td a.ico.delete').click();
+    cy.actionWait();
+    cy.get('table.data-table tbody tr').should('have.length', 11);
+    cy.logoutCMS();
+  });
+});

+ 96 - 0
cypress/integration/control-panel/002_module_blog/003_categories_updates.js

@@ -0,0 +1,96 @@
+/// <reference types="Cypress" />
+
+context('Module blog categories updates', () => {
+  it('should reset', () => {
+    cy.installCMS();
+  });
+
+  it('should render correct data in table', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/categories/');
+    cy.get('table.data-table thead tr').should('have.length', 1);
+    cy.get('table.data-table thead tr th').should('have.length', 2);
+    cy.get('table.data-table tbody tr').should('have.length', 11);
+    cy.get('table.data-table tbody tr:nth-child(1) td').should('have.length', 2);
+
+    cy.get('table.data-table tbody tr:nth-child(1) td:nth-child(1)').should('contain', 'Health and food');
+    cy.get('table.data-table tbody tr:nth-child(2) td:nth-child(1)').should('contain', '— Juices');
+    cy.get('table.data-table tbody tr:nth-child(3) td:nth-child(1)').should('contain', '— — Natural');
+    cy.get('table.data-table tbody tr:nth-child(4) td:nth-child(1)').should('contain', '— — For kids');
+    cy.get('table.data-table tbody tr:nth-child(5) td:nth-child(1)').should('contain', '— Nutrition');
+    cy.get('table.data-table tbody tr:nth-child(6) td:nth-child(1)').should('contain', '— — For all');
+    cy.get('table.data-table tbody tr:nth-child(7) td:nth-child(1)').should('contain', '— — For athletes');
+    cy.get('table.data-table tbody tr:nth-child(8) td:nth-child(1)').should('contain', 'News');
+    cy.get('table.data-table tbody tr:nth-child(9) td:nth-child(1)').should('contain', '— Computers and technology');
+    cy.get('table.data-table tbody tr:nth-child(10) td:nth-child(1)').should('contain', '— Film industry');
+    cy.get('table.data-table tbody tr:nth-child(11) td:nth-child(1)').should('contain', 'Hobby');
+
+    cy.logoutCMS();
+  });
+
+  it('should change category parent (from left to right)', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/categories/');
+    
+    cy.contains('table.data-table tbody tr td a', '— Juices').click();
+    cy.get('.data-form.blog-categories-modify select[name=parent]').select('News');
+    cy.get('#add-edit-button').click();
+    cy.actionWait();
+
+    cy.visitCMS('/cp/blog/categories/');
+
+    cy.get('table.data-table tbody tr:nth-child(1) td:nth-child(1)').should('contain', 'Health and food');
+    cy.get('table.data-table tbody tr:nth-child(2) td:nth-child(1)').should('contain', '— Nutrition');
+    cy.get('table.data-table tbody tr:nth-child(3) td:nth-child(1)').should('contain', '— — For all');
+    cy.get('table.data-table tbody tr:nth-child(4) td:nth-child(1)').should('contain', '— — For athletes');
+    cy.get('table.data-table tbody tr:nth-child(5) td:nth-child(1)').should('contain', 'News');
+    cy.get('table.data-table tbody tr:nth-child(6) td:nth-child(1)').should('contain', '— Computers and technology');
+    cy.get('table.data-table tbody tr:nth-child(7) td:nth-child(1)').should('contain', '— Film industry');
+    cy.get('table.data-table tbody tr:nth-child(8) td:nth-child(1)').should('contain', '— Juices');
+    cy.get('table.data-table tbody tr:nth-child(9) td:nth-child(1)').should('contain', '— — Natural');
+    cy.get('table.data-table tbody tr:nth-child(10) td:nth-child(1)').should('contain', '— — For kids');
+    cy.get('table.data-table tbody tr:nth-child(11) td:nth-child(1)').should('contain', 'Hobby');
+
+    cy.logoutCMS();
+  });
+
+  it('should change category parent (from right to left)', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/categories/');
+    
+    cy.contains('table.data-table tbody tr td a', '— Juices').click();
+    cy.get('.data-form.blog-categories-modify select[name=parent]').select('— Nutrition');
+    cy.get('#add-edit-button').click();
+    cy.actionWait();
+
+    cy.visitCMS('/cp/blog/categories/');
+
+    cy.get('table.data-table tbody tr:nth-child(1) td:nth-child(1)').should('contain', 'Health and food');
+    cy.get('table.data-table tbody tr:nth-child(2) td:nth-child(1)').should('contain', '— Nutrition');
+    cy.get('table.data-table tbody tr:nth-child(3) td:nth-child(1)').should('contain', '— — For all');
+    cy.get('table.data-table tbody tr:nth-child(4) td:nth-child(1)').should('contain', '— — For athletes');
+    cy.get('table.data-table tbody tr:nth-child(5) td:nth-child(1)').should('contain', '— — Juices');
+    cy.get('table.data-table tbody tr:nth-child(6) td:nth-child(1)').should('contain', '— — — Natural');
+    cy.get('table.data-table tbody tr:nth-child(7) td:nth-child(1)').should('contain', '— — — For kids');
+    cy.get('table.data-table tbody tr:nth-child(8) td:nth-child(1)').should('contain', 'News');
+    cy.get('table.data-table tbody tr:nth-child(9) td:nth-child(1)').should('contain', '— Computers and technology');
+    cy.get('table.data-table tbody tr:nth-child(10) td:nth-child(1)').should('contain', '— Film industry');
+    cy.get('table.data-table tbody tr:nth-child(11) td:nth-child(1)').should('contain', 'Hobby');
+
+    cy.logoutCMS();
+  });
+
+  it('should do not allow to change category parent to they child as parent', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/blog/categories/');
+
+    cy.contains('table.data-table tbody tr td a', '— Juices').click();
+    cy.get('.data-form.blog-categories-modify select[name=parent]').select('— — — Natural');
+    cy.get('#add-edit-button').click();
+    cy.actionWait();
+
+    cy.get('.data-form.blog-categories-modify div.sys-messages').should('exist');
+
+    cy.logoutCMS();
+  });
+});

+ 83 - 0
cypress/integration/control-panel/003_module_users/001_users.js

@@ -0,0 +1,83 @@
+/// <reference types="Cypress" />
+
+context('Module users', () => {
+  it('should reset', () => {
+    cy.installCMS();
+  });
+
+  it('should render data table', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/users/');
+    cy.get('table.data-table thead tr').should('have.length', 1);
+    cy.get('table.data-table thead tr th').should('have.length', 4);
+    cy.get('table.data-table tbody tr').should('have.length', 1);
+    cy.get('table.data-table tbody tr:nth-child(1) td').should('have.length', 4);
+    cy.logoutCMS();
+  });
+
+  it('should render data form', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/users/add/');
+    cy.get('.data-form.users-add input[type=text]').should('have.length', 2);
+    cy.get('.data-form.users-add input[type=email]').should('have.length', 1);
+    cy.get('.data-form.users-add input[type=password]').should('have.length', 1);
+    cy.get('.data-form.users-add input[type=checkbox]').should('have.length', 2);
+    cy.logoutCMS();
+  });
+
+  it('should not add new user', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/users/add/');
+    cy.get('.data-form.users-add input[name=email]').clear().type('some@text');
+    cy.get('.data-form.users-add input[name=password]').clear().type('some@text');
+    cy.get('#add-edit-button').click();
+    cy.actionWait();
+    cy.get('.data-form.users-add div.sys-messages').should('exist');
+    cy.logoutCMS();
+  });
+
+  it('should add new user', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/users/add/');
+    cy.get('.data-form.users-add input[name=first_name]').clear().type('Some user first name');
+    cy.get('.data-form.users-add input[name=last_name]').clear().type('Some user last name');
+    cy.get('.data-form.users-add input[name=email]').clear().type('some@user.com');
+    cy.get('.data-form.users-add input[name=password]').clear().type('some@text');
+    cy.get('.data-form.users-add label[for=lbl_active]').click();
+    cy.get('.data-form.users-add label[for=lbl_admin]').click();
+    cy.get('#add-edit-button').click();
+    cy.actionWait();
+    cy.logoutCMS();
+  });
+
+  it('should render added user in list', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/users/');
+    cy.get('table.data-table tbody tr').should('have.length', 2);
+    cy.get('table.data-table tbody tr td').should('contain', 'some@user.com');
+    cy.contains('table.data-table tbody tr td a', 'some@user.com').parentsUntil('tr').parent().find('.svg-green').should('exist');
+    cy.logoutCMS();
+  });
+
+  it('should render added user in edit form', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/users/');
+    cy.contains('table.data-table tbody tr td a', 'some@user.com').click();
+    cy.get('.data-form.users-modify input[name=first_name]').should('have.value', 'Some user first name');
+    cy.get('.data-form.users-modify input[name=last_name]').should('have.value', 'Some user last name');
+    cy.get('.data-form.users-modify input[name=email]').should('have.value', 'some@user.com');
+    cy.get('.data-form.users-modify input[name=password]').should('have.value', '');
+    cy.get('.data-form.users-modify input[name=active]').should('be.checked');
+    cy.get('.data-form.users-modify input[name=admin]').should('be.checked');
+    cy.logoutCMS();
+  });
+
+  it('should delete added user', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/users/');
+    cy.contains('table.data-table tbody tr td a', 'some@user.com').parentsUntil('tr').parent().find('td a.ico.delete').click();
+    cy.actionWait();
+    cy.get('table.data-table tbody tr').should('have.length', 1);
+    cy.logoutCMS();
+  });
+});

+ 63 - 0
cypress/integration/control-panel/004_module_settings/001_robots_txt.js

@@ -0,0 +1,63 @@
+/// <reference types="Cypress" />
+
+context('Module robots.txt', () => {
+  it('should reset', () => {
+    cy.installCMS();
+  });
+
+  it('should render edit form', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/settings/');
+    cy.get('.data-form.settings- textarea[name=content]').should('exist');
+    cy.get('.data-form.settings- textarea[name=content]').should('have.value', 'User-agent: *\nDisallow: /\n');
+    cy.logoutCMS();
+  });
+
+  it('should render result file', () => {
+    cy.request({
+      url: cy.getBaseUrl() + '/robots.txt',
+      followRedirect: false
+    }).then((response) => {
+      expect(response.status).to.eq(200);
+      expect(response.body).to.eq('User-agent: *\r\nDisallow: /\r\n');
+    });
+  });
+
+  it('should change file content', () => {
+    cy.loginCMS();
+
+    cy.visitCMS('/cp/settings/');
+    cy.get('.data-form.settings- textarea[name=content]').clear().type('Some file content');
+    cy.get('#add-edit-button').click();
+    cy.actionWait();
+
+    cy.visitCMS('/cp/settings/');
+    cy.get('.data-form.settings- textarea[name=content]').should('have.value', 'Some file content');
+
+    cy.request({
+      url: cy.getBaseUrl() + '/robots.txt',
+      followRedirect: false
+    }).then((response) => {
+      expect(response.status).to.eq(200);
+      expect(response.body).to.eq('Some file content');
+    });
+
+    cy.visitCMS('/cp/settings/');
+    cy.get('.data-form.settings- textarea[name=content]').clear().type('User-agent: *\nDisallow: /\n');
+    cy.get('#add-edit-button').click();
+    cy.actionWait();
+
+    cy.visitCMS('/cp/settings/');
+    cy.get('.data-form.settings- textarea[name=content]').should('have.value', 'User-agent: *\nDisallow: /\n');
+
+    cy.request({
+      url: cy.getBaseUrl() + '/robots.txt',
+      followRedirect: false
+    }).then((response) => {
+      expect(response.status).to.eq(200);
+      expect(response.body).to.eq('User-agent: *\r\nDisallow: /\r\n');
+    });
+
+    cy.logoutCMS();
+  });
+});

+ 34 - 0
cypress/integration/control-panel/004_module_settings/002_pagination.js

@@ -0,0 +1,34 @@
+/// <reference types="Cypress" />
+
+context('Module Pagination', () => {
+  it('should reset', () => {
+    cy.installCMS();
+  });
+
+  it('should render inputs for blog pagination', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/settings/pagination/');
+    cy.get('.data-form.settings-pagination input[name=blog-index]').should('exist');
+    cy.get('.data-form.settings-pagination input[name=blog-category]').should('exist');
+    cy.get('.data-form.settings-pagination input[name=blog-index]').should('have.value', '5');
+    cy.get('.data-form.settings-pagination input[name=blog-category]').should('have.value', '5');
+    cy.logoutCMS();
+  });
+
+  it('should change inputs value for blog pagination', () => {
+    cy.loginCMS();
+    cy.visitCMS('/cp/settings/pagination/');
+    cy.get('.data-form.settings-pagination input[name=blog-index]').clear().type('2');
+    cy.get('.data-form.settings-pagination input[name=blog-category]').clear().type('3');
+    cy.get('#add-edit-button').click();
+    cy.actionWait();
+    cy.visitCMS('/cp/settings/pagination/');
+    cy.get('.data-form.settings-pagination input[name=blog-index]').should('have.value', '2');
+    cy.get('.data-form.settings-pagination input[name=blog-category]').should('have.value', '3');
+    cy.get('.data-form.settings-pagination input[name=blog-index]').clear().type('5');
+    cy.get('.data-form.settings-pagination input[name=blog-category]').clear().type('5');
+    cy.get('#add-edit-button').click();
+    cy.actionWait();
+    cy.logoutCMS();
+  });
+});

+ 17 - 0
cypress/plugins/index.js

@@ -0,0 +1,17 @@
+// ***********************************************************
+// This example plugins/index.js can be used to load plugins
+//
+// You can change the location of this file or turn off loading
+// the plugins file with the 'pluginsFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/plugins-guide
+// ***********************************************************
+
+// This function is called when a project is opened or re-opened (e.g. due to
+// the project's config changing)
+
+module.exports = (on, config) => {
+  // `on` is used to hook into various events Cypress emits
+  // `config` is the resolved Cypress config
+}

+ 99 - 0
cypress/support/commands.js

@@ -0,0 +1,99 @@
+// ***********************************************
+// This example commands.js shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add("login", (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This is will overwrite an existing command --
+// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
+
+function getBaseUrl() {
+  return 'http://localhost:8080';
+}
+
+cy.getBaseUrl = function() {
+  return getBaseUrl();
+}
+
+Cypress.Commands.add('visitCMS', (url) => {
+  cy.visit(getBaseUrl() + url);
+});
+
+Cypress.Commands.add('actionStart', () => {
+  cy.server();
+  cy.route({
+    method: 'POST',
+    url: '/*',
+  }).as('formAction');
+});
+
+Cypress.Commands.add('actionWait', () => {
+  cy.wait('@formAction');
+});
+
+Cypress.Commands.add('resetCMS', () => {
+  cy.request({
+    method: 'POST',
+    url: getBaseUrl() + '/',
+    form: true,
+    body: {
+      action: 'index-cypress-reset',
+    }
+  }).then((response) => {
+    expect(response.body).to.eq('OK');
+  });
+});
+
+Cypress.Commands.add('installCMS', () => {
+  cy.actionStart();
+  cy.resetCMS();
+
+  cy.visitCMS('/cp/');
+  cy.get('.form-signin input[name=name]').type('fave');
+  cy.get('.form-signin input[name=user]').type('root');
+  cy.get('.form-signin input[name=password]').type('root');
+  cy.get('.form-signin button').click();
+  cy.actionWait();
+
+  cy.visitCMS('/cp/');
+  cy.get('.form-signin input[name=first_name]').type('First');
+  cy.get('.form-signin input[name=last_name]').type('Last');
+  cy.get('.form-signin input[name=email]').type('example@example.com');
+  cy.get('.form-signin input[name=password]').type('example@example.com');
+  cy.get('.form-signin button').click();
+  cy.actionWait();
+});
+
+Cypress.Commands.add('loginCMS', () => {
+  cy.actionStart();
+  cy.visitCMS('/cp/');
+  cy.get('.form-signin input[name=email]').type('example@example.com');
+  cy.get('.form-signin input[name=password]').type('example@example.com');
+  cy.get('.form-signin button').click();
+  cy.actionWait();
+});
+
+Cypress.Commands.add('logoutCMS', () => {
+  cy.actionStart();
+  cy.visitCMS('/cp/');
+  cy.get('#navbarCollapse ul.navbar-nav:nth-child(2) li.nav-item:nth-child(1) a.nav-link').click();
+  cy.contains('#navbarCollapse ul.navbar-nav:nth-child(2) li.nav-item:nth-child(1) div.dropdown-menu a', 'Logout').click();
+  cy.actionWait();
+});

+ 20 - 0
cypress/support/index.js

@@ -0,0 +1,20 @@
+// ***********************************************************
+// This example support/index.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')

+ 0 - 28
database/schema.sql

@@ -1,28 +0,0 @@
-CREATE TABLE `users` (
-	`id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'AI',
-	`first_name` varchar(64) NOT NULL DEFAULT '' COMMENT 'User first name',
-	`last_name` varchar(64) NOT NULL DEFAULT '' COMMENT 'User last name',
-	`email` varchar(64) NOT NULL COMMENT 'User email',
-	`password` varchar(32) NOT NULL COMMENT 'User password (MD5)',
-	`admin` int(1) NOT NULL COMMENT 'Is admin user or not',
-	`active` int(1) NOT NULL COMMENT 'Is active user or not',
-	PRIMARY KEY (`id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
-ALTER TABLE `users` ADD UNIQUE KEY `email` (`email`);
-
-CREATE TABLE `pages` (
-	`id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'AI',
-	`user` int(11) NOT NULL COMMENT 'User id',
-	`name` varchar(255) NOT NULL COMMENT 'Page name',
-	`alias` varchar(255) NOT NULL COMMENT 'Page url part',
-	`content` text NOT NULL COMMENT 'Page content',
-	`meta_title` varchar(255) NOT NULL DEFAULT '' COMMENT 'Page meta title',
-	`meta_keywords` varchar(255) NOT NULL DEFAULT '' COMMENT 'Page meta keywords',
-	`meta_description` varchar(510) NOT NULL DEFAULT '' COMMENT 'Page meta description',
-	`datetime` datetime NOT NULL COMMENT 'Creation date/time',
-	`active` int(1) NOT NULL COMMENT 'Is active page or not',
-	PRIMARY KEY (`id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
-ALTER TABLE `pages` ADD UNIQUE KEY `alias` (`alias`);

+ 16 - 3
engine/builder/data_form.go

@@ -4,11 +4,13 @@ import (
 	"html"
 
 	"golang-fave/engine/wrapper"
+	"golang-fave/utils"
 )
 
 const (
 	DFKHidden = iota
 	DFKText
+	DFKNumber
 	DFKEmail
 	DFKPassword
 	DFKTextArea
@@ -27,6 +29,8 @@ type DataFormField struct {
 	Target      string
 	Required    bool
 	Classes     string
+	Min         string
+	Max         string
 	CallBack    func(field *DataFormField) string
 }
 
@@ -36,7 +40,7 @@ func DataForm(wrap *wrapper.Wrapper, data []DataFormField) string {
 	var html_message string
 	var html_button string
 
-	for _, field := range data {
+	for i, field := range data {
 		if field.Kind == DFKHidden {
 			if field.CallBack != nil {
 				html_hidden += field.CallBack(&field)
@@ -57,7 +61,7 @@ func DataForm(wrap *wrapper.Wrapper, data []DataFormField) string {
 					classes = " " + classes
 				}
 
-				html_element += `<div class="form-group">`
+				html_element += `<div class="form-group n` + utils.IntToStr(i) + `">`
 				html_element += `<div class="row">`
 				html_element += `<div class="col-md-3">`
 
@@ -72,6 +76,15 @@ func DataForm(wrap *wrapper.Wrapper, data []DataFormField) string {
 				html_element += `<div>`
 				if field.Kind == DFKText {
 					html_element += `<input class="form-control` + classes + `" type="text" id="lbl_` + field.Name + `" name="` + field.Name + `" value="` + html.EscapeString(field.Value) + `" placeholder="` + field.Placeholder + `" autocomplete="off"` + required + `>`
+				} else if field.Kind == DFKNumber {
+					html_element += `<input class="form-control` + classes + `" type="number" id="lbl_` + field.Name + `" name="` + field.Name + `" value="` + html.EscapeString(field.Value) + `" `
+					if field.Min != "" {
+						html_element += `min="` + field.Min + `" `
+					}
+					if field.Max != "" {
+						html_element += `max="` + field.Max + `" `
+					}
+					html_element += `placeholder="` + field.Placeholder + `" autocomplete="off"` + required + `>`
 				} else if field.Kind == DFKEmail {
 					html_element += `<input class="form-control` + classes + `" type="email" id="lbl_` + field.Name + `" name="` + field.Name + `" value="` + html.EscapeString(field.Value) + `" placeholder="` + field.Placeholder + `" autocomplete="off"` + required + `>`
 				} else if field.Kind == DFKPassword {
@@ -125,6 +138,6 @@ func DataForm(wrap *wrapper.Wrapper, data []DataFormField) string {
 		html_hidden = `<div class="hidden">` + html_hidden + `</div>`
 	}
 
-	return `<form class="data-form prev-data-lost" action="/cp/" method="post" autocomplete="off">` +
+	return `<form class="data-form ` + wrap.CurrModule + `-` + wrap.CurrSubModule + ` prev-data-lost" action="/cp/" method="post" autocomplete="off">` +
 		html_hidden + html_element + html_message + html_button + `</form>`
 }

+ 90 - 44
engine/builder/data_table.go

@@ -6,6 +6,7 @@ import (
 	"math"
 	"strconv"
 
+	"golang-fave/engine/sqlw"
 	"golang-fave/engine/wrapper"
 )
 
@@ -17,12 +18,34 @@ type DataTableRow struct {
 	CallBack    func(values *[]string) string
 }
 
-func DataTable(wrap *wrapper.Wrapper, table string, order_by string, order_way string, data *[]DataTableRow, action func(values *[]string) string, pagination_url string) string {
+func DataTable(
+	wrap *wrapper.Wrapper,
+	table string,
+	order_by string,
+	order_way string,
+	data *[]DataTableRow,
+	action func(values *[]string) string,
+	pagination_url string,
+	custom_sql_count func() (int, error),
+	custom_sql_data func(limit_offset int, pear_page int) (*sqlw.Rows, error),
+	pagination_enabled bool,
+) string {
 	var num int
-	err := wrap.DB.QueryRow("SELECT COUNT(*) FROM `" + table + "`;").Scan(&num)
-	if err != nil {
-		return ""
+	var err error
+
+	if pagination_enabled {
+		if custom_sql_count != nil {
+			num, err = custom_sql_count()
+		} else {
+			err = wrap.DB.QueryRow("SELECT COUNT(*) FROM `" + table + "`;").Scan(&num)
+			if err != nil {
+				return ""
+			}
+		}
+	} else {
+		num = 0
 	}
+
 	pear_page := 10
 	max_pages := int(math.Ceil(float64(num) / float64(pear_page)))
 	curr_page := 1
@@ -45,7 +68,10 @@ func DataTable(wrap *wrapper.Wrapper, table string, order_by string, order_way s
 	result := `<table id="cp-table-` + table + `" class="table data-table table-striped table-bordered table-hover table_` + table + `">`
 	result += `<thead>`
 	result += `<tr>`
-	sql := "SELECT"
+	qsql := ""
+	if custom_sql_data == nil {
+		qsql = "SELECT"
+	}
 	for i, column := range *data {
 		if column.NameInTable != "" {
 			classes := column.Classes
@@ -54,24 +80,35 @@ func DataTable(wrap *wrapper.Wrapper, table string, order_by string, order_way s
 			}
 			result += `<th scope="col" class="col_` + column.DBField + classes + `">` + html.EscapeString(column.NameInTable) + `</th>`
 		}
-		if column.DBExp == "" {
-			sql += " `" + column.DBField + "`"
-		} else {
-			sql += " " + column.DBExp + " as `" + column.DBField + "`"
-		}
-		if i+1 < len(*data) {
-			sql += ","
+		if custom_sql_data == nil {
+			if column.DBExp == "" {
+				qsql += " `" + column.DBField + "`"
+			} else {
+				qsql += " " + column.DBExp + " as `" + column.DBField + "`"
+			}
+			if i+1 < len(*data) {
+				qsql += ","
+			}
 		}
 	}
-	sql += " FROM `" + table + "` ORDER BY `" + order_by + "` " + order_way + " LIMIT ?, ?;"
+	if custom_sql_data == nil {
+		qsql += " FROM `" + table + "` ORDER BY `" + order_by + "` " + order_way + " LIMIT ?, ?;"
+	}
 	if action != nil {
 		result += `<th scope="col" class="col_action">&nbsp;</th>`
 	}
 	result += `</tr>`
 	result += `</thead>`
 	result += `<tbody>`
-	if num > 0 {
-		rows, err := wrap.DB.Query(sql, limit_offset, pear_page)
+	if num > 0 || !pagination_enabled {
+		have_records := false
+		var rows *sqlw.Rows
+		var err error
+		if custom_sql_data == nil {
+			rows, err = wrap.DB.Query(qsql, limit_offset, pear_page)
+		} else {
+			rows, err = custom_sql_data(limit_offset, pear_page)
+		}
 		if err == nil {
 			values := make([]string, len(*data))
 			scan := make([]interface{}, len(values))
@@ -81,6 +118,9 @@ func DataTable(wrap *wrapper.Wrapper, table string, order_by string, order_way s
 			for rows.Next() {
 				err = rows.Scan(scan...)
 				if err == nil {
+					if !have_records {
+						have_records = true
+					}
 					result += `<tr>`
 					for i, val := range values {
 						if (*data)[i].NameInTable != "" {
@@ -101,6 +141,10 @@ func DataTable(wrap *wrapper.Wrapper, table string, order_by string, order_way s
 					result += `</tr>`
 				}
 			}
+			rows.Close()
+		}
+		if !have_records {
+			result += `<tr><td colspan="50">No any data found</td></tr>`
 		}
 	} else {
 		result += `<tr><td colspan="50">No any data found</td></tr>`
@@ -108,40 +152,42 @@ func DataTable(wrap *wrapper.Wrapper, table string, order_by string, order_way s
 	result += `</tbody></table>`
 
 	// Show page navigation only if pages more then one
-	if max_pages > 1 {
-		result += `<nav>`
-		result += `<ul class="pagination" style="margin-bottom:0px;">`
-		class := ""
-		if curr_page <= 1 {
-			class = " disabled"
-		}
-		result += `<li class="page-item` + class + `">`
-		result += `<a class="page-link" href="` + pagination_url + `?p=` + fmt.Sprintf("%d", curr_page-1) + `" aria-label="Previous">`
-		result += `<span aria-hidden="true">&laquo;</span>`
-		result += `<span class="sr-only">Previous</span>`
-		result += `</a>`
-		result += `</li>`
-		for i := 1; i <= max_pages; i++ {
+	if pagination_enabled {
+		if max_pages > 1 {
+			result += `<nav>`
+			result += `<ul class="pagination" style="margin-bottom:0px;">`
+			class := ""
+			if curr_page <= 1 {
+				class = " disabled"
+			}
+			result += `<li class="page-item` + class + `">`
+			result += `<a class="page-link" href="` + pagination_url + `?p=` + fmt.Sprintf("%d", curr_page-1) + `" aria-label="Previous">`
+			result += `<span aria-hidden="true">&laquo;</span>`
+			result += `<span class="sr-only">Previous</span>`
+			result += `</a>`
+			result += `</li>`
+			for i := 1; i <= max_pages; i++ {
+				class = ""
+				if i == curr_page {
+					class = " active"
+				}
+				result += `<li class="page-item` + class + `">`
+				result += `<a class="page-link" href="` + pagination_url + `?p=` + fmt.Sprintf("%d", i) + `">` + fmt.Sprintf("%d", i) + `</a>`
+				result += `</li>`
+			}
 			class = ""
-			if i == curr_page {
-				class = " active"
+			if curr_page >= max_pages {
+				class = " disabled"
 			}
 			result += `<li class="page-item` + class + `">`
-			result += `<a class="page-link" href="` + pagination_url + `?p=` + fmt.Sprintf("%d", i) + `">` + fmt.Sprintf("%d", i) + `</a>`
+			result += `<a class="page-link" href="` + pagination_url + `?p=` + fmt.Sprintf("%d", curr_page+1) + `" aria-label="Next">`
+			result += `<span aria-hidden="true">&raquo;</span>`
+			result += `<span class="sr-only">Next</span>`
+			result += `</a>`
 			result += `</li>`
+			result += `</ul>`
+			result += `</nav>`
 		}
-		class = ""
-		if curr_page >= max_pages {
-			class = " disabled"
-		}
-		result += `<li class="page-item` + class + `">`
-		result += `<a class="page-link" href="` + pagination_url + `?p=` + fmt.Sprintf("%d", curr_page+1) + `" aria-label="Next">`
-		result += `<span aria-hidden="true">&raquo;</span>`
-		result += `<span class="sr-only">Next</span>`
-		result += `</a>`
-		result += `</li>`
-		result += `</ul>`
-		result += `</nav>`
 	}
 
 	return result

+ 0 - 2
engine/builder/data_table_action.go

@@ -15,12 +15,10 @@ func DataTableAction(data *[]DataTableActionRow) string {
 		if row.Target != "" {
 			target = ` target="` + row.Target + `"`
 		}
-
 		classes := row.Classes
 		if classes != "" {
 			classes = " " + classes
 		}
-
 		result += `<a class="ico` + classes + `" title="` + row.Hint + `" href="` +
 			row.Href + `"` + target + `>` + row.Icon + `</a>`
 	}

+ 3 - 8
engine/engine.go

@@ -6,6 +6,7 @@ import (
 	"strings"
 
 	"golang-fave/assets"
+	"golang-fave/engine/mysqlpool"
 	"golang-fave/engine/wrapper"
 	"golang-fave/logger"
 	"golang-fave/modules"
@@ -19,8 +20,8 @@ type Engine struct {
 	Mods *modules.Modules
 }
 
-func Response(l *logger.Logger, m *modules.Modules, w http.ResponseWriter, r *http.Request, s *session.Session, host, port, chost, dirConfig, dirHtdocs, dirLogs, dirTemplate, dirTmp string) bool {
-	wrap := wrapper.New(l, w, r, s, host, port, chost, dirConfig, dirHtdocs, dirLogs, dirTemplate, dirTmp)
+func Response(mp *mysqlpool.MySqlPool, l *logger.Logger, m *modules.Modules, w http.ResponseWriter, r *http.Request, s *session.Session, host, port, chost, dirConfig, dirHtdocs, dirLogs, dirTemplate, dirTmp string) bool {
+	wrap := wrapper.New(l, w, r, s, host, port, chost, dirConfig, dirHtdocs, dirLogs, dirTemplate, dirTmp, mp)
 	eng := &Engine{
 		Wrap: wrap,
 		Mods: m,
@@ -29,11 +30,6 @@ func Response(l *logger.Logger, m *modules.Modules, w http.ResponseWriter, r *ht
 }
 
 func (this *Engine) Process() bool {
-	// Check and set session user
-	if !this.Wrap.S.IsSetInt("UserId") {
-		this.Wrap.S.SetInt("UserId", 0)
-	}
-
 	this.Wrap.IsBackend = this.Wrap.R.URL.Path == "/cp" || strings.HasPrefix(this.Wrap.R.URL.Path, "/cp/")
 	this.Wrap.ConfMysqlExists = utils.IsMySqlConfigExists(this.Wrap.DConfig + string(os.PathSeparator) + "mysql.json")
 	this.Wrap.UrlArgs = append(this.Wrap.UrlArgs, utils.UrlToArray(this.Wrap.R.URL.Path)...)
@@ -76,7 +72,6 @@ func (this *Engine) Process() bool {
 		utils.SystemErrorPageEngine(this.Wrap.W, err)
 		return true
 	}
-	defer this.Wrap.DB.Close()
 
 	// Show add first user form if no any user in database
 	if !utils.IsFileExists(this.Wrap.DConfig + string(os.PathSeparator) + ".installed") {

+ 381 - 0
engine/fetdata/blog.go

@@ -0,0 +1,381 @@
+package fetdata
+
+import (
+	"math"
+	"strings"
+
+	"golang-fave/engine/wrapper"
+	"golang-fave/utils"
+)
+
+type BlogPagination struct {
+	Num     string
+	Link    string
+	Current bool
+	Dots    bool
+}
+
+type Blog struct {
+	wrap     *wrapper.Wrapper
+	category *BlogCategory
+	post     *BlogPost
+
+	posts          []*BlogPost
+	postsCount     int
+	postsPerPage   int
+	postsMaxPage   int
+	postsCurrPage  int
+	pagination     []*BlogPagination
+	paginationPrev *BlogPagination
+	paginationNext *BlogPagination
+
+	bufferCats map[string][]*BlogCategory
+}
+
+func (this *Blog) init() {
+	if this == nil {
+		return
+	}
+	sql_nums := `
+		SELECT
+			COUNT(*)
+		FROM
+			blog_posts
+		WHERE
+			active = 1
+		;
+	`
+	sql_rows := `
+		SELECT
+			id,
+			user,
+			name,
+			alias,
+			briefly,
+			content,
+			UNIX_TIMESTAMP(datetime) as datetime,
+			active
+		FROM
+			blog_posts
+		WHERE
+			active = 1
+		ORDER BY
+			id DESC
+		LIMIT ?, ?;
+	`
+
+	// Category selected
+	if this.category != nil {
+		var cat_ids []string
+		if rows, err := this.wrap.DB.Query(
+			`SELECT
+				node.id
+			FROM
+				blog_cats AS node,
+				blog_cats AS parent
+			WHERE
+				node.lft BETWEEN parent.lft AND parent.rgt AND
+				node.id > 1 AND
+				parent.id = ?
+			GROUP BY
+				node.id
+			ORDER BY
+				node.lft ASC
+			;`,
+			this.category.Id(),
+		); err == nil {
+			defer rows.Close()
+			for rows.Next() {
+				var cat_id string
+				if err := rows.Scan(&cat_id); err == nil {
+					cat_ids = append(cat_ids, cat_id)
+				}
+			}
+		}
+		sql_nums = `
+			SELECT
+				COUNT(*)
+			FROM
+				(
+					SELECT
+						COUNT(*)
+					FROM
+						blog_posts
+						LEFT JOIN blog_cat_post_rel ON blog_cat_post_rel.post_id = blog_posts.id
+					WHERE
+						blog_posts.active = 1 AND
+						blog_cat_post_rel.category_id IN (` + strings.Join(cat_ids, ", ") + `)
+					GROUP BY
+						blog_posts.id
+				) AS tbl
+			;
+		`
+		sql_rows = `
+			SELECT
+				blog_posts.id,
+				blog_posts.user,
+				blog_posts.name,
+				blog_posts.alias,
+				blog_posts.briefly,
+				blog_posts.content,
+				UNIX_TIMESTAMP(blog_posts.datetime) AS datetime,
+				blog_posts.active
+			FROM
+				blog_posts
+				LEFT JOIN blog_cat_post_rel ON blog_cat_post_rel.post_id = blog_posts.id
+			WHERE
+				blog_posts.active = 1 AND
+				blog_cat_post_rel.category_id IN (` + strings.Join(cat_ids, ", ") + `)
+			GROUP BY
+				blog_posts.id
+			ORDER BY
+				blog_posts.id DESC
+			LIMIT ?, ?;
+		`
+	}
+
+	if err := this.wrap.DB.QueryRow(sql_nums).Scan(&this.postsCount); err == nil {
+		if this.category == nil {
+			this.postsPerPage = (*this.wrap.Config).Blog.Pagination.Index
+		} else {
+			this.postsPerPage = (*this.wrap.Config).Blog.Pagination.Category
+		}
+		this.postsMaxPage = int(math.Ceil(float64(this.postsCount) / float64(this.postsPerPage)))
+		this.postsCurrPage = this.wrap.GetCurrentPage(this.postsMaxPage)
+		offset := this.postsCurrPage*this.postsPerPage - this.postsPerPage
+		if rows, err := this.wrap.DB.Query(sql_rows, offset, this.postsPerPage); err == nil {
+			defer rows.Close()
+			for rows.Next() {
+				row := utils.MySql_blog_post{}
+				if err := rows.Scan(&row.A_id, &row.A_user, &row.A_name, &row.A_alias, &row.A_briefly, &row.A_content, &row.A_datetime, &row.A_active); err == nil {
+					this.posts = append(this.posts, &BlogPost{object: &row})
+				}
+			}
+		}
+	}
+
+	// Build pagination
+	if true {
+		for i := 1; i < this.postsCurrPage; i++ {
+			if this.postsCurrPage >= 5 && i > 1 && i < this.postsCurrPage-1 {
+				continue
+			}
+			if this.postsCurrPage >= 5 && i > 1 && i < this.postsCurrPage {
+				this.pagination = append(this.pagination, &BlogPagination{
+					Dots: true,
+				})
+			}
+			link := this.wrap.R.URL.Path
+			if i > 1 {
+				link = link + "?p=" + utils.IntToStr(i)
+			}
+			this.pagination = append(this.pagination, &BlogPagination{
+				Num:     utils.IntToStr(i),
+				Link:    link,
+				Current: false,
+			})
+		}
+
+		// Current page
+		link := this.wrap.R.URL.Path
+		if this.postsCurrPage > 1 {
+			link = link + "?p=" + utils.IntToStr(this.postsCurrPage)
+		}
+		this.pagination = append(this.pagination, &BlogPagination{
+			Num:     utils.IntToStr(this.postsCurrPage),
+			Link:    link,
+			Current: true,
+		})
+
+		for i := this.postsCurrPage + 1; i <= this.postsMaxPage; i++ {
+			if this.postsCurrPage < this.postsMaxPage-3 && i == this.postsCurrPage+3 {
+				this.pagination = append(this.pagination, &BlogPagination{
+					Dots: true,
+				})
+			}
+			if this.postsCurrPage < this.postsMaxPage-3 && i > this.postsCurrPage+1 && i <= this.postsMaxPage-1 {
+				continue
+			}
+			link := this.wrap.R.URL.Path
+			if i > 1 {
+				link = link + "?p=" + utils.IntToStr(i)
+			}
+			this.pagination = append(this.pagination, &BlogPagination{
+				Num:     utils.IntToStr(i),
+				Link:    link,
+				Current: false,
+			})
+		}
+	} else {
+		for i := 1; i <= this.postsMaxPage; i++ {
+			link := this.wrap.R.URL.Path
+			if i > 1 {
+				link = link + "?p=" + utils.IntToStr(i)
+			}
+			this.pagination = append(this.pagination, &BlogPagination{
+				Num:     utils.IntToStr(i),
+				Link:    link,
+				Current: i == this.postsCurrPage,
+			})
+		}
+	}
+
+	// Pagination prev/next
+	if this.postsMaxPage > 1 {
+		link := this.wrap.R.URL.Path
+		if this.postsCurrPage-1 > 1 {
+			link = this.wrap.R.URL.Path + "?p=" + utils.IntToStr(this.postsCurrPage-1)
+		}
+		this.paginationPrev = &BlogPagination{
+			Num:     utils.IntToStr(this.postsCurrPage - 1),
+			Link:    link,
+			Current: this.postsCurrPage <= 1,
+		}
+		if this.postsCurrPage >= 1 && this.postsCurrPage < this.postsMaxPage {
+			link = this.wrap.R.URL.Path + "?p=" + utils.IntToStr(this.postsCurrPage+1)
+		} else {
+			link = this.wrap.R.URL.Path + "?p=" + utils.IntToStr(this.postsMaxPage)
+		}
+		this.paginationNext = &BlogPagination{
+			Num:     utils.IntToStr(this.postsCurrPage + 1),
+			Link:    link,
+			Current: this.postsCurrPage >= this.postsMaxPage,
+		}
+	}
+}
+
+func (this *Blog) Category() *BlogCategory {
+	if this == nil {
+		return nil
+	}
+	return this.category
+}
+
+func (this *Blog) Post() *BlogPost {
+	if this == nil {
+		return nil
+	}
+	return this.post
+}
+
+func (this *Blog) HavePosts() bool {
+	if this == nil {
+		return false
+	}
+	if len(this.posts) <= 0 {
+		return false
+	}
+	return true
+}
+
+func (this *Blog) Posts() []*BlogPost {
+	if this == nil {
+		return []*BlogPost{}
+	}
+	return this.posts
+}
+
+func (this *Blog) PostsCount() int {
+	if this == nil {
+		return 0
+	}
+	return this.postsCount
+}
+
+func (this *Blog) PostsPerPage() int {
+	if this == nil {
+		return 0
+	}
+	return this.postsPerPage
+}
+
+func (this *Blog) PostsMaxPage() int {
+	if this == nil {
+		return 0
+	}
+	return this.postsMaxPage
+}
+
+func (this *Blog) PostsCurrPage() int {
+	if this == nil {
+		return 0
+	}
+	return this.postsCurrPage
+}
+
+func (this *Blog) Pagination() []*BlogPagination {
+	if this == nil {
+		return []*BlogPagination{}
+	}
+	return this.pagination
+}
+
+func (this *Blog) PaginationPrev() *BlogPagination {
+	if this == nil {
+		return nil
+	}
+	return this.paginationPrev
+}
+
+func (this *Blog) PaginationNext() *BlogPagination {
+	if this == nil {
+		return nil
+	}
+	return this.paginationNext
+}
+
+func (this *Blog) Categories(mlvl int) []*BlogCategory {
+	if this == nil {
+		return []*BlogCategory{}
+	}
+	if this.bufferCats == nil {
+		this.bufferCats = map[string][]*BlogCategory{}
+	}
+	key := ""
+	where := ``
+	if mlvl > 0 {
+		where += `AND tbl.depth <= ` + utils.IntToStr(mlvl)
+	}
+	if _, ok := this.bufferCats[key]; !ok {
+		var cats []*BlogCategory
+		if rows, err := this.wrap.DB.Query(`
+			SELECT
+				tbl.*
+			FROM
+				(
+					SELECT
+						node.id,
+						node.user,
+						node.name,
+						node.alias,
+						node.lft,
+						node.rgt,
+						(COUNT(parent.id) - 1) AS depth
+					FROM
+						blog_cats AS node,
+						blog_cats AS parent
+					WHERE
+						node.lft BETWEEN parent.lft AND parent.rgt
+					GROUP BY
+						node.id
+					ORDER BY
+						node.lft ASC
+				) AS tbl
+			WHERE
+				tbl.id > 1
+				` + where + `
+			;
+		`); err == nil {
+			defer rows.Close()
+			for rows.Next() {
+				row := utils.MySql_blog_category{}
+				var Depth int
+				if err := rows.Scan(&row.A_id, &row.A_user, &row.A_name, &row.A_alias, &row.A_lft, &row.A_rgt, &Depth); err == nil {
+					cats = append(cats, &BlogCategory{object: &row, depth: Depth})
+				}
+			}
+		}
+		this.bufferCats[key] = cats
+	}
+	return this.bufferCats[key]
+}

+ 66 - 0
engine/fetdata/blog_category.go

@@ -0,0 +1,66 @@
+package fetdata
+
+import (
+	"golang-fave/utils"
+)
+
+type BlogCategory struct {
+	object *utils.MySql_blog_category
+	depth  int
+}
+
+func (this *BlogCategory) Id() int {
+	if this == nil {
+		return 0
+	}
+	return this.object.A_id
+}
+
+func (this *BlogCategory) User() int {
+	if this == nil {
+		return 0
+	}
+	return this.object.A_user
+}
+
+func (this *BlogCategory) Name() string {
+	if this == nil {
+		return ""
+	}
+	return this.object.A_name
+}
+
+func (this *BlogCategory) Alias() string {
+	if this == nil {
+		return ""
+	}
+	return this.object.A_alias
+}
+
+func (this *BlogCategory) Left() int {
+	if this == nil {
+		return 0
+	}
+	return this.object.A_lft
+}
+
+func (this *BlogCategory) Right() int {
+	if this == nil {
+		return 0
+	}
+	return this.object.A_rgt
+}
+
+func (this *BlogCategory) Permalink() string {
+	if this == nil {
+		return ""
+	}
+	return "/blog/category/" + this.object.A_alias + "/"
+}
+
+func (this *BlogCategory) Level() int {
+	if this == nil {
+		return 0
+	}
+	return this.depth
+}

+ 82 - 0
engine/fetdata/blog_post.go

@@ -0,0 +1,82 @@
+package fetdata
+
+import (
+	"html/template"
+	"time"
+
+	"golang-fave/utils"
+)
+
+type BlogPost struct {
+	object *utils.MySql_blog_post
+}
+
+func (this *BlogPost) Id() int {
+	if this == nil {
+		return 0
+	}
+	return this.object.A_id
+}
+
+func (this *BlogPost) User() int {
+	if this == nil {
+		return 0
+	}
+	return this.object.A_user
+}
+
+func (this *BlogPost) Name() string {
+	if this == nil {
+		return ""
+	}
+	return this.object.A_name
+}
+
+func (this *BlogPost) Alias() string {
+	if this == nil {
+		return ""
+	}
+	return this.object.A_alias
+}
+
+func (this *BlogPost) Briefly() template.HTML {
+	if this == nil {
+		return template.HTML("")
+	}
+	return template.HTML(this.object.A_briefly)
+}
+
+func (this *BlogPost) Content() template.HTML {
+	if this == nil {
+		return template.HTML("")
+	}
+	return template.HTML(this.object.A_content)
+}
+
+func (this *BlogPost) DateTimeUnix() int {
+	if this == nil {
+		return 0
+	}
+	return this.object.A_datetime
+}
+
+func (this *BlogPost) DateTimeFormat(format string) string {
+	if this == nil {
+		return ""
+	}
+	return time.Unix(int64(this.object.A_datetime), 0).Format(format)
+}
+
+func (this *BlogPost) Active() bool {
+	if this == nil {
+		return false
+	}
+	return this.object.A_active > 0
+}
+
+func (this *BlogPost) Permalink() string {
+	if this == nil {
+		return ""
+	}
+	return "/blog/" + this.object.A_alias + "/"
+}

+ 0 - 53
engine/fetdata/content.go

@@ -1,53 +0,0 @@
-package fetdata
-
-import (
-	"html/template"
-	"time"
-
-	"golang-fave/utils"
-)
-
-func (this *FERData) Name() string {
-	if this.dataRow != nil {
-		if this.wrap.CurrModule == "index" {
-			return this.dataRow.(*utils.MySql_page).A_name
-		}
-	}
-	return ""
-}
-
-func (this *FERData) Alias() string {
-	if this.dataRow != nil {
-		if this.wrap.CurrModule == "index" {
-			return this.dataRow.(*utils.MySql_page).A_alias
-		}
-	}
-	return ""
-}
-
-func (this *FERData) Content() template.HTML {
-	if this.dataRow != nil {
-		if this.wrap.CurrModule == "index" {
-			return template.HTML(this.dataRow.(*utils.MySql_page).A_content)
-		}
-	}
-	return template.HTML("")
-}
-
-func (this *FERData) DateTime() int {
-	if this.dataRow != nil {
-		if this.wrap.CurrModule == "index" {
-			return this.dataRow.(*utils.MySql_page).A_datetime
-		}
-	}
-	return 0
-}
-
-func (this *FERData) DateTimeFormat(format string) string {
-	if this.dataRow != nil {
-		if this.wrap.CurrModule == "index" {
-			return time.Unix(int64(this.dataRow.(*utils.MySql_page).A_datetime), 0).Format(format)
-		}
-	}
-	return ""
-}

+ 73 - 17
engine/fetdata/fetdata.go

@@ -1,38 +1,94 @@
 package fetdata
 
 import (
+	"time"
+
 	"golang-fave/engine/wrapper"
 	"golang-fave/utils"
 )
 
 type FERData struct {
-	wrap    *wrapper.Wrapper
-	dataRow interface{}
-	is404   bool
+	wrap  *wrapper.Wrapper
+	is404 bool
 
-	bufferUser *utils.MySql_user
+	Page *Page
+	Blog *Blog
 }
 
 func New(wrap *wrapper.Wrapper, drow interface{}, is404 bool) *FERData {
+	var d_Page *Page
+	var d_Blog *Blog
+
+	if wrap.CurrModule == "index" {
+		if o, ok := drow.(*utils.MySql_page); ok {
+			d_Page = &Page{object: o}
+		}
+	} else if wrap.CurrModule == "blog" {
+		if len(wrap.UrlArgs) == 3 && wrap.UrlArgs[0] == "blog" && wrap.UrlArgs[1] == "category" && wrap.UrlArgs[2] != "" {
+			if o, ok := drow.(*utils.MySql_blog_category); ok {
+				d_Blog = &Blog{wrap: wrap, category: &BlogCategory{object: o}}
+				d_Blog.init()
+			}
+		} else if len(wrap.UrlArgs) == 2 && wrap.UrlArgs[0] == "blog" && wrap.UrlArgs[1] != "" {
+			if o, ok := drow.(*utils.MySql_blog_post); ok {
+				d_Blog = &Blog{wrap: wrap, post: &BlogPost{object: o}}
+				d_Blog.init()
+			}
+		} else {
+			d_Blog = &Blog{wrap: wrap}
+			d_Blog.init()
+		}
+	}
+
+	if d_Blog == nil {
+		d_Blog = &Blog{wrap: wrap}
+	}
+
 	fer := &FERData{
-		wrap:    wrap,
-		dataRow: drow,
-		is404:   is404,
+		wrap:  wrap,
+		is404: is404,
+		Page:  d_Page,
+		Blog:  d_Blog,
 	}
-	return fer.init()
+
+	return fer
 }
 
-func (this *FERData) init() *FERData {
-	if this.dataRow != nil {
-		if this.wrap.CurrModule == "index" {
-			if this.dataRow.(*utils.MySql_page).A_meta_title == "" {
-				this.dataRow.(*utils.MySql_page).A_meta_title = this.dataRow.(*utils.MySql_page).A_name
-			}
+func (this *FERData) RequestURI() string {
+	return this.wrap.R.RequestURI
+}
+
+func (this *FERData) RequestURL() string {
+	return this.wrap.R.URL.Path
+}
+
+func (this *FERData) RequestGET() string {
+	return utils.ExtractGetParams(this.wrap.R.RequestURI)
+}
+
+func (this *FERData) Module() string {
+	if this.is404 {
+		return "404"
+	}
+	var mod string
+	if this.wrap.CurrModule == "index" {
+		mod = "index"
+	} else if this.wrap.CurrModule == "blog" {
+		if len(this.wrap.UrlArgs) == 3 && this.wrap.UrlArgs[0] == "blog" && this.wrap.UrlArgs[1] == "category" && this.wrap.UrlArgs[2] != "" {
+			mod = "blog-category"
+		} else if len(this.wrap.UrlArgs) == 2 && this.wrap.UrlArgs[0] == "blog" && this.wrap.UrlArgs[1] != "" {
+			mod = "blog-post"
+		} else {
+			mod = "blog"
 		}
 	}
-	return this
+	return mod
+}
+
+func (this *FERData) DateTimeUnix() int {
+	return int(time.Now().Unix())
 }
 
-func (this *FERData) Is404() bool {
-	return this.is404
+func (this *FERData) DateTimeFormat(format string) string {
+	return time.Unix(int64(time.Now().Unix()), 0).Format(format)
 }

+ 0 - 17
engine/fetdata/http.go

@@ -1,17 +0,0 @@
-package fetdata
-
-import (
-	"golang-fave/utils"
-)
-
-func (this *FERData) RequestURI() string {
-	return this.wrap.R.RequestURI
-}
-
-func (this *FERData) RequestURL() string {
-	return this.wrap.R.URL.Path
-}
-
-func (this *FERData) RequestGET() string {
-	return utils.ExtractGetParams(this.wrap.R.RequestURI)
-}

+ 89 - 0
engine/fetdata/index.go

@@ -0,0 +1,89 @@
+package fetdata
+
+import (
+	"html/template"
+	"time"
+
+	"golang-fave/utils"
+)
+
+type Page struct {
+	object *utils.MySql_page
+}
+
+func (this *Page) Id() int {
+	if this == nil {
+		return 0
+	}
+	return this.object.A_id
+}
+
+func (this *Page) User() int {
+	if this == nil {
+		return 0
+	}
+	return this.object.A_user
+}
+
+func (this *Page) Name() string {
+	if this == nil {
+		return ""
+	}
+	return this.object.A_name
+}
+
+func (this *Page) Alias() string {
+	if this == nil {
+		return ""
+	}
+	return this.object.A_alias
+}
+
+func (this *Page) Content() template.HTML {
+	if this == nil {
+		return template.HTML("")
+	}
+	return template.HTML(this.object.A_content)
+}
+
+func (this *Page) MetaTitle() string {
+	if this == nil {
+		return ""
+	}
+	return this.object.A_meta_title
+}
+
+func (this *Page) MetaKeywords() string {
+	if this == nil {
+		return ""
+	}
+	return this.object.A_meta_keywords
+}
+
+func (this *Page) MetaDescription() string {
+	if this == nil {
+		return ""
+	}
+	return this.object.A_meta_description
+}
+
+func (this *Page) DateTimeUnix() int {
+	if this == nil {
+		return 0
+	}
+	return this.object.A_datetime
+}
+
+func (this *Page) DateTimeFormat(format string) string {
+	if this == nil {
+		return ""
+	}
+	return time.Unix(int64(this.object.A_datetime), 0).Format(format)
+}
+
+func (this *Page) Active() bool {
+	if this == nil {
+		return false
+	}
+	return this.object.A_active > 0
+}

+ 0 - 32
engine/fetdata/meta_data.go

@@ -1,32 +0,0 @@
-package fetdata
-
-import (
-	"golang-fave/utils"
-)
-
-func (this *FERData) MetaTitle() string {
-	if this.dataRow != nil {
-		if this.wrap.CurrModule == "index" {
-			return this.dataRow.(*utils.MySql_page).A_meta_title
-		}
-	}
-	return ""
-}
-
-func (this *FERData) MetaKeywords() string {
-	if this.dataRow != nil {
-		if this.wrap.CurrModule == "index" {
-			return this.dataRow.(*utils.MySql_page).A_meta_keywords
-		}
-	}
-	return ""
-}
-
-func (this *FERData) MetaDescription() string {
-	if this.dataRow != nil {
-		if this.wrap.CurrModule == "index" {
-			return this.dataRow.(*utils.MySql_page).A_meta_description
-		}
-	}
-	return ""
-}

+ 0 - 74
engine/fetdata/user.go

@@ -1,74 +0,0 @@
-package fetdata
-
-import (
-	"golang-fave/utils"
-)
-
-func (this *FERData) userToBuffer() {
-	if this.bufferUser == nil {
-		user := utils.MySql_user{}
-		if this.wrap.S.GetInt("UserId", 0) > 0 {
-			err := this.wrap.DB.QueryRow(`
-				SELECT
-					id,
-					first_name,
-					last_name,
-					email,
-					admin,
-					active
-				FROM
-					users
-				WHERE
-					id = ?
-				LIMIT 1;`,
-				this.wrap.S.GetInt("UserId", 0),
-			).Scan(
-				&user.A_id,
-				&user.A_first_name,
-				&user.A_last_name,
-				&user.A_email,
-				&user.A_admin,
-				&user.A_active,
-			)
-			if err != nil {
-				this.wrap.LogError(err.Error())
-			}
-		}
-		this.bufferUser = &user
-	}
-}
-
-func (this *FERData) UserIsLoggedIn() bool {
-	this.userToBuffer()
-	return this.bufferUser.A_id > 0
-}
-
-func (this *FERData) UserID() int {
-	this.userToBuffer()
-	return this.bufferUser.A_id
-}
-
-func (this *FERData) UserFirstName() string {
-	this.userToBuffer()
-	return this.bufferUser.A_first_name
-}
-
-func (this *FERData) UserLastName() string {
-	this.userToBuffer()
-	return this.bufferUser.A_last_name
-}
-
-func (this *FERData) UserEmail() string {
-	this.userToBuffer()
-	return this.bufferUser.A_email
-}
-
-func (this *FERData) UserIsAdmin() bool {
-	this.userToBuffer()
-	return this.bufferUser.A_admin > 0
-}
-
-func (this *FERData) UserIsActive() bool {
-	this.userToBuffer()
-	return this.bufferUser.A_active > 0
-}

+ 43 - 0
engine/mysqlpool/mysqlpool.go

@@ -0,0 +1,43 @@
+package mysqlpool
+
+import (
+	"sync"
+
+	"golang-fave/engine/sqlw"
+)
+
+type MySqlPool struct {
+	sync.RWMutex
+	connections map[string]*sqlw.DB
+}
+
+func New() *MySqlPool {
+	r := MySqlPool{}
+	r.connections = map[string]*sqlw.DB{}
+	return &r
+}
+
+func (this *MySqlPool) Get(key string) *sqlw.DB {
+	this.Lock()
+	defer this.Unlock()
+	if value, ok := this.connections[key]; ok == true {
+		return value
+	}
+	return nil
+}
+
+func (this *MySqlPool) Set(key string, value *sqlw.DB) {
+	this.Lock()
+	defer this.Unlock()
+	this.connections[key] = value
+}
+
+func (this *MySqlPool) CloseAll() {
+	this.Lock()
+	defer this.Unlock()
+	for _, c := range this.connections {
+		if c != nil {
+			c.Close()
+		}
+	}
+}

+ 34 - 0
engine/sqlw/log.go

@@ -0,0 +1,34 @@
+package sqlw
+
+import (
+	"fmt"
+	"os"
+	"regexp"
+	"strings"
+	"time"
+
+	"golang-fave/consts"
+)
+
+func log(query string, s time.Time, e error, transaction bool) {
+	msg := query
+	if reg, err := regexp.Compile("[\\s\\t]+"); err == nil {
+		msg = strings.Trim(reg.ReplaceAllString(msg, " "), " ")
+	}
+	if reg, err := regexp.Compile("[\\s\\t]+;$"); err == nil {
+		msg = reg.ReplaceAllString(msg, ";")
+	}
+	eStr := " (nil)"
+	if e != nil {
+		eStr = " (" + e.Error() + ") "
+	}
+	if consts.IS_WIN {
+		fmt.Fprintln(os.Stdout, "[SQL] "+msg+eStr+fmt.Sprintf(" %.3f ms", time.Now().Sub(s).Seconds()))
+	} else {
+		color := "0;33"
+		if transaction {
+			color = "1;33"
+		}
+		fmt.Fprintln(os.Stdout, "\033["+color+"m[SQL] "+msg+eStr+fmt.Sprintf(" %.3f ms", time.Now().Sub(s).Seconds())+"\033[0m")
+	}
+}

+ 125 - 0
engine/sqlw/sqlw.go

@@ -0,0 +1,125 @@
+package sqlw
+
+import (
+	"database/sql"
+	_ "github.com/go-sql-driver/mysql"
+
+	"errors"
+	"time"
+
+	"golang-fave/consts"
+)
+
+type Rows = sql.Rows
+
+type DB struct {
+	db *sql.DB
+}
+
+var ErrNoRows = sql.ErrNoRows
+
+func Open(driverName, dataSourceName string) (*DB, error) {
+	db, err := sql.Open(driverName, dataSourceName)
+	if err != nil {
+		if consts.ParamDebug {
+			log("[CM] OPEN", time.Now(), err, true)
+		}
+		return nil, err
+	}
+	if consts.ParamDebug {
+		log("[CM] OPEN", time.Now(), err, true)
+	}
+	return &DB{db: db}, err
+}
+
+func (this *DB) Close() error {
+	if consts.ParamDebug {
+		err := this.db.Close()
+		log("[CM] CLOSE", time.Now(), err, true)
+		return err
+	}
+	return this.db.Close()
+}
+
+func (this *DB) Ping() error {
+	if consts.ParamDebug {
+		err := this.db.Ping()
+		log("[CM] PING", time.Now(), err, true)
+		return err
+	}
+	return this.db.Ping()
+}
+
+func (this *DB) SetConnMaxLifetime(d time.Duration) {
+	this.db.SetConnMaxLifetime(d)
+}
+
+func (this *DB) SetMaxIdleConns(n int) {
+	this.db.SetMaxIdleConns(n)
+}
+
+func (this *DB) SetMaxOpenConns(n int) {
+	this.db.SetMaxOpenConns(n)
+}
+
+func (this *DB) QueryRow(query string, args ...interface{}) *sql.Row {
+	if consts.ParamDebug {
+		s := time.Now()
+		r := this.db.QueryRow(query, args...)
+		log(query, s, nil, false)
+		return r
+	}
+	return this.db.QueryRow(query, args...)
+}
+
+func (this *DB) Begin() (*Tx, error) {
+	tx, err := this.db.Begin()
+	if err != nil {
+		if consts.ParamDebug {
+			log("[TX] TRANSACTION START", time.Now(), err, true)
+		}
+		return nil, err
+	}
+	if consts.ParamDebug {
+		s := time.Now()
+		log("[TX] TRANSACTION START", s, err, true)
+		return &Tx{tx, s}, err
+	}
+	return &Tx{tx, time.Now()}, err
+}
+
+func (this *DB) Query(query string, args ...interface{}) (*sql.Rows, error) {
+	if consts.ParamDebug {
+		s := time.Now()
+		r, e := this.db.Query(query, args...)
+		log(query, s, e, false)
+		return r, e
+	}
+	return this.db.Query(query, args...)
+}
+
+func (this *DB) Exec(query string, args ...interface{}) (sql.Result, error) {
+	if consts.ParamDebug {
+		s := time.Now()
+		r, e := this.db.Exec(query, args...)
+		log(query, s, e, false)
+		return r, e
+	}
+	return this.db.Exec(query, args...)
+}
+
+func (this *DB) Transaction(queries func(tx *Tx) error) error {
+	if queries == nil {
+		return errors.New("queries is not set for transaction")
+	}
+	tx, err := this.Begin()
+	if err != nil {
+		return err
+	}
+	err = queries(tx)
+	if err != nil {
+		tx.Rollback()
+		return err
+	}
+	return tx.Commit()
+}

+ 63 - 0
engine/sqlw/txw.go

@@ -0,0 +1,63 @@
+package sqlw
+
+import (
+	"database/sql"
+	_ "github.com/go-sql-driver/mysql"
+
+	"time"
+
+	"golang-fave/consts"
+)
+
+type Tx struct {
+	tx *sql.Tx
+	s  time.Time
+}
+
+func (this *Tx) Rollback() error {
+	if consts.ParamDebug {
+		err := this.tx.Rollback()
+		log("[TX] TRANSACTION END (Rollback)", this.s, nil, true)
+		return err
+	}
+	return this.tx.Rollback()
+}
+
+func (this *Tx) Commit() error {
+	if consts.ParamDebug {
+		err := this.tx.Commit()
+		log("[TX] TRANSACTION END (Commit)", this.s, err, true)
+		return err
+	}
+	return this.tx.Commit()
+}
+
+func (this *Tx) Exec(query string, args ...interface{}) (sql.Result, error) {
+	if consts.ParamDebug {
+		s := time.Now()
+		r, e := this.tx.Exec(query, args...)
+		log("[TX] "+query, s, e, true)
+		return r, e
+	}
+	return this.tx.Exec(query, args...)
+}
+
+func (this *Tx) QueryRow(query string, args ...interface{}) *sql.Row {
+	if consts.ParamDebug {
+		s := time.Now()
+		r := this.tx.QueryRow(query, args...)
+		log("[TX] "+query, s, nil, true)
+		return r
+	}
+	return this.tx.QueryRow(query, args...)
+}
+
+func (this *Tx) Query(query string, args ...interface{}) (*sql.Rows, error) {
+	if consts.ParamDebug {
+		s := time.Now()
+		r, e := this.tx.Query(query, args...)
+		log("[TX] "+query, s, e, true)
+		return r, e
+	}
+	return this.tx.Query(query, args...)
+}

+ 52 - 0
engine/wrapper/config.go

@@ -0,0 +1,52 @@
+package wrapper
+
+import (
+	"encoding/json"
+	"os"
+)
+
+type Config struct {
+	Blog struct {
+		Pagination struct {
+			Index    int
+			Category int
+		}
+	}
+}
+
+func configNew() *Config {
+	c := &Config{}
+	c.configDefault()
+	return c
+}
+
+func (this *Config) configDefault() {
+	this.Blog.Pagination.Index = 5
+	this.Blog.Pagination.Category = 5
+}
+
+func (this *Config) configRead(file string) error {
+	f, err := os.Open(file)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	dec := json.NewDecoder(f)
+	return dec.Decode(this)
+}
+
+func (this *Config) configWrite(file string) error {
+	r, err := json.Marshal(this)
+	if err != nil {
+		return err
+	}
+	f, err := os.Create(file)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	_, err = f.WriteString(string(r))
+	return err
+}

+ 99 - 20
engine/wrapper/wrapper.go

@@ -2,21 +2,27 @@ package wrapper
 
 import (
 	"bytes"
-	"database/sql"
 	"errors"
 	"fmt"
 	"html/template"
 	"net/http"
 	"os"
+	"strconv"
+	"time"
 
 	"golang-fave/consts"
+	"golang-fave/engine/mysqlpool"
+	"golang-fave/engine/sqlw"
 	"golang-fave/logger"
 	"golang-fave/utils"
 
-	_ "github.com/go-sql-driver/mysql"
 	"github.com/vladimirok5959/golang-server-sessions/session"
 )
 
+type Tx = sqlw.Tx
+
+var ErrNoRows = sqlw.ErrNoRows
+
 type Wrapper struct {
 	l *logger.Logger
 	W http.ResponseWriter
@@ -38,12 +44,20 @@ type Wrapper struct {
 	UrlArgs         []string
 	CurrModule      string
 	CurrSubModule   string
+	MSPool          *mysqlpool.MySqlPool
+	Config          *Config
 
-	DB   *sql.DB
+	DB   *sqlw.DB
 	User *utils.MySql_user
 }
 
-func New(l *logger.Logger, w http.ResponseWriter, r *http.Request, s *session.Session, host, port, chost, dirConfig, dirHtdocs, dirLogs, dirTemplate, dirTmp string) *Wrapper {
+func New(l *logger.Logger, w http.ResponseWriter, r *http.Request, s *session.Session, host, port, chost, dirConfig, dirHtdocs, dirLogs, dirTemplate, dirTmp string, mp *mysqlpool.MySqlPool) *Wrapper {
+
+	conf := configNew()
+	if err := conf.configRead(dirConfig + string(os.PathSeparator) + "config.json"); err != nil {
+		l.Log("Host config file: %s", r, true, err.Error())
+	}
+
 	return &Wrapper{
 		l:             l,
 		W:             w,
@@ -60,21 +74,20 @@ func New(l *logger.Logger, w http.ResponseWriter, r *http.Request, s *session.Se
 		UrlArgs:       []string{},
 		CurrModule:    "",
 		CurrSubModule: "",
+		MSPool:        mp,
+		Config:        conf,
 	}
 }
 
-func (this *Wrapper) LogAccess(msg string) {
-	this.l.Log(msg, this.R, false)
+func (this *Wrapper) LogAccess(msg string, vars ...interface{}) {
+	this.l.Log(msg, this.R, false, vars...)
 }
 
-func (this *Wrapper) LogError(msg string) {
-	this.l.Log(msg, this.R, true)
+func (this *Wrapper) LogError(msg string, vars ...interface{}) {
+	this.l.Log(msg, this.R, true, vars...)
 }
 
-func (this *Wrapper) UseDatabase() error {
-	if this.DB != nil {
-		return errors.New("already connected to database")
-	}
+func (this *Wrapper) dbReconnect() error {
 	if !utils.IsMySqlConfigExists(this.DConfig + string(os.PathSeparator) + "mysql.json") {
 		return errors.New("can't read database configuration file")
 	}
@@ -82,15 +95,38 @@ func (this *Wrapper) UseDatabase() error {
 	if err != nil {
 		return err
 	}
-	this.DB, err = sql.Open("mysql", mc.User+":"+mc.Password+"@tcp("+mc.Host+":"+mc.Port+")/"+mc.Name)
+	this.DB, err = sqlw.Open("mysql", mc.User+":"+mc.Password+"@tcp("+mc.Host+":"+mc.Port+")/"+mc.Name)
 	if err != nil {
 		return err
 	}
-	err = this.DB.Ping()
-	if err != nil {
+	this.MSPool.Set(this.CurrHost, this.DB)
+	return nil
+}
+
+func (this *Wrapper) UseDatabase() error {
+	this.DB = this.MSPool.Get(this.CurrHost)
+	if this.DB == nil {
+		if err := this.dbReconnect(); err != nil {
+			return err
+		}
+	}
+
+	if err := this.DB.Ping(); err != nil {
 		this.DB.Close()
-		return err
+		if err := this.dbReconnect(); err != nil {
+			return err
+		}
+		if err := this.DB.Ping(); err != nil {
+			this.DB.Close()
+			return err
+		}
 	}
+
+	// Max 30 minutes and max 2 connection per host
+	this.DB.SetConnMaxLifetime(time.Minute * 30)
+	this.DB.SetMaxIdleConns(2)
+	this.DB.SetMaxOpenConns(2)
+
 	return nil
 }
 
@@ -164,8 +200,29 @@ func (this *Wrapper) RenderToString(tcont []byte, data interface{}) string {
 	return tpl.String()
 }
 
-func (this *Wrapper) RenderFrontEnd(tname string, data interface{}) {
-	tmpl, err := template.ParseFiles(
+func (this *Wrapper) RenderFrontEnd(tname string, data interface{}, status int) {
+	tmplFuncs := template.FuncMap{
+		"plus": func(a, b int) int {
+			return a + b
+		},
+		"minus": func(a, b int) int {
+			return a - b
+		},
+		"multiply": func(a, b int) int {
+			return a * b
+		},
+		"divide": func(a, b int) int {
+			return a / b
+		},
+		"repeat": func(a string, n int) template.HTML {
+			out := ""
+			for i := 1; i <= n; i++ {
+				out += a
+			}
+			return template.HTML(out)
+		},
+	}
+	tmpl, err := template.New(tname+".html").Funcs(tmplFuncs).ParseFiles(
 		this.DTemplate+string(os.PathSeparator)+tname+".html",
 		this.DTemplate+string(os.PathSeparator)+"header.html",
 		this.DTemplate+string(os.PathSeparator)+"sidebar-left.html",
@@ -176,8 +233,6 @@ func (this *Wrapper) RenderFrontEnd(tname string, data interface{}) {
 		utils.SystemErrorPageTemplate(this.W, err)
 		return
 	}
-	this.W.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
-	this.W.Header().Set("Content-Type", "text/html; charset=utf-8")
 	var tpl bytes.Buffer
 	err = tmpl.Execute(&tpl, consts.TmplData{
 		System: utils.GetTmplSystemData(),
@@ -187,6 +242,9 @@ func (this *Wrapper) RenderFrontEnd(tname string, data interface{}) {
 		utils.SystemErrorPageTemplate(this.W, err)
 		return
 	}
+	this.W.WriteHeader(status)
+	this.W.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+	this.W.Header().Set("Content-Type", "text/html; charset=utf-8")
 	this.W.Write(tpl.Bytes())
 }
 
@@ -209,3 +267,24 @@ func (this *Wrapper) RenderBackEnd(tcont []byte, data interface{}) {
 	}
 	this.W.Write(tpl.Bytes())
 }
+
+func (this *Wrapper) GetCurrentPage(max int) int {
+	curr := 1
+	page := this.R.URL.Query().Get("p")
+	if page != "" {
+		if i, err := strconv.Atoi(page); err == nil {
+			if i < 1 {
+				curr = 1
+			} else if i > max {
+				curr = max
+			} else {
+				curr = i
+			}
+		}
+	}
+	return curr
+}
+
+func (this *Wrapper) ConfigSave() error {
+	return this.Config.configWrite(this.DConfig + string(os.PathSeparator) + "config.json")
+}

+ 2 - 2
go.mod

@@ -2,9 +2,9 @@ module golang-fave
 
 require (
 	github.com/go-sql-driver/mysql v1.4.1
-	github.com/vladimirok5959/golang-server-bootstrap v1.0.4
+	github.com/vladimirok5959/golang-server-bootstrap v1.0.5
 	github.com/vladimirok5959/golang-server-resources v1.0.2
-	github.com/vladimirok5959/golang-server-sessions v1.0.1
+	github.com/vladimirok5959/golang-server-sessions v1.0.4
 	github.com/vladimirok5959/golang-server-static v1.0.0
 	google.golang.org/appengine v1.4.0 // indirect
 )

+ 8 - 6
go.sum

@@ -1,16 +1,18 @@
 github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
 github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/vladimirok5959/golang-server-bootstrap v1.0.2 h1:UiOKkiNGcEddnx9O0cuLM1euSWgKimQCFIMD0+W8njw=
-github.com/vladimirok5959/golang-server-bootstrap v1.0.2/go.mod h1:R5PGBuqlupYd0evIXoi81plWH/HpNQO2V/jHxZzg2y0=
-github.com/vladimirok5959/golang-server-bootstrap v1.0.3 h1:fNLzc3qZrB1SiX7PtjPU+mel/TJFuy6nI2y/9Pup6Uw=
-github.com/vladimirok5959/golang-server-bootstrap v1.0.3/go.mod h1:R5PGBuqlupYd0evIXoi81plWH/HpNQO2V/jHxZzg2y0=
 github.com/vladimirok5959/golang-server-bootstrap v1.0.4 h1:+Su6EAc5ZXntjEOkYKVZUJ8pRVrh7GIvqrkmeMS48Vo=
 github.com/vladimirok5959/golang-server-bootstrap v1.0.4/go.mod h1:R5PGBuqlupYd0evIXoi81plWH/HpNQO2V/jHxZzg2y0=
+github.com/vladimirok5959/golang-server-bootstrap v1.0.5 h1:+7MfVJCh/c5LcDV9xCWeXSE3KVDSP5EiQPddkccznV8=
+github.com/vladimirok5959/golang-server-bootstrap v1.0.5/go.mod h1:R5PGBuqlupYd0evIXoi81plWH/HpNQO2V/jHxZzg2y0=
 github.com/vladimirok5959/golang-server-resources v1.0.2 h1:XwxFXyaOtfDGRmYp8P9q4P4gx4YK8NiYacpHe9V8Lck=
 github.com/vladimirok5959/golang-server-resources v1.0.2/go.mod h1:tsf2oAEf3E3ukiQSCO7dstl0IXbEXec68UUIiMWysBc=
-github.com/vladimirok5959/golang-server-sessions v1.0.1 h1:cQsLk8hz7pkIV7/XectfejDF2j+61FE+/s4xErxBNJw=
-github.com/vladimirok5959/golang-server-sessions v1.0.1/go.mod h1:W6eCEIltyTs5IUvN1DfzLk59z+M1031kr8bMFUiq8vU=
+github.com/vladimirok5959/golang-server-sessions v1.0.2 h1:VG14aTnH8+vb81quhekkrJ1vTi57EU4USDX1UzDYIzw=
+github.com/vladimirok5959/golang-server-sessions v1.0.2/go.mod h1:W6eCEIltyTs5IUvN1DfzLk59z+M1031kr8bMFUiq8vU=
+github.com/vladimirok5959/golang-server-sessions v1.0.3 h1:P2HKDP1zY6H6XYrB8LzsW7boFLdWT+DtAIZE8kmtq9k=
+github.com/vladimirok5959/golang-server-sessions v1.0.3/go.mod h1:W6eCEIltyTs5IUvN1DfzLk59z+M1031kr8bMFUiq8vU=
+github.com/vladimirok5959/golang-server-sessions v1.0.4 h1:LsqgTDAjeSnPblzpof4GBTNV04Kv8T5FI1UvIX7WUTQ=
+github.com/vladimirok5959/golang-server-sessions v1.0.4/go.mod h1:W6eCEIltyTs5IUvN1DfzLk59z+M1031kr8bMFUiq8vU=
 github.com/vladimirok5959/golang-server-static v1.0.0 h1:jmVNUCVF44+Am0euUMYFN4L3gdJYwm7aS8LFUmUAHJk=
 github.com/vladimirok5959/golang-server-static v1.0.0/go.mod h1:dxZsjCCpT65Z9dLP6p7RmR2rbgtYj6E4FSSSUqkNrsw=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=

+ 50 - 0
hosts/localhost/template/blog-category.html

@@ -0,0 +1,50 @@
+{{template "header.html" .}}
+<div class="card mb-4">
+	{{if $.Data.Blog.HavePosts}}
+		{{range $.Data.Blog.Posts}}
+			<div class="post">
+				<div class="card-body">
+					<h2 class="card-title">
+						<a href="{{.Permalink}}">
+							{{.Name}}
+						</a>
+					</h2>
+					<div class="post-content">
+						{{.Briefly}}
+					</div>
+					<div class="post-date">
+						<small>Published on {{.DateTimeFormat "02/01/2006, 15:04:05"}}</small>
+					</div>
+				</div>
+			</div>
+		{{end}}
+	{{else}}
+		<div class="card-body">
+			Sorry, no posts matched your criteria
+		</div>
+	{{end}}
+</div>
+{{if $.Data.Blog.HavePosts}}
+	{{if gt $.Data.Blog.PostsMaxPage 1 }}
+		<nav>
+			<ul class="pagination mb-4">
+				{{if $.Data.Blog.PaginationPrev}}
+					<li class="page-item{{if $.Data.Blog.PaginationPrev.Current}} disabled{{end}}">
+						<a class="page-link" href="{{$.Data.Blog.PaginationPrev.Link}}">Previous</a>
+					</li>
+				{{end}}
+				{{range $.Data.Blog.Pagination}}
+					<li class="page-item{{if .Current}} active{{end}}">
+						<a class="page-link" href="{{.Link}}">{{.Num}}</a>
+					</li>
+				{{end}}
+				{{if $.Data.Blog.PaginationNext}}
+					<li class="page-item{{if $.Data.Blog.PaginationNext.Current}} disabled{{end}}">
+						<a class="page-link" href="{{$.Data.Blog.PaginationNext.Link}}">Next</a>
+					</li>
+				{{end}}
+			</ul>
+		</nav>
+	{{end}}
+{{end}}
+{{template "footer.html" .}}

+ 13 - 0
hosts/localhost/template/blog-post.html

@@ -0,0 +1,13 @@
+{{template "header.html" .}}
+<div class="card mb-4">
+	<div class="card-body">
+		<h2 class="card-title">{{$.Data.Blog.Post.Name}}</h2>
+		<div class="page-content">
+			{{$.Data.Blog.Post.Content}}
+		</div>
+	</div>
+	<div class="card-footer text-muted">
+		Published on {{$.Data.Blog.Post.DateTimeFormat "02/01/2006, 15:04:05"}}
+	</div>
+</div>
+{{template "footer.html" .}}

+ 54 - 0
hosts/localhost/template/blog.html

@@ -0,0 +1,54 @@
+{{template "header.html" .}}
+<div class="card mb-4">
+	{{if $.Data.Blog.HavePosts}}
+		{{range $.Data.Blog.Posts}}
+			<div class="post">
+				<div class="card-body">
+					<h2 class="card-title">
+						<a href="{{.Permalink}}">
+							{{.Name}}
+						</a>
+					</h2>
+					<div class="post-content">
+						{{.Briefly}}
+					</div>
+					<div class="post-date">
+						<small>Published on {{.DateTimeFormat "02/01/2006, 15:04:05"}}</small>
+					</div>
+				</div>
+			</div>
+		{{end}}
+	{{else}}
+		<div class="card-body">
+			Sorry, no posts matched your criteria
+		</div>
+	{{end}}
+</div>
+{{if $.Data.Blog.HavePosts}}
+	{{if gt $.Data.Blog.PostsMaxPage 1 }}
+		<nav>
+			<ul class="pagination mb-4">
+				{{if $.Data.Blog.PaginationPrev}}
+					<li class="page-item{{if $.Data.Blog.PaginationPrev.Current}} disabled{{end}}">
+						<a class="page-link" href="{{$.Data.Blog.PaginationPrev.Link}}">Previous</a>
+					</li>
+				{{end}}
+				{{range $.Data.Blog.Pagination}}
+					{{if .Dots}}
+						<li class="page-item disabled"><a class="page-link" href="">...</a></li>
+					{{else}}
+						<li class="page-item{{if .Current}} active{{end}}">
+							<a class="page-link" href="{{.Link}}">{{.Num}}</a>
+						</li>
+					{{end}}
+				{{end}}
+				{{if $.Data.Blog.PaginationNext}}
+					<li class="page-item{{if $.Data.Blog.PaginationNext.Current}} disabled{{end}}">
+						<a class="page-link" href="{{$.Data.Blog.PaginationNext.Link}}">Next</a>
+					</li>
+				{{end}}
+			</ul>
+		</nav>
+	{{end}}
+{{end}}
+{{template "footer.html" .}}

+ 7 - 1
hosts/localhost/template/footer.html

@@ -8,7 +8,13 @@
 		</div>
 		<footer class="bg-light py-4">
 			<div class="container">
-				<p class="m-0 text-center text-black">Copyright © Your Website 2019</p>
+				<p class="m-0 text-center text-black">
+					Copyright © Your Website {{if eq ($.Data.DateTimeFormat "2006") "2019"}}
+						{{$.Data.DateTimeFormat "2006"}}
+					{{else}}
+						2019-{{$.Data.DateTimeFormat "2006"}}
+					{{end}}
+				</p>
 			</div>
 		</footer>
 		<!-- Optional JavaScript -->

+ 41 - 12
hosts/localhost/template/header.html

@@ -9,9 +9,25 @@
 		<!-- Bootstrap CSS -->
 		<link rel="stylesheet" href="{{$.System.PathCssBootstrap}}">
 
-		<title>{{if not $.Data.Is404}}{{$.Data.MetaTitle}}{{else}}Error 404{{end}}</title>
-		<meta name="keywords" content="{{$.Data.MetaKeywords}}" />
-		<meta name="description" content="{{$.Data.MetaDescription}}" />
+		<title>
+			{{if not (eq $.Data.Module "404")}}
+				{{if eq $.Data.Module "index"}}
+					{{$.Data.Page.Name}}
+				{{else if or (eq $.Data.Module "blog") (eq $.Data.Module "blog-post") (eq $.Data.Module "blog-category")}}
+					{{if eq $.Data.Module "blog-category"}}
+						Posts of category "{{$.Data.Blog.Category.Name}}" | Blog
+					{{else if eq $.Data.Module "blog-post"}}
+						{{$.Data.Blog.Post.Name}} | Blog
+					{{else}}
+						Latest posts | Blog
+					{{end}}
+				{{end}}
+			{{else}}
+				Error 404
+			{{end}}
+		</title>
+		<meta name="keywords" content="{{$.Data.Page.MetaKeywords}}" />
+		<meta name="description" content="{{$.Data.Page.MetaDescription}}" />
 		<link rel="shortcut icon" href="{{$.System.PathIcoFav}}" type="image/x-icon" />
 
 		<!-- Template CSS file from template folder -->
@@ -30,17 +46,20 @@
 					</button>
 					<div class="collapse navbar-collapse" id="navbarResponsive">
 						<ul class="navbar-nav ml-auto">
-							<li class="nav-item{{if eq $.Data.Alias "/"}} active{{end}}">
+							<li class="nav-item{{if eq $.Data.Page.Alias "/"}} active{{end}}">
 								<a class="nav-link" href="/">Home</a>
 							</li>
 							<li class="nav-item">
-								<a class="nav-link{{if eq $.Data.Alias "/another/"}} active{{end}}" href="/another/">Another</a>
+								<a class="nav-link{{if eq $.Data.Page.Alias "/another/"}} active{{end}}" href="/another/">Another</a>
 							</li>
 							<li class="nav-item">
-								<a class="nav-link{{if eq $.Data.Alias "/about/"}} active{{end}}" href="/about/">About</a>
+								<a class="nav-link{{if eq $.Data.Page.Alias "/about/"}} active{{end}}" href="/about/">About</a>
 							</li>
 							<li class="nav-item">
-								<a class="nav-link{{if $.Data.Is404}} active{{end}}" href="/not-existent-page/">404</a>
+								<a class="nav-link{{if or (eq $.Data.Module "blog") (eq $.Data.Module "blog-post") (eq $.Data.Module "blog-category")}} active{{end}}" href="/blog/">Blog</a>
+							</li>
+							<li class="nav-item">
+								<a class="nav-link{{if eq $.Data.Module "404"}} active{{end}}" href="/not-existent-page/">404</a>
 							</li>
 						</ul>
 					</div>
@@ -50,11 +69,21 @@
 				<div class="bg-fave">
 					<div class="container">
 						<h1 class="text-left text-white m-0 p-0 py-5">
-							{{if not $.Data.Is404}}
-								{{if eq $.Data.Alias "/"}}
-									Welcome to home page
-								{{else}}
-									Welcome to some another page
+							{{if not (eq $.Data.Module "404")}}
+								{{if eq $.Data.Module "index"}}
+									{{if eq $.Data.Page.Alias "/"}}
+										Welcome to home page
+									{{else}}
+										Welcome to some another page
+									{{end}}
+								{{else if or (eq $.Data.Module "blog") (eq $.Data.Module "blog-post") (eq $.Data.Module "blog-category")}}
+									{{if eq $.Data.Module "blog-category"}}
+										Blog category
+									{{else if eq $.Data.Module "blog-post"}}
+										Blog post
+									{{else}}
+										Blog
+									{{end}}
 								{{end}}
 							{{else}}
 								Oops, page is not found...

+ 2 - 2
hosts/localhost/template/index.html

@@ -1,9 +1,9 @@
 {{template "header.html" .}}
 <div class="card mb-4">
 	<div class="card-body">
-		<h2 class="card-title">{{$.Data.Name}}</h2>
+		<h2 class="card-title">{{$.Data.Page.Name}}</h2>
 		<div class="page-content">
-			{{$.Data.Content}}
+			{{$.Data.Page.Content}}
 		</div>
 	</div>
 	<div class="card-footer text-muted">

+ 3 - 3
hosts/localhost/template/page.html

@@ -1,13 +1,13 @@
 {{template "header.html" .}}
 <div class="card mb-4">
 	<div class="card-body">
-		<h2 class="card-title">{{$.Data.Name}}</h2>
+		<h2 class="card-title">{{$.Data.Page.Name}}</h2>
 		<div class="page-content">
-			{{$.Data.Content}}
+			{{$.Data.Page.Content}}
 		</div>
 	</div>
 	<div class="card-footer text-muted">
-		Published on {{$.Data.DateTimeFormat "02/01/2006, 15:04:05"}}
+		Published on {{$.Data.Page.DateTimeFormat "02/01/2006, 15:04:05"}}
 	</div>
 </div>
 {{template "footer.html" .}}

+ 12 - 0
hosts/localhost/template/sidebar-right.html

@@ -1,3 +1,15 @@
+<div class="card mb-4">
+	<h5 class="card-header">Categories</h5>
+	<div class="card-body">
+		<ul class="m-0 p-0 pl-4">
+			{{range $.Data.Blog.Categories 0}}
+				<li class="{{if and $.Data.Blog.Category (eq $.Data.Blog.Category.Id .Id)}}active{{end}}">
+					<a href="{{.Permalink}}">{{.Name}}</a>
+				</li>
+			{{end}}
+		</ul>
+	</div>
+</div>
 <div class="card mb-4">
 	<h5 class="card-header">Useful links</h5>
 	<div class="card-body">

+ 7 - 10
logger/handler.go

@@ -29,16 +29,13 @@ func (this handler) log(w *writer, r *http.Request) {
 		fmt.Sprintf("%.3f ms", time.Now().Sub(w.s).Seconds()),
 	}, " "))
 
-	// Do not wait
-	go func() {
-		select {
-		case this.c <- logMsg{r.Host, msg, w.status >= 400}:
-			return
-		case <-time.After(1 * time.Second):
-			fmt.Println("Logger error, log channel is overflowed (2)")
-			return
-		}
-	}()
+	select {
+	case this.c <- logMsg{r.Host, msg, w.status >= 400}:
+		return
+	case <-time.After(1 * time.Second):
+		fmt.Println("Logger error, log channel is overflowed (2)")
+		return
+	}
 }
 
 func (this handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {

+ 12 - 11
logger/logger.go

@@ -125,22 +125,23 @@ func New() *Logger {
 	return &lg
 }
 
-func (this *Logger) Log(msg string, r *http.Request, isError bool) {
+func (this *Logger) Log(msg string, r *http.Request, isError bool, vars ...interface{}) {
 	var host string = ""
 	if r != nil {
 		host = r.Host
 	}
 
-	// Do not wait
-	go func() {
-		select {
-		case this.cdata <- logMsg{host, msg, isError}:
-			return
-		case <-time.After(1 * time.Second):
-			fmt.Println("Logger error, log channel is overflowed (1)")
-			return
-		}
-	}()
+	if len(vars) > 0 {
+		msg = fmt.Sprintf(msg, vars...)
+	}
+
+	select {
+	case this.cdata <- logMsg{host, msg, isError}:
+		return
+	case <-time.After(1 * time.Second):
+		fmt.Println("Logger error, log channel is overflowed (1)")
+		return
+	}
 }
 
 func (this *Logger) SetWwwDir(dir string) {

+ 20 - 5
main.go

@@ -10,6 +10,7 @@ import (
 	"golang-fave/assets"
 	"golang-fave/consts"
 	"golang-fave/engine"
+	"golang-fave/engine/mysqlpool"
 	"golang-fave/logger"
 	"golang-fave/modules"
 	"golang-fave/utils"
@@ -86,10 +87,13 @@ func main() {
 	// Init modules
 	mods := modules.New()
 
+	// MySQL connections pool
+	mpool := mysqlpool.New()
+
 	// Init and start web server
-	bootstrap.Start(lg.Handler, fmt.Sprintf("%s:%d", consts.ParamHost, consts.ParamPort), 9, consts.AssetsPath, func(w http.ResponseWriter, r *http.Request) {
+	bootstrap.Start(lg.Handler, fmt.Sprintf("%s:%d", consts.ParamHost, consts.ParamPort), 9, consts.AssetsPath, func(w http.ResponseWriter, r *http.Request, o interface{}) {
 		w.Header().Set("Server", "fave.pro/"+consts.ServerVersion)
-	}, func(w http.ResponseWriter, r *http.Request) {
+	}, func(w http.ResponseWriter, r *http.Request, o interface{}) {
 		// Schema
 		r.URL.Scheme = "http"
 
@@ -156,16 +160,27 @@ func main() {
 		sess := session.New(w, r, vhost_dir_tmp)
 		defer sess.Close()
 
+		// Convert
+		var mp *mysqlpool.MySqlPool
+		if mpool, ok := o.(*mysqlpool.MySqlPool); ok {
+			mp = mpool
+		}
+
 		// Logic
-		if engine.Response(lg, mods, w, r, sess, host, port, curr_host, vhost_dir_config, vhost_dir_htdocs, vhost_dir_logs, vhost_dir_template, vhost_dir_tmp) {
-			return
+		if mp != nil {
+			if engine.Response(mp, lg, mods, w, r, sess, host, port, curr_host, vhost_dir_config, vhost_dir_htdocs, vhost_dir_logs, vhost_dir_template, vhost_dir_tmp) {
+				return
+			}
 		}
 
 		// Error 404
 		utils.SystemErrorPage404(w)
 	}, func(s *http.Server) {
 		s.SetKeepAlivesEnabled(consts.ParamKeepAlive)
-	})
+	}, mpool)
+
+	// Close MySQL
+	mpool.CloseAll()
 }
 
 func ServeTemplateFile(w http.ResponseWriter, r *http.Request, file string, path string, dir string) bool {

+ 598 - 0
modules/module_blog.go

@@ -0,0 +1,598 @@
+package modules
+
+import (
+	"html"
+	"net/http"
+	"strings"
+
+	"golang-fave/assets"
+	"golang-fave/consts"
+	"golang-fave/engine/builder"
+	"golang-fave/engine/fetdata"
+	"golang-fave/engine/sqlw"
+	"golang-fave/engine/wrapper"
+	"golang-fave/utils"
+)
+
+func (this *Modules) RegisterModule_Blog() *Module {
+	return this.newModule(MInfo{
+		WantDB: true,
+		Mount:  "blog",
+		Name:   "Blog",
+		Order:  1,
+		System: false,
+		Icon:   assets.SysSvgIconList,
+		Sub: &[]MISub{
+			{Mount: "default", Name: "List of posts", Show: true, Icon: assets.SysSvgIconList},
+			{Mount: "add", Name: "Add new post", Show: true, Icon: assets.SysSvgIconPlus},
+			{Mount: "modify", Name: "Modify post", Show: false},
+			{Sep: true, Show: true},
+			{Mount: "categories", Name: "List of categories", Show: true, Icon: assets.SysSvgIconList},
+			{Mount: "categories-add", Name: "Add new category", Show: true, Icon: assets.SysSvgIconPlus},
+			{Mount: "categories-modify", Name: "Modify category", Show: false},
+		},
+	}, func(wrap *wrapper.Wrapper) {
+		if len(wrap.UrlArgs) == 3 && wrap.UrlArgs[0] == "blog" && wrap.UrlArgs[1] == "category" && wrap.UrlArgs[2] != "" {
+			// Blog category
+			row := &utils.MySql_blog_category{}
+			err := wrap.DB.QueryRow(`
+				SELECT
+					id,
+					user,
+					name,
+					alias,
+					lft,
+					rgt
+				FROM
+					blog_cats
+				WHERE
+					alias = ? AND
+					id > 1
+				LIMIT 1;`,
+				wrap.UrlArgs[2],
+			).Scan(
+				&row.A_id,
+				&row.A_user,
+				&row.A_name,
+				&row.A_alias,
+				&row.A_lft,
+				&row.A_rgt,
+			)
+
+			if err != nil && err != wrapper.ErrNoRows {
+				// System error 500
+				utils.SystemErrorPageEngine(wrap.W, err)
+				return
+			} else if err == wrapper.ErrNoRows {
+				// User error 404 page
+				wrap.RenderFrontEnd("404", fetdata.New(wrap, nil, true), http.StatusNotFound)
+				return
+			}
+
+			// Fix url
+			if wrap.R.URL.Path[len(wrap.R.URL.Path)-1] != '/' {
+				http.Redirect(wrap.W, wrap.R, wrap.R.URL.Path+"/"+utils.ExtractGetParams(wrap.R.RequestURI), 301)
+				return
+			}
+
+			// Render template
+			wrap.RenderFrontEnd("blog-category", fetdata.New(wrap, row, false), http.StatusOK)
+			return
+		} else if len(wrap.UrlArgs) == 2 && wrap.UrlArgs[0] == "blog" && wrap.UrlArgs[1] != "" {
+			// Blog post
+			row := &utils.MySql_blog_post{}
+			err := wrap.DB.QueryRow(`
+				SELECT
+					id,
+					user,
+					name,
+					alias,
+					content,
+					UNIX_TIMESTAMP(datetime) as datetime,
+					active
+				FROM
+					blog_posts
+				WHERE
+					active = 1 and
+					alias = ?
+				LIMIT 1;`,
+				wrap.UrlArgs[1],
+			).Scan(
+				&row.A_id,
+				&row.A_user,
+				&row.A_name,
+				&row.A_alias,
+				&row.A_content,
+				&row.A_datetime,
+				&row.A_active,
+			)
+
+			if err != nil && err != wrapper.ErrNoRows {
+				// System error 500
+				utils.SystemErrorPageEngine(wrap.W, err)
+				return
+			} else if err == wrapper.ErrNoRows {
+				// User error 404 page
+				wrap.RenderFrontEnd("404", fetdata.New(wrap, nil, true), http.StatusNotFound)
+				return
+			}
+
+			// Fix url
+			if wrap.R.URL.Path[len(wrap.R.URL.Path)-1] != '/' {
+				http.Redirect(wrap.W, wrap.R, wrap.R.URL.Path+"/"+utils.ExtractGetParams(wrap.R.RequestURI), 301)
+				return
+			}
+
+			// Render template
+			wrap.RenderFrontEnd("blog-post", fetdata.New(wrap, row, false), http.StatusOK)
+			return
+		} else if len(wrap.UrlArgs) == 1 && wrap.UrlArgs[0] == "blog" {
+			// Blog
+
+			// Fix url
+			if wrap.R.URL.Path[len(wrap.R.URL.Path)-1] != '/' {
+				http.Redirect(wrap.W, wrap.R, wrap.R.URL.Path+"/"+utils.ExtractGetParams(wrap.R.RequestURI), 301)
+				return
+			}
+
+			// Render template
+			wrap.RenderFrontEnd("blog", fetdata.New(wrap, nil, false), http.StatusOK)
+			return
+		}
+
+		// User error 404 page
+		wrap.RenderFrontEnd("404", fetdata.New(wrap, nil, true), http.StatusNotFound)
+	}, func(wrap *wrapper.Wrapper) (string, string, string) {
+		content := ""
+		sidebar := ""
+		if wrap.CurrSubModule == "" || wrap.CurrSubModule == "default" {
+			content += this.getBreadCrumbs(wrap, &[]consts.BreadCrumb{
+				{Name: "List of posts"},
+			})
+			content += builder.DataTable(
+				wrap,
+				"blog_posts",
+				"id",
+				"DESC",
+				&[]builder.DataTableRow{
+					{
+						DBField: "id",
+					},
+					{
+						DBField:     "name",
+						NameInTable: "Post / URL",
+						CallBack: func(values *[]string) string {
+							name := `<a href="/cp/` + wrap.CurrModule + `/modify/` + (*values)[0] + `/">` + html.EscapeString((*values)[1]) + `</a>`
+							alias := html.EscapeString((*values)[2])
+							return `<div>` + name + `</div><div><small>/blog/` + alias + `/</small></div>`
+						},
+					},
+					{
+						DBField: "alias",
+					},
+					{
+						DBField:     "datetime",
+						DBExp:       "UNIX_TIMESTAMP(`datetime`)",
+						NameInTable: "Date / Time",
+						Classes:     "d-none d-md-table-cell",
+						CallBack: func(values *[]string) string {
+							t := int64(utils.StrToInt((*values)[3]))
+							return `<div>` + utils.UnixTimestampToFormat(t, "02.01.2006") + `</div>` +
+								`<div><small>` + utils.UnixTimestampToFormat(t, "15:04:05") + `</small></div>`
+						},
+					},
+					{
+						DBField:     "active",
+						NameInTable: "Active",
+						Classes:     "d-none d-sm-table-cell",
+						CallBack: func(values *[]string) string {
+							return builder.CheckBox(utils.StrToInt((*values)[4]))
+						},
+					},
+				},
+				func(values *[]string) string {
+					return builder.DataTableAction(&[]builder.DataTableActionRow{
+						{
+							Icon:   assets.SysSvgIconView,
+							Href:   `/blog/` + (*values)[2] + `/`,
+							Hint:   "View",
+							Target: "_blank",
+						},
+						{
+							Icon: assets.SysSvgIconEdit,
+							Href: "/cp/" + wrap.CurrModule + "/modify/" + (*values)[0] + "/",
+							Hint: "Edit",
+						},
+						{
+							Icon: assets.SysSvgIconRemove,
+							Href: "javascript:fave.ActionDataTableDelete(this,'blog-delete','" +
+								(*values)[0] + "','Are you sure want to delete post?');",
+							Hint:    "Delete",
+							Classes: "delete",
+						},
+					})
+				},
+				"/cp/"+wrap.CurrModule+"/",
+				nil,
+				nil,
+				true,
+			)
+		} else if wrap.CurrSubModule == "categories" {
+			content += this.getBreadCrumbs(wrap, &[]consts.BreadCrumb{
+				{Name: "Categories", Link: "/cp/" + wrap.CurrModule + "/" + wrap.CurrSubModule + "/"},
+				{Name: "List of categories"},
+			})
+			content += builder.DataTable(
+				wrap,
+				"blog_cats",
+				"id",
+				"ASC",
+				&[]builder.DataTableRow{
+					{
+						DBField: "id",
+					},
+					{
+						DBField: "user",
+					},
+					{
+						DBField:     "name",
+						NameInTable: "Category",
+						CallBack: func(values *[]string) string {
+							depth := utils.StrToInt((*values)[4]) - 1
+							if depth < 0 {
+								depth = 0
+							}
+							sub := strings.Repeat("&mdash; ", depth)
+							name := `<a href="/cp/` + wrap.CurrModule + `/categories-modify/` + (*values)[0] + `/">` + sub + html.EscapeString((*values)[2]) + `</a>`
+							return `<div>` + name + `</div>`
+						},
+					},
+					{
+						DBField: "alias",
+					},
+					{
+						DBField: "depth",
+					},
+				},
+				func(values *[]string) string {
+					return builder.DataTableAction(&[]builder.DataTableActionRow{
+						{
+							Icon:   assets.SysSvgIconView,
+							Href:   `/blog/category/` + (*values)[3] + `/`,
+							Hint:   "View",
+							Target: "_blank",
+						},
+						{
+							Icon: assets.SysSvgIconEdit,
+							Href: "/cp/" + wrap.CurrModule + "/categories-modify/" + (*values)[0] + "/",
+							Hint: "Edit",
+						},
+						{
+							Icon: assets.SysSvgIconRemove,
+							Href: "javascript:fave.ActionDataTableDelete(this,'blog-categories-delete','" +
+								(*values)[0] + "','Are you sure want to delete category?');",
+							Hint:    "Delete",
+							Classes: "delete",
+						},
+					})
+				},
+				"/cp/"+wrap.CurrModule+"/"+wrap.CurrSubModule+"/",
+				nil,
+				func(limit_offset int, pear_page int) (*sqlw.Rows, error) {
+					return wrap.DB.Query(
+						`SELECT
+							node.id,
+							node.user,
+							node.name,
+							node.alias,
+							(COUNT(parent.id) - 1) AS depth
+						FROM
+							blog_cats AS node,
+							blog_cats AS parent
+						WHERE
+							node.lft BETWEEN parent.lft AND parent.rgt AND
+							node.id > 1
+						GROUP BY
+							node.id
+						ORDER BY
+							node.lft ASC
+						;`,
+					)
+				},
+				false,
+			)
+		} else if wrap.CurrSubModule == "add" || wrap.CurrSubModule == "modify" {
+			if wrap.CurrSubModule == "add" {
+				content += this.getBreadCrumbs(wrap, &[]consts.BreadCrumb{
+					{Name: "Add new post"},
+				})
+			} else {
+				content += this.getBreadCrumbs(wrap, &[]consts.BreadCrumb{
+					{Name: "Modify post"},
+				})
+			}
+
+			data := utils.MySql_blog_post{
+				A_id:       0,
+				A_user:     0,
+				A_name:     "",
+				A_alias:    "",
+				A_content:  "",
+				A_datetime: 0,
+				A_active:   0,
+			}
+
+			if wrap.CurrSubModule == "modify" {
+				if len(wrap.UrlArgs) != 3 {
+					return "", "", ""
+				}
+				if !utils.IsNumeric(wrap.UrlArgs[2]) {
+					return "", "", ""
+				}
+				err := wrap.DB.QueryRow(`
+					SELECT
+						id,
+						user,
+						name,
+						alias,
+						briefly,
+						content,
+						active
+					FROM
+						blog_posts
+					WHERE
+						id = ?
+					LIMIT 1;`,
+					utils.StrToInt(wrap.UrlArgs[2]),
+				).Scan(
+					&data.A_id,
+					&data.A_user,
+					&data.A_name,
+					&data.A_alias,
+					&data.A_briefly,
+					&data.A_content,
+					&data.A_active,
+				)
+				if err != nil {
+					return "", "", ""
+				}
+			}
+
+			// All post current categories
+			var selids []int
+			if data.A_id > 0 {
+				rows, err := wrap.DB.Query("SELECT category_id FROM blog_cat_post_rel WHERE post_id = ?;", data.A_id)
+				if err == nil {
+					defer rows.Close()
+					values := make([]int, 1)
+					scan := make([]interface{}, len(values))
+					for i := range values {
+						scan[i] = &values[i]
+					}
+					for rows.Next() {
+						err = rows.Scan(scan...)
+						if err == nil {
+							selids = append(selids, int(values[0]))
+						}
+					}
+				}
+			}
+
+			btn_caption := "Add"
+			if wrap.CurrSubModule == "modify" {
+				btn_caption = "Save"
+			}
+
+			content += builder.DataForm(wrap, []builder.DataFormField{
+				{
+					Kind:  builder.DFKHidden,
+					Name:  "action",
+					Value: "blog-modify",
+				},
+				{
+					Kind:  builder.DFKHidden,
+					Name:  "id",
+					Value: utils.IntToStr(data.A_id),
+				},
+				{
+					Kind:    builder.DFKText,
+					Caption: "Post name",
+					Name:    "name",
+					Value:   data.A_name,
+				},
+				{
+					Kind:    builder.DFKText,
+					Caption: "Post alias",
+					Name:    "alias",
+					Value:   data.A_alias,
+					Hint:    "Example: our-news",
+				},
+				{
+					Kind:    builder.DFKText,
+					Caption: "Categories",
+					Name:    "cats",
+					Value:   "0",
+					CallBack: func(field *builder.DataFormField) string {
+						return `<div class="form-group n4">` +
+							`<div class="row">` +
+							`<div class="col-md-3">` +
+							`<label for="lbl_parent">Categories</label>` +
+							`</div>` +
+							`<div class="col-md-9">` +
+							`<div>` +
+							`<select class="form-control" id="lbl_cats" name="cats[]" multiple>` +
+							this.blog_GetCategorySelectOptions(wrap, 0, 0, selids) +
+							`</select>` +
+							`</div>` +
+							`</div>` +
+							`</div>` +
+							`</div>`
+					},
+				},
+				{
+					Kind:    builder.DFKTextArea,
+					Caption: "Briefly",
+					Name:    "briefly",
+					Value:   data.A_briefly,
+					Classes: "briefly autosize",
+				},
+				{
+					Kind:    builder.DFKTextArea,
+					Caption: "Post content",
+					Name:    "content",
+					Value:   data.A_content,
+					Classes: "autosize",
+				},
+				{
+					Kind:    builder.DFKCheckBox,
+					Caption: "Active",
+					Name:    "active",
+					Value:   utils.IntToStr(data.A_active),
+				},
+				{
+					Kind: builder.DFKMessage,
+				},
+				{
+					Kind:   builder.DFKSubmit,
+					Value:  btn_caption,
+					Target: "add-edit-button",
+				},
+			})
+
+			if wrap.CurrSubModule == "add" {
+				sidebar += `<button class="btn btn-primary btn-sidebar" id="add-edit-button">Add</button>`
+			} else {
+				sidebar += `<button class="btn btn-primary btn-sidebar" id="add-edit-button">Save</button>`
+			}
+		} else if wrap.CurrSubModule == "categories-add" || wrap.CurrSubModule == "categories-modify" {
+			if wrap.CurrSubModule == "categories-add" {
+				content += this.getBreadCrumbs(wrap, &[]consts.BreadCrumb{
+					{Name: "Categories", Link: "/cp/" + wrap.CurrModule + "/categories/"},
+					{Name: "Add new category"},
+				})
+			} else {
+				content += this.getBreadCrumbs(wrap, &[]consts.BreadCrumb{
+					{Name: "Categories", Link: "/cp/" + wrap.CurrModule + "/categories/"},
+					{Name: "Modify category"},
+				})
+			}
+
+			data := utils.MySql_blog_category{
+				A_id:    0,
+				A_user:  0,
+				A_name:  "",
+				A_alias: "",
+				A_lft:   0,
+				A_rgt:   0,
+			}
+
+			if wrap.CurrSubModule == "categories-modify" {
+				if len(wrap.UrlArgs) != 3 {
+					return "", "", ""
+				}
+				if !utils.IsNumeric(wrap.UrlArgs[2]) {
+					return "", "", ""
+				}
+				err := wrap.DB.QueryRow(`
+					SELECT
+						id,
+						user,
+						name,
+						alias,
+						lft,
+						rgt
+					FROM
+						blog_cats
+					WHERE
+						id = ?
+					LIMIT 1;`,
+					utils.StrToInt(wrap.UrlArgs[2]),
+				).Scan(
+					&data.A_id,
+					&data.A_user,
+					&data.A_name,
+					&data.A_alias,
+					&data.A_lft,
+					&data.A_rgt,
+				)
+				if err != nil {
+					return "", "", ""
+				}
+			}
+
+			btn_caption := "Add"
+			if wrap.CurrSubModule == "categories-modify" {
+				btn_caption = "Save"
+			}
+
+			parentId := 0
+			if wrap.CurrSubModule == "categories-modify" {
+				parentId = this.blog_GetCategoryParentId(wrap, data.A_id)
+			}
+
+			content += builder.DataForm(wrap, []builder.DataFormField{
+				{
+					Kind:  builder.DFKHidden,
+					Name:  "action",
+					Value: "blog-categories-modify",
+				},
+				{
+					Kind:  builder.DFKHidden,
+					Name:  "id",
+					Value: utils.IntToStr(data.A_id),
+				},
+				{
+					Kind:    builder.DFKText,
+					Caption: "Parent",
+					Name:    "parent",
+					Value:   "0",
+					CallBack: func(field *builder.DataFormField) string {
+						return `<div class="form-group n2">` +
+							`<div class="row">` +
+							`<div class="col-md-3">` +
+							`<label for="lbl_parent">Parent</label>` +
+							`</div>` +
+							`<div class="col-md-9">` +
+							`<div>` +
+							`<select class="form-control" id="lbl_parent" name="parent">` +
+							`<option value="0">&mdash;</option>` +
+							this.blog_GetCategorySelectOptions(wrap, data.A_id, parentId, []int{}) +
+							`</select>` +
+							`</div>` +
+							`</div>` +
+							`</div>` +
+							`</div>`
+					},
+				},
+				{
+					Kind:    builder.DFKText,
+					Caption: "Name",
+					Name:    "name",
+					Value:   data.A_name,
+				},
+				{
+					Kind:    builder.DFKText,
+					Caption: "Alias",
+					Name:    "alias",
+					Value:   data.A_alias,
+					Hint:    "Example: popular-posts",
+				},
+				{
+					Kind: builder.DFKMessage,
+				},
+				{
+					Kind:   builder.DFKSubmit,
+					Value:  btn_caption,
+					Target: "add-edit-button",
+				},
+			})
+
+			if wrap.CurrSubModule == "categories-add" {
+				sidebar += `<button class="btn btn-primary btn-sidebar" id="add-edit-button">Add</button>`
+			} else {
+				sidebar += `<button class="btn btn-primary btn-sidebar" id="add-edit-button">Save</button>`
+			}
+		}
+		return this.getSidebarModules(wrap), content, sidebar
+	})
+}

+ 46 - 0
modules/module_blog_act_delete.go

@@ -0,0 +1,46 @@
+package modules
+
+import (
+	"golang-fave/engine/wrapper"
+	"golang-fave/utils"
+)
+
+func (this *Modules) RegisterAction_BlogDelete() *Action {
+	return this.newAction(AInfo{
+		WantDB:    true,
+		Mount:     "blog-delete",
+		WantAdmin: true,
+	}, func(wrap *wrapper.Wrapper) {
+		pf_id := wrap.R.FormValue("id")
+
+		if !utils.IsNumeric(pf_id) {
+			wrap.MsgError(`Inner system error`)
+			return
+		}
+
+		if err := wrap.DB.Transaction(func(tx *wrapper.Tx) error {
+			// Block rows
+			if _, err := tx.Exec("SELECT id FROM blog_posts WHERE id = ? FOR UPDATE;", pf_id); err != nil {
+				return err
+			}
+			if _, err := tx.Exec("SELECT id FROM blog_cat_post_rel WHERE post_id = ? FOR UPDATE;", pf_id); err != nil {
+				return err
+			}
+
+			// Delete target post with category connection data
+			if _, err := tx.Exec("DELETE FROM blog_posts WHERE id = ?;", pf_id); err != nil {
+				return err
+			}
+			if _, err := tx.Exec("DELETE FROM blog_cat_post_rel WHERE post_id = ?;", pf_id); err != nil {
+				return err
+			}
+			return nil
+		}); err != nil {
+			wrap.MsgError(err.Error())
+			return
+		}
+
+		// Reload current page
+		wrap.Write(`window.location.reload(false);`)
+	})
+}

+ 197 - 0
modules/module_blog_act_modify.go

@@ -0,0 +1,197 @@
+package modules
+
+import (
+	"errors"
+	"strings"
+
+	"golang-fave/engine/wrapper"
+	"golang-fave/utils"
+)
+
+func (this *Modules) RegisterAction_BlogModify() *Action {
+	return this.newAction(AInfo{
+		WantDB:    true,
+		Mount:     "blog-modify",
+		WantAdmin: true,
+	}, func(wrap *wrapper.Wrapper) {
+		pf_id := wrap.R.FormValue("id")
+		pf_name := wrap.R.FormValue("name")
+		pf_alias := wrap.R.FormValue("alias")
+		pf_briefly := wrap.R.FormValue("briefly")
+		pf_content := wrap.R.FormValue("content")
+		pf_active := wrap.R.FormValue("active")
+
+		if pf_active == "" {
+			pf_active = "0"
+		}
+
+		if !utils.IsNumeric(pf_id) {
+			wrap.MsgError(`Inner system error`)
+			return
+		}
+
+		if pf_name == "" {
+			wrap.MsgError(`Please specify page name`)
+			return
+		}
+
+		if pf_alias == "" {
+			pf_alias = utils.GenerateSingleAlias(pf_name)
+		}
+
+		if !utils.IsValidSingleAlias(pf_alias) {
+			wrap.MsgError(`Please specify correct post alias`)
+			return
+		}
+
+		if pf_id == "0" {
+			if err := wrap.DB.Transaction(func(tx *wrapper.Tx) error {
+				// Insert row
+				res, err := tx.Exec(
+					`INSERT INTO blog_posts SET
+						user = ?,
+						name = ?,
+						alias = ?,
+						briefly = ?,
+						content = ?,
+						datetime = ?,
+						active = ?
+					;`,
+					wrap.User.A_id,
+					pf_name,
+					pf_alias,
+					pf_briefly,
+					pf_content,
+					utils.UnixTimestampToMySqlDateTime(utils.GetCurrentUnixTimestamp()),
+					pf_active,
+				)
+				if err != nil {
+					return err
+				}
+
+				// Get inserted post id
+				lastID, err := res.LastInsertId()
+				if err != nil {
+					return err
+				}
+
+				// Block rows
+				if _, err := tx.Exec("SELECT id FROM blog_posts WHERE id = ? FOR UPDATE;", lastID); err != nil {
+					return err
+				}
+
+				// Insert post and categories relations
+				catids := utils.GetPostArrayInt("cats[]", wrap.R)
+				if len(catids) > 0 {
+					var catsCount int
+					err = tx.QueryRow(`
+						SELECT
+							COUNT(*)
+						FROM
+							blog_cats
+						WHERE
+							id IN(` + strings.Join(utils.ArrayOfIntToArrayOfString(catids), ",") + `)
+						FOR UPDATE;`,
+					).Scan(
+						&catsCount,
+					)
+					if err != nil {
+						return err
+					}
+					if len(catids) != catsCount {
+						return errors.New("Inner system error")
+					}
+					var balkInsertArr []string
+					for _, el := range catids {
+						balkInsertArr = append(balkInsertArr, `(NULL,`+utils.Int64ToStr(lastID)+`,`+utils.IntToStr(el)+`)`)
+					}
+					if _, err = tx.Exec(
+						`INSERT INTO blog_cat_post_rel (id,post_id,category_id) VALUES ` + strings.Join(balkInsertArr, ",") + `;`,
+					); err != nil {
+						return err
+					}
+				}
+				return nil
+			}); err != nil {
+				wrap.MsgError(err.Error())
+				return
+			}
+
+			wrap.Write(`window.location='/cp/blog/';`)
+		} else {
+			if err := wrap.DB.Transaction(func(tx *wrapper.Tx) error {
+				// Block rows
+				if _, err := tx.Exec("SELECT id FROM blog_posts WHERE id = ? FOR UPDATE;", pf_id); err != nil {
+					return err
+				}
+				if _, err := tx.Exec("SELECT id FROM blog_cat_post_rel WHERE post_id = ? FOR UPDATE;", pf_id); err != nil {
+					return err
+				}
+
+				// Update row
+				if _, err := tx.Exec(
+					`UPDATE blog_posts SET
+						name = ?,
+						alias = ?,
+						briefly = ?,
+						content = ?,
+						active = ?
+					WHERE
+						id = ?
+					;`,
+					pf_name,
+					pf_alias,
+					pf_briefly,
+					pf_content,
+					pf_active,
+					utils.StrToInt(pf_id),
+				); err != nil {
+					return err
+				}
+
+				// Delete post and categories relations
+				if _, err := tx.Exec("DELETE FROM blog_cat_post_rel WHERE post_id = ?;", pf_id); err != nil {
+					return err
+				}
+
+				// Insert post and categories relations
+				catids := utils.GetPostArrayInt("cats[]", wrap.R)
+				if len(catids) > 0 {
+					var catsCount int
+					err := tx.QueryRow(`
+						SELECT
+							COUNT(*)
+						FROM
+							blog_cats
+						WHERE
+							id IN(` + strings.Join(utils.ArrayOfIntToArrayOfString(catids), ",") + `)
+						FOR UPDATE;`,
+					).Scan(
+						&catsCount,
+					)
+					if err != nil {
+						return err
+					}
+					if len(catids) != catsCount {
+						return errors.New("Inner system error")
+					}
+					var balkInsertArr []string
+					for _, el := range catids {
+						balkInsertArr = append(balkInsertArr, `(NULL,`+pf_id+`,`+utils.IntToStr(el)+`)`)
+					}
+					if _, err := tx.Exec(
+						`INSERT INTO blog_cat_post_rel (id,post_id,category_id) VALUES ` + strings.Join(balkInsertArr, ",") + `;`,
+					); err != nil {
+						return err
+					}
+				}
+				return nil
+			}); err != nil {
+				wrap.MsgError(err.Error())
+				return
+			}
+
+			wrap.Write(`window.location='/cp/blog/modify/` + pf_id + `/';`)
+		}
+	})
+}

+ 91 - 0
modules/module_blog_categories.go

@@ -0,0 +1,91 @@
+package modules
+
+import (
+	"html"
+	"strings"
+
+	"golang-fave/engine/wrapper"
+	"golang-fave/utils"
+)
+
+func (this *Modules) blog_GetCategorySelectOptions(wrap *wrapper.Wrapper, id int, parentId int, selids []int) string {
+	result := ``
+	rows, err := wrap.DB.Query(
+		`SELECT
+			node.id,
+			node.user,
+			node.name,
+			node.alias,
+			(COUNT(parent.id) - 1) AS depth
+		FROM
+			blog_cats AS node,
+			blog_cats AS parent
+		WHERE
+			node.lft BETWEEN parent.lft AND parent.rgt AND
+			node.id > 1
+		GROUP BY
+			node.id
+		ORDER BY
+			node.lft ASC
+		;`,
+	)
+	if err == nil {
+		defer rows.Close()
+		values := make([]string, 5)
+		scan := make([]interface{}, len(values))
+		for i := range values {
+			scan[i] = &values[i]
+		}
+		idStr := utils.IntToStr(id)
+		parentIdStr := utils.IntToStr(parentId)
+		for rows.Next() {
+			err = rows.Scan(scan...)
+			if err == nil {
+				disabled := ""
+				if string(values[0]) == idStr {
+					disabled = " disabled"
+				}
+				selected := ""
+				if string(values[0]) == parentIdStr {
+					selected = " selected"
+				}
+				if len(selids) > 0 && utils.InArrayInt(selids, utils.StrToInt(string(values[0]))) {
+					selected = " selected"
+				}
+				depth := utils.StrToInt(string(values[4])) - 1
+				if depth < 0 {
+					depth = 0
+				}
+				sub := strings.Repeat("&mdash; ", depth)
+				result += `<option value="` + html.EscapeString(string(values[0])) + `"` + disabled + selected + `>` + sub + html.EscapeString(string(values[2])) + `</option>`
+			}
+		}
+	}
+	return result
+}
+
+func (this *Modules) blog_GetCategoryParentId(wrap *wrapper.Wrapper, id int) int {
+	var parentId int
+	err := wrap.DB.QueryRow(`
+		SELECT
+			parent.id
+		FROM
+			blog_cats AS node,
+			blog_cats AS parent
+		WHERE
+			node.lft BETWEEN parent.lft AND parent.rgt AND
+			node.id = ? AND
+			parent.id <> ?
+		ORDER BY
+			parent.lft DESC
+		LIMIT 1;`,
+		id,
+		id,
+	).Scan(
+		&parentId,
+	)
+	if err != nil {
+		return 0
+	}
+	return parentId
+}

+ 60 - 0
modules/module_blog_categories_act_delete.go

@@ -0,0 +1,60 @@
+package modules
+
+import (
+	"golang-fave/engine/wrapper"
+	"golang-fave/utils"
+)
+
+func (this *Modules) RegisterAction_BlogCategoriesDelete() *Action {
+	return this.newAction(AInfo{
+		WantDB:    true,
+		Mount:     "blog-categories-delete",
+		WantAdmin: true,
+	}, func(wrap *wrapper.Wrapper) {
+		pf_id := wrap.R.FormValue("id")
+
+		if !utils.IsNumeric(pf_id) || utils.StrToInt(pf_id) <= 1 {
+			wrap.MsgError(`Inner system error`)
+			return
+		}
+
+		err := wrap.DB.Transaction(func(tx *wrapper.Tx) error {
+			// Block rows
+			if _, err := tx.Exec("SELECT id FROM blog_cats FOR UPDATE;"); err != nil {
+				return err
+			}
+			if _, err := tx.Exec("SELECT id FROM blog_cat_post_rel WHERE category_id = ? FOR UPDATE;", pf_id); err != nil {
+				return err
+			}
+
+			// Process
+			if _, err := tx.Exec("SELECT @ml := lft, @mr := rgt FROM blog_cats WHERE id = ?;", pf_id); err != nil {
+				return err
+			}
+			if _, err := tx.Exec("DELETE FROM blog_cats WHERE id = ?;", pf_id); err != nil {
+				return err
+			}
+			if _, err := tx.Exec("UPDATE blog_cats SET lft = lft - 1, rgt = rgt - 1 WHERE lft > @ml AND rgt < @mr;"); err != nil {
+				return err
+			}
+			if _, err := tx.Exec("UPDATE blog_cats SET lft = lft - 2 WHERE lft > @mr;"); err != nil {
+				return err
+			}
+			if _, err := tx.Exec("UPDATE blog_cats SET rgt = rgt - 2 WHERE rgt > @mr;"); err != nil {
+				return err
+			}
+			if _, err := tx.Exec("DELETE FROM blog_cat_post_rel WHERE category_id = ?;", pf_id); err != nil {
+				return err
+			}
+			return nil
+		})
+
+		if err != nil {
+			wrap.MsgError(err.Error())
+			return
+		}
+
+		// Reload current page
+		wrap.Write(`window.location.reload(false);`)
+	})
+}

+ 227 - 0
modules/module_blog_categories_act_modify.go

@@ -0,0 +1,227 @@
+package modules
+
+import (
+	"errors"
+
+	"golang-fave/engine/wrapper"
+	"golang-fave/utils"
+)
+
+func (this *Modules) blog_ActionCategoryAdd(wrap *wrapper.Wrapper, pf_id, pf_name, pf_alias, pf_parent string) error {
+	return wrap.DB.Transaction(func(tx *wrapper.Tx) error {
+		// Block rows
+		if _, err := tx.Exec("SELECT id FROM blog_cats FOR UPDATE;"); err != nil {
+			return err
+		}
+
+		// Process
+		if _, err := tx.Exec("SELECT @mr := rgt FROM blog_cats WHERE id = ?;", pf_parent); err != nil {
+			return err
+		}
+		if _, err := tx.Exec("UPDATE blog_cats SET rgt = rgt + 2 WHERE rgt > @mr;"); err != nil {
+			return err
+		}
+		if _, err := tx.Exec("UPDATE blog_cats SET lft = lft + 2 WHERE lft > @mr;"); err != nil {
+			return err
+		}
+		if _, err := tx.Exec("UPDATE blog_cats SET rgt = rgt + 2 WHERE id = ?;", pf_parent); err != nil {
+			return err
+		}
+		if _, err := tx.Exec("INSERT INTO blog_cats (id, user, name, alias, lft, rgt) VALUES (NULL, ?, ?, ?, @mr, @mr + 1);", wrap.User.A_id, pf_name, pf_alias); err != nil {
+			return err
+		}
+		return nil
+	})
+}
+
+func (this *Modules) blog_ActionCategoryUpdate(wrap *wrapper.Wrapper, pf_id, pf_name, pf_alias, pf_parent string) error {
+	parentId := this.blog_GetCategoryParentId(wrap, utils.StrToInt(pf_id))
+
+	if utils.StrToInt(pf_parent) == parentId {
+		// If parent not changed, just update category data
+		return wrap.DB.Transaction(func(tx *wrapper.Tx) error {
+			// Process
+			if _, err := tx.Exec(`
+				UPDATE blog_cats SET
+					name = ?,
+					alias = ?
+				WHERE
+					id > 1 AND
+					id = ?
+				;`,
+				pf_name,
+				pf_alias,
+				pf_id,
+			); err != nil {
+				return err
+			}
+			return nil
+		})
+	}
+
+	// TODO: Fix parent change
+
+	// Parent is changed, move category to new parent
+	return wrap.DB.Transaction(func(tx *wrapper.Tx) error {
+		// Block all rows
+		if _, err := tx.Exec("SELECT id FROM blog_cats FOR UPDATE;"); err != nil {
+			return err
+		}
+
+		var parentL int
+		var parentR int
+		if err := tx.QueryRow(`SELECT lft, rgt FROM blog_cats WHERE id = ?;`, pf_parent).Scan(&parentL, &parentR); err != nil {
+			return err
+		}
+
+		var targetL int
+		var targetR int
+		if err := tx.QueryRow(`SELECT lft, rgt FROM blog_cats WHERE id = ?;`, pf_id).Scan(&targetL, &targetR); err != nil {
+			return err
+		}
+
+		if !(targetL < parentL && targetR > parentR) {
+			// Select data
+			rows, err := tx.Query("SELECT id, lft, rgt FROM blog_cats WHERE lft >= ? and rgt <= ? ORDER BY lft ASC", targetL, targetR)
+			if err != nil {
+				return err
+			}
+			defer rows.Close()
+			var rows_id []int
+			var rows_lft []int
+			var rows_rgt []int
+			for rows.Next() {
+				var row_id int
+				var row_lft int
+				var row_rgt int
+				if err := rows.Scan(&row_id, &row_lft, &row_rgt); err == nil {
+					rows_id = append(rows_id, row_id)
+					rows_lft = append(rows_lft, row_lft)
+					rows_rgt = append(rows_rgt, row_rgt)
+				}
+			}
+
+			if targetL > parentR {
+				// From right to left
+				// Shift
+				step := targetR - targetL + 1
+				if _, err := tx.Exec("UPDATE blog_cats SET lft = lft + ? WHERE lft > ? and lft < ?;", step, parentR, targetL); err != nil {
+					return err
+				}
+				if _, err := tx.Exec("UPDATE blog_cats SET rgt = rgt + ? WHERE rgt > ? and rgt < ?;", step, parentR, targetL); err != nil {
+					return err
+				}
+				if _, err := tx.Exec("UPDATE blog_cats SET rgt = rgt + ? WHERE id = ?;", step, pf_parent); err != nil {
+					return err
+				}
+
+				// Update target rows
+				for i, _ := range rows_id {
+					new_lft := rows_lft[i] - (targetL - parentR)
+					new_rgt := rows_rgt[i] - (targetL - parentR)
+					if _, err := tx.Exec("UPDATE blog_cats SET lft = ?, rgt = ? WHERE id = ?;", new_lft, new_rgt, rows_id[i]); err != nil {
+						return err
+					}
+				}
+			} else {
+				// From left to right
+				// Shift
+				step := targetR - targetL + 1
+				if _, err := tx.Exec("UPDATE blog_cats SET lft = lft - ? WHERE lft > ? and lft < ?;", step, targetR, parentR); err != nil {
+					return err
+				}
+				if _, err := tx.Exec("UPDATE blog_cats SET rgt = rgt - ? WHERE rgt > ? and rgt < ?;", step, targetR, parentR); err != nil {
+					return err
+				}
+
+				// Update target rows
+				for i, _ := range rows_id {
+					new_lft := rows_lft[i] + (parentR - targetL - step)
+					new_rgt := rows_rgt[i] + (parentR - targetL - step)
+					if _, err := tx.Exec("UPDATE blog_cats SET lft = ?, rgt = ? WHERE id = ?;", new_lft, new_rgt, rows_id[i]); err != nil {
+						return err
+					}
+				}
+			}
+		} else {
+			// Trying to move category to they child as parent
+			return errors.New("Category can't be moved inside here child")
+		}
+
+		// Update target cat data
+		if _, err := tx.Exec("UPDATE blog_cats SET name = ?, alias = ? WHERE id = ?;", pf_name, pf_alias, pf_id); err != nil {
+			return err
+		}
+
+		return nil
+	})
+}
+
+func (this *Modules) RegisterAction_BlogCategoriesModify() *Action {
+	return this.newAction(AInfo{
+		WantDB:    true,
+		Mount:     "blog-categories-modify",
+		WantAdmin: true,
+	}, func(wrap *wrapper.Wrapper) {
+		pf_id := wrap.R.FormValue("id")
+		pf_name := wrap.R.FormValue("name")
+		pf_alias := wrap.R.FormValue("alias")
+		pf_parent := wrap.R.FormValue("parent")
+
+		if !utils.IsNumeric(pf_id) || !utils.IsNumeric(pf_parent) {
+			wrap.MsgError(`Inner system error`)
+			return
+		}
+
+		if pf_name == "" {
+			wrap.MsgError(`Please specify category name`)
+			return
+		}
+
+		if pf_alias == "" {
+			pf_alias = utils.GenerateSingleAlias(pf_name)
+		}
+
+		if !utils.IsValidSingleAlias(pf_alias) {
+			wrap.MsgError(`Please specify correct category alias`)
+			return
+		}
+
+		// Set root category as default
+		if pf_parent == "0" {
+			pf_parent = "1"
+		} else {
+			// Check if parent category exists
+			var parentId int
+			err := wrap.DB.QueryRow(`
+				SELECT
+					id
+				FROM
+					blog_cats
+				WHERE
+					id > 1 AND
+					id = ?
+				LIMIT 1;`,
+				pf_parent,
+			).Scan(&parentId)
+			if err != nil {
+				wrap.MsgError(err.Error())
+				return
+			}
+		}
+
+		if pf_id == "0" {
+			if err := this.blog_ActionCategoryAdd(wrap, pf_id, pf_name, pf_alias, pf_parent); err != nil {
+				wrap.MsgError(err.Error())
+				return
+			}
+			wrap.Write(`window.location='/cp/blog/categories/';`)
+		} else {
+			if err := this.blog_ActionCategoryUpdate(wrap, pf_id, pf_name, pf_alias, pf_parent); err != nil {
+				wrap.MsgError(err.Error())
+				return
+			}
+			wrap.Write(`window.location='/cp/blog/categories-modify/` + pf_id + `/';`)
+		}
+	})
+}

File diff suppressed because it is too large
+ 66 - 390
modules/module_index.go


+ 49 - 0
modules/module_index_act_cypress.go

@@ -0,0 +1,49 @@
+package modules
+
+import (
+	"os"
+
+	"golang-fave/consts"
+	"golang-fave/engine/sqlw"
+	"golang-fave/engine/wrapper"
+)
+
+func (this *Modules) RegisterAction_IndexCypressReset() *Action {
+	return this.newAction(AInfo{
+		WantDB: false,
+		Mount:  "index-cypress-reset",
+	}, func(wrap *wrapper.Wrapper) {
+		if !consts.ParamDebug {
+			wrap.Write(`Access denied`)
+			return
+		}
+
+		db, err := sqlw.Open("mysql", "root:root@tcp(localhost:3306)/fave")
+		if err != nil {
+			wrap.Write(err.Error())
+			return
+		}
+		defer db.Close()
+		err = db.Ping()
+		if err != nil {
+			wrap.Write(err.Error())
+			return
+		}
+
+		os.Remove(wrap.DConfig + string(os.PathSeparator) + ".installed")
+		os.Remove(wrap.DConfig + string(os.PathSeparator) + "mysql.json")
+		os.Remove(wrap.DConfig + string(os.PathSeparator) + "config.json")
+
+		_, _ = db.Exec(
+			`DROP TABLE
+				blog_cats,
+				blog_cat_post_rel,
+				blog_posts,
+				pages,
+				users
+			;`,
+		)
+
+		wrap.Write(`OK`)
+	})
+}

+ 37 - 0
modules/module_index_act_delete.go

@@ -0,0 +1,37 @@
+package modules
+
+import (
+	"golang-fave/engine/wrapper"
+	"golang-fave/utils"
+)
+
+func (this *Modules) RegisterAction_IndexDelete() *Action {
+	return this.newAction(AInfo{
+		WantDB:    true,
+		Mount:     "index-delete",
+		WantAdmin: true,
+	}, func(wrap *wrapper.Wrapper) {
+		pf_id := wrap.R.FormValue("id")
+
+		if !utils.IsNumeric(pf_id) {
+			wrap.MsgError(`Inner system error`)
+			return
+		}
+
+		err := wrap.DB.Transaction(func(tx *wrapper.Tx) error {
+			// Process
+			if _, err := tx.Exec("DELETE FROM pages WHERE id = ?;", pf_id); err != nil {
+				return err
+			}
+			return nil
+		})
+
+		if err != nil {
+			wrap.MsgError(err.Error())
+			return
+		}
+
+		// Reload current page
+		wrap.Write(`window.location.reload(false);`)
+	})
+}

+ 76 - 0
modules/module_index_act_first_user.go

@@ -0,0 +1,76 @@
+package modules
+
+import (
+	"golang-fave/engine/wrapper"
+	"golang-fave/utils"
+)
+
+func (this *Modules) RegisterAction_IndexFirstUser() *Action {
+	return this.newAction(AInfo{
+		WantDB: true,
+		Mount:  "index-first-user",
+	}, func(wrap *wrapper.Wrapper) {
+		pf_first_name := wrap.R.FormValue("first_name")
+		pf_last_name := wrap.R.FormValue("last_name")
+		pf_email := wrap.R.FormValue("email")
+		pf_password := wrap.R.FormValue("password")
+
+		if pf_email == "" {
+			wrap.MsgError(`Please specify user email`)
+			return
+		}
+
+		if !utils.IsValidEmail(pf_email) {
+			wrap.MsgError(`Please specify correct user email`)
+			return
+		}
+
+		if pf_password == "" {
+			wrap.MsgError(`Please specify user password`)
+			return
+		}
+
+		// Security, check if still need to run this action
+		var count int
+		err := wrap.DB.QueryRow(`
+			SELECT
+				COUNT(*)
+			FROM
+				users
+			;`,
+		).Scan(
+			&count,
+		)
+		if err != nil {
+			wrap.MsgError(err.Error())
+			return
+		}
+		if count > 0 {
+			wrap.MsgError(`CMS is already configured`)
+			return
+		}
+
+		_, err = wrap.DB.Exec(
+			`INSERT INTO users SET
+				id = 1,
+				first_name = ?,
+				last_name = ?,
+				email = ?,
+				password = MD5(?),
+				admin = 1,
+				active = 1
+			;`,
+			pf_first_name,
+			pf_last_name,
+			pf_email,
+			pf_password,
+		)
+		if err != nil {
+			wrap.MsgError(err.Error())
+			return
+		}
+
+		// Reload current page
+		wrap.Write(`window.location.reload(false);`)
+	})
+}

+ 22 - 0
modules/module_index_act_logout.go

@@ -0,0 +1,22 @@
+package modules
+
+import (
+	"golang-fave/engine/wrapper"
+)
+
+func (this *Modules) RegisterAction_IndexUserLogout() *Action {
+	return this.newAction(AInfo{
+		WantDB:   true,
+		Mount:    "index-user-logout",
+		WantUser: true,
+	}, func(wrap *wrapper.Wrapper) {
+		// Reset session var
+		wrap.S.SetInt("UserId", 0)
+
+		// Delete session file
+		_ = wrap.S.Destroy()
+
+		// Reload current page
+		wrap.Write(`window.location.reload(false);`)
+	})
+}

+ 105 - 0
modules/module_index_act_modify.go

@@ -0,0 +1,105 @@
+package modules
+
+import (
+	"golang-fave/engine/wrapper"
+	"golang-fave/utils"
+)
+
+func (this *Modules) RegisterAction_IndexModify() *Action {
+	return this.newAction(AInfo{
+		WantDB:    true,
+		Mount:     "index-modify",
+		WantAdmin: true,
+	}, func(wrap *wrapper.Wrapper) {
+		pf_id := wrap.R.FormValue("id")
+		pf_name := wrap.R.FormValue("name")
+		pf_alias := wrap.R.FormValue("alias")
+		pf_content := wrap.R.FormValue("content")
+		pf_meta_title := wrap.R.FormValue("meta_title")
+		pf_meta_keywords := wrap.R.FormValue("meta_keywords")
+		pf_meta_description := wrap.R.FormValue("meta_description")
+		pf_active := wrap.R.FormValue("active")
+
+		if pf_active == "" {
+			pf_active = "0"
+		}
+
+		if !utils.IsNumeric(pf_id) {
+			wrap.MsgError(`Inner system error`)
+			return
+		}
+
+		if pf_name == "" {
+			wrap.MsgError(`Please specify page name`)
+			return
+		}
+
+		if pf_alias == "" {
+			pf_alias = utils.GenerateAlias(pf_name)
+		}
+
+		if !utils.IsValidAlias(pf_alias) {
+			wrap.MsgError(`Please specify correct page alias`)
+			return
+		}
+
+		if pf_id == "0" {
+			// Add new page
+			_, err := wrap.DB.Exec(
+				`INSERT INTO pages SET
+					user = ?,
+					name = ?,
+					alias = ?,
+					content = ?,
+					meta_title = ?,
+					meta_keywords = ?,
+					meta_description = ?,
+					datetime = ?,
+					active = ?
+				;`,
+				wrap.User.A_id,
+				pf_name,
+				pf_alias,
+				pf_content,
+				pf_meta_title,
+				pf_meta_keywords,
+				pf_meta_description,
+				utils.UnixTimestampToMySqlDateTime(utils.GetCurrentUnixTimestamp()),
+				pf_active,
+			)
+			if err != nil {
+				wrap.MsgError(err.Error())
+				return
+			}
+			wrap.Write(`window.location='/cp/';`)
+		} else {
+			// Update page
+			_, err := wrap.DB.Exec(
+				`UPDATE pages SET
+					name = ?,
+					alias = ?,
+					content = ?,
+					meta_title = ?,
+					meta_keywords = ?,
+					meta_description = ?,
+					active = ?
+				WHERE
+					id = ?
+				;`,
+				pf_name,
+				pf_alias,
+				pf_content,
+				pf_meta_title,
+				pf_meta_keywords,
+				pf_meta_description,
+				pf_active,
+				utils.StrToInt(pf_id),
+			)
+			if err != nil {
+				wrap.MsgError(err.Error())
+				return
+			}
+			wrap.Write(`window.location='/cp/index/modify/` + pf_id + `/';`)
+		}
+	})
+}

File diff suppressed because it is too large
+ 281 - 0
modules/module_index_act_mysql_setup.go


+ 71 - 0
modules/module_index_act_signin.go

@@ -0,0 +1,71 @@
+package modules
+
+import (
+	"golang-fave/engine/sqlw"
+	"golang-fave/engine/wrapper"
+	"golang-fave/utils"
+)
+
+func (this *Modules) RegisterAction_IndexUserSignIn() *Action {
+	return this.newAction(AInfo{
+		WantDB: true,
+		Mount:  "index-user-sign-in",
+	}, func(wrap *wrapper.Wrapper) {
+		pf_email := wrap.R.FormValue("email")
+		pf_password := wrap.R.FormValue("password")
+
+		if pf_email == "" {
+			wrap.MsgError(`Please specify user email`)
+			return
+		}
+
+		if !utils.IsValidEmail(pf_email) {
+			wrap.MsgError(`Please specify correct user email`)
+			return
+		}
+
+		if pf_password == "" {
+			wrap.MsgError(`Please specify user password`)
+			return
+		}
+
+		if wrap.S.GetInt("UserId", 0) > 0 {
+			wrap.MsgError(`You already logined`)
+			return
+		}
+
+		var user_id int
+		err := wrap.DB.QueryRow(
+			`SELECT
+				id
+			FROM
+				users
+			WHERE
+				email = ? and
+				password = MD5(?) and
+				admin = 1 and
+				active = 1
+			LIMIT 1;`,
+			pf_email,
+			pf_password,
+		).Scan(
+			&user_id,
+		)
+
+		if err != nil && err != sqlw.ErrNoRows {
+			wrap.MsgError(err.Error())
+			return
+		}
+
+		if err == sqlw.ErrNoRows {
+			wrap.MsgError(`Incorrect email or password`)
+			return
+		}
+
+		// Save to current session
+		wrap.S.SetInt("UserId", user_id)
+
+		// Reload current page
+		wrap.Write(`window.location.reload(false);`)
+	})
+}

+ 74 - 0
modules/module_index_act_update_profile.go

@@ -0,0 +1,74 @@
+package modules
+
+import (
+	"golang-fave/engine/wrapper"
+	"golang-fave/utils"
+)
+
+func (this *Modules) RegisterAction_IndexUserUpdateProfile() *Action {
+	return this.newAction(AInfo{
+		WantDB:   true,
+		Mount:    "index-user-update-profile",
+		WantUser: true,
+	}, func(wrap *wrapper.Wrapper) {
+		pf_first_name := wrap.R.FormValue("first_name")
+		pf_last_name := wrap.R.FormValue("last_name")
+		pf_email := wrap.R.FormValue("email")
+		pf_password := wrap.R.FormValue("password")
+
+		if pf_email == "" {
+			wrap.MsgError(`Please specify user email`)
+			return
+		}
+
+		if !utils.IsValidEmail(pf_email) {
+			wrap.MsgError(`Please specify correct user email`)
+			return
+		}
+
+		if pf_password != "" {
+			// Update with password if set
+			_, err := wrap.DB.Exec(
+				`UPDATE users SET
+					first_name = ?,
+					last_name = ?,
+					email = ?,
+					password = MD5(?)
+				WHERE
+					id = ?
+				;`,
+				pf_first_name,
+				pf_last_name,
+				pf_email,
+				pf_password,
+				wrap.User.A_id,
+			)
+			if err != nil {
+				wrap.MsgError(err.Error())
+				return
+			}
+		} else {
+			// Update without password if not set
+			_, err := wrap.DB.Exec(
+				`UPDATE users SET
+					first_name = ?,
+					last_name = ?,
+					email = ?
+				WHERE
+					id = ?
+				;`,
+				pf_first_name,
+				pf_last_name,
+				pf_email,
+				wrap.User.A_id,
+			)
+			if err != nil {
+				wrap.MsgError(err.Error())
+				return
+			}
+		}
+
+		// Reload current page
+		wrap.Write(`window.location.reload(false);`)
+	})
+}

+ 42 - 20
modules/module_settings.go

@@ -9,6 +9,7 @@ import (
 	"golang-fave/consts"
 	"golang-fave/engine/builder"
 	"golang-fave/engine/wrapper"
+	"golang-fave/utils"
 )
 
 func (this *Modules) RegisterModule_Settings() *Module {
@@ -21,6 +22,7 @@ func (this *Modules) RegisterModule_Settings() *Module {
 		Icon:   assets.SysSvgIconGear,
 		Sub: &[]MISub{
 			{Mount: "default", Name: "Robots.txt", Show: true, Icon: assets.SysSvgIconBug},
+			{Mount: "pagination", Name: "Pagination", Show: true, Icon: assets.SysSvgIconList},
 		},
 	}, nil, func(wrap *wrapper.Wrapper) (string, string, string) {
 		content := ""
@@ -43,7 +45,7 @@ func (this *Modules) RegisterModule_Settings() *Module {
 				{
 					Kind: builder.DFKText,
 					CallBack: func(field *builder.DataFormField) string {
-						return `<div class="form-group"><div class="row"><div class="col-12"><textarea class="form-control autosize" id="lbl_content" name="content" placeholder="" autocomplete="off">` + html.EscapeString(string(fcont)) + `</textarea></div></div></div>`
+						return `<div class="form-group last"><div class="row"><div class="col-12"><textarea class="form-control autosize" id="lbl_content" name="content" placeholder="" autocomplete="off">` + html.EscapeString(string(fcont)) + `</textarea></div></div></div>`
 					},
 				},
 				{
@@ -61,27 +63,47 @@ func (this *Modules) RegisterModule_Settings() *Module {
 			})
 
 			sidebar += `<button class="btn btn-primary btn-sidebar" id="add-edit-button">Save</button>`
-		}
-		return this.getSidebarModules(wrap), content, sidebar
-	})
-}
+		} else if wrap.CurrSubModule == "pagination" {
+			content += this.getBreadCrumbs(wrap, &[]consts.BreadCrumb{
+				{Name: "Pagination"},
+			})
 
-func (this *Modules) RegisterAction_SettingsRobotsTxt() *Action {
-	return this.newAction(AInfo{
-		WantDB:    true,
-		Mount:     "settings-robots-txt",
-		WantAdmin: true,
-	}, func(wrap *wrapper.Wrapper) {
-		pf_content := wrap.R.FormValue("content")
+			content += builder.DataForm(wrap, []builder.DataFormField{
+				{
+					Kind:  builder.DFKHidden,
+					Name:  "action",
+					Value: "settings-pagination",
+				},
+				{
+					Kind:     builder.DFKNumber,
+					Caption:  "Blog main page",
+					Name:     "blog-index",
+					Min:      "1",
+					Max:      "100",
+					Required: true,
+					Value:    utils.IntToStr((*wrap.Config).Blog.Pagination.Index),
+				},
+				{
+					Kind:     builder.DFKNumber,
+					Caption:  "Blog category page",
+					Name:     "blog-category",
+					Min:      "1",
+					Max:      "100",
+					Required: true,
+					Value:    utils.IntToStr((*wrap.Config).Blog.Pagination.Category),
+				},
+				{
+					Kind: builder.DFKMessage,
+				},
+				{
+					Kind:   builder.DFKSubmit,
+					Value:  "Save",
+					Target: "add-edit-button",
+				},
+			})
 
-		// Save robots.txt content
-		err := ioutil.WriteFile(wrap.DTemplate+string(os.PathSeparator)+"robots.txt", []byte(pf_content), 0664)
-		if err != nil {
-			wrap.MsgError(err.Error())
-			return
+			sidebar += `<button class="btn btn-primary btn-sidebar" id="add-edit-button">Save</button>`
 		}
-
-		// Reload current page
-		wrap.Write(`window.location.reload(false);`)
+		return this.getSidebarModules(wrap), content, sidebar
 	})
 }

+ 57 - 0
modules/module_settings_act_pagination.go

@@ -0,0 +1,57 @@
+package modules
+
+import (
+	"strconv"
+
+	"golang-fave/engine/wrapper"
+	"golang-fave/utils"
+)
+
+func (this *Modules) RegisterAction_SettingsPagination() *Action {
+	return this.newAction(AInfo{
+		WantDB:    true,
+		Mount:     "settings-pagination",
+		WantAdmin: true,
+	}, func(wrap *wrapper.Wrapper) {
+		pf_blog_index := wrap.R.FormValue("blog-index")
+		pf_blog_category := wrap.R.FormValue("blog-category")
+
+		if _, err := strconv.Atoi(pf_blog_index); err != nil {
+			wrap.MsgError(`Blog posts count per page on main page must be integer number`)
+			return
+		}
+		if _, err := strconv.Atoi(pf_blog_category); err != nil {
+			wrap.MsgError(`Blog posts count per page on category page must be integer number`)
+			return
+		}
+
+		pfi_blog_index := utils.StrToInt(pf_blog_index)
+		pfi_blog_category := utils.StrToInt(pf_blog_category)
+
+		// Correct some values
+		if pfi_blog_index < 0 {
+			pfi_blog_index = 1
+		}
+		if pfi_blog_index > 100 {
+			pfi_blog_index = 100
+		}
+
+		if pfi_blog_category < 0 {
+			pfi_blog_category = 1
+		}
+		if pfi_blog_category > 100 {
+			pfi_blog_category = 100
+		}
+
+		(*wrap.Config).Blog.Pagination.Index = pfi_blog_index
+		(*wrap.Config).Blog.Pagination.Category = pfi_blog_category
+
+		if err := wrap.ConfigSave(); err != nil {
+			wrap.MsgError(err.Error())
+			return
+		}
+
+		// Reload current page
+		wrap.Write(`window.location.reload(false);`)
+	})
+}

+ 28 - 0
modules/module_settings_act_robots_txt.go

@@ -0,0 +1,28 @@
+package modules
+
+import (
+	"io/ioutil"
+	"os"
+
+	"golang-fave/engine/wrapper"
+)
+
+func (this *Modules) RegisterAction_SettingsRobotsTxt() *Action {
+	return this.newAction(AInfo{
+		WantDB:    true,
+		Mount:     "settings-robots-txt",
+		WantAdmin: true,
+	}, func(wrap *wrapper.Wrapper) {
+		pf_content := wrap.R.FormValue("content")
+
+		// Save robots.txt content
+		err := ioutil.WriteFile(wrap.DTemplate+string(os.PathSeparator)+"robots.txt", []byte(pf_content), 0664)
+		if err != nil {
+			wrap.MsgError(err.Error())
+			return
+		}
+
+		// Reload current page
+		wrap.Write(`window.location.reload(false);`)
+	})
+}

+ 61 - 199
modules/module_users.go

@@ -30,63 +30,74 @@ func (this *Modules) RegisterModule_Users() *Module {
 			content += this.getBreadCrumbs(wrap, &[]consts.BreadCrumb{
 				{Name: "List of users"},
 			})
-			content += builder.DataTable(wrap, "users", "id", "DESC", &[]builder.DataTableRow{
-				{
-					DBField: "id",
-				},
-				{
-					DBField:     "email",
-					NameInTable: "Email / Name",
-					CallBack: func(values *[]string) string {
-						email := `<a href="/cp/` + wrap.CurrModule + `/modify/` + (*values)[0] + `/">` + html.EscapeString((*values)[1]) + `</a>`
-						name := html.EscapeString((*values)[2])
-						if name != "" && (*values)[3] != "" {
-							name += ` ` + (*values)[3]
-						}
-						if name != "" {
-							name = `<div><small>` + name + `</small></div>`
-						}
-						return `<div>` + email + `</div>` + name
+			content += builder.DataTable(
+				wrap,
+				"users",
+				"id",
+				"DESC",
+				&[]builder.DataTableRow{
+					{
+						DBField: "id",
 					},
-				},
-				{
-					DBField: "first_name",
-				},
-				{
-					DBField: "last_name",
-				},
-				{
-					DBField:     "active",
-					NameInTable: "Active",
-					Classes:     "d-none d-sm-table-cell",
-					CallBack: func(values *[]string) string {
-						return builder.CheckBox(utils.StrToInt((*values)[4]))
+					{
+						DBField:     "email",
+						NameInTable: "Email / Name",
+						CallBack: func(values *[]string) string {
+							email := `<a href="/cp/` + wrap.CurrModule + `/modify/` + (*values)[0] + `/">` + html.EscapeString((*values)[1]) + `</a>`
+							name := html.EscapeString((*values)[2])
+							if name != "" && (*values)[3] != "" {
+								name += ` ` + (*values)[3]
+							}
+							if name != "" {
+								name = `<div><small>` + name + `</small></div>`
+							}
+							return `<div>` + email + `</div>` + name
+						},
 					},
-				},
-				{
-					DBField:     "admin",
-					NameInTable: "Admin",
-					Classes:     "d-none d-md-table-cell",
-					CallBack: func(values *[]string) string {
-						return builder.CheckBox(utils.StrToInt((*values)[5]))
+					{
+						DBField: "first_name",
 					},
-				},
-			}, func(values *[]string) string {
-				return builder.DataTableAction(&[]builder.DataTableActionRow{
 					{
-						Icon: assets.SysSvgIconEdit,
-						Href: "/cp/" + wrap.CurrModule + "/modify/" + (*values)[0] + "/",
-						Hint: "Edit",
+						DBField: "last_name",
 					},
 					{
-						Icon: assets.SysSvgIconRemove,
-						Href: "javascript:fave.ActionDataTableDelete(this,'users-delete','" +
-							(*values)[0] + "','Are you sure want to delete user?');",
-						Hint:    "Delete",
-						Classes: "delete",
+						DBField:     "active",
+						NameInTable: "Active",
+						Classes:     "d-none d-sm-table-cell",
+						CallBack: func(values *[]string) string {
+							return builder.CheckBox(utils.StrToInt((*values)[4]))
+						},
 					},
-				})
-			}, "/cp/"+wrap.CurrModule+"/")
+					{
+						DBField:     "admin",
+						NameInTable: "Admin",
+						Classes:     "d-none d-md-table-cell",
+						CallBack: func(values *[]string) string {
+							return builder.CheckBox(utils.StrToInt((*values)[5]))
+						},
+					},
+				},
+				func(values *[]string) string {
+					return builder.DataTableAction(&[]builder.DataTableActionRow{
+						{
+							Icon: assets.SysSvgIconEdit,
+							Href: "/cp/" + wrap.CurrModule + "/modify/" + (*values)[0] + "/",
+							Hint: "Edit",
+						},
+						{
+							Icon: assets.SysSvgIconRemove,
+							Href: "javascript:fave.ActionDataTableDelete(this,'users-delete','" +
+								(*values)[0] + "','Are you sure want to delete user?');",
+							Hint:    "Delete",
+							Classes: "delete",
+						},
+					})
+				},
+				"/cp/"+wrap.CurrModule+"/",
+				nil,
+				nil,
+				true,
+			)
 		} else if wrap.CurrSubModule == "add" || wrap.CurrSubModule == "modify" {
 			if wrap.CurrSubModule == "add" {
 				content += this.getBreadCrumbs(wrap, &[]consts.BreadCrumb{
@@ -221,152 +232,3 @@ func (this *Modules) RegisterModule_Users() *Module {
 		return this.getSidebarModules(wrap), content, sidebar
 	})
 }
-
-func (this *Modules) RegisterAction_UsersModify() *Action {
-	return this.newAction(AInfo{
-		WantDB:    true,
-		Mount:     "users-modify",
-		WantAdmin: true,
-	}, func(wrap *wrapper.Wrapper) {
-		pf_id := wrap.R.FormValue("id")
-		pf_first_name := wrap.R.FormValue("first_name")
-		pf_last_name := wrap.R.FormValue("last_name")
-		pf_email := wrap.R.FormValue("email")
-		pf_password := wrap.R.FormValue("password")
-		pf_admin := wrap.R.FormValue("admin")
-		pf_active := wrap.R.FormValue("active")
-
-		if pf_admin == "" {
-			pf_admin = "0"
-		}
-
-		if pf_active == "" {
-			pf_active = "0"
-		}
-
-		if !utils.IsNumeric(pf_id) {
-			wrap.MsgError(`Inner system error`)
-			return
-		}
-
-		if pf_email == "" {
-			wrap.MsgError(`Please specify user email`)
-			return
-		}
-
-		if !utils.IsValidEmail(pf_email) {
-			wrap.MsgError(`Please specify correct user email`)
-			return
-		}
-
-		// First user always super admin
-		// Rewrite active and admin status
-		if pf_id == "1" {
-			pf_admin = "1"
-			pf_active = "1"
-		}
-
-		if pf_id == "0" {
-			// Add new user
-			if pf_password == "" {
-				wrap.MsgError(`Please specify user password`)
-				return
-			}
-			_, err := wrap.DB.Query(
-				`INSERT INTO users SET
-					first_name = ?,
-					last_name = ?,
-					email = ?,
-					password = MD5(?),
-					admin = ?,
-					active = ?
-				;`,
-				pf_first_name,
-				pf_last_name,
-				pf_email,
-				pf_password,
-				pf_admin,
-				pf_active,
-			)
-			if err != nil {
-				wrap.MsgError(err.Error())
-				return
-			}
-			wrap.Write(`window.location='/cp/users/';`)
-		} else {
-			// Update user
-			if pf_password == "" {
-				_, err := wrap.DB.Query(
-					`UPDATE users SET
-						first_name = ?,
-						last_name = ?,
-						email = ?,
-						admin = ?,
-						active = ?
-					WHERE
-						id = ?
-					;`,
-					pf_first_name,
-					pf_last_name,
-					pf_email,
-					pf_admin,
-					pf_active,
-					utils.StrToInt(pf_id),
-				)
-				if err != nil {
-					wrap.MsgError(err.Error())
-					return
-				}
-			} else {
-				_, err := wrap.DB.Query(
-					`UPDATE users SET
-						first_name = ?,
-						last_name = ?,
-						email = ?,
-						password = MD5(?)
-					WHERE
-						id = ?
-					;`,
-					pf_first_name,
-					pf_last_name,
-					pf_email,
-					pf_password,
-					utils.StrToInt(pf_id),
-				)
-				if err != nil {
-					wrap.MsgError(err.Error())
-					return
-				}
-			}
-			wrap.Write(`window.location='/cp/users/modify/` + pf_id + `/';`)
-		}
-	})
-}
-
-func (this *Modules) RegisterAction_UsersDelete() *Action {
-	return this.newAction(AInfo{
-		WantDB:    true,
-		Mount:     "users-delete",
-		WantAdmin: true,
-	}, func(wrap *wrapper.Wrapper) {
-		pf_id := wrap.R.FormValue("id")
-
-		if !utils.IsNumeric(pf_id) {
-			wrap.MsgError(`Inner system error`)
-			return
-		}
-
-		// Delete user
-		_, err := wrap.DB.Query(
-			`DELETE FROM users WHERE id = ? and id <> 1;`,
-			utils.StrToInt(pf_id),
-		)
-		if err != nil {
-			wrap.MsgError(err.Error())
-			return
-		}
-
-		// Reload current page
-		wrap.Write(`window.location.reload(false);`)
-	})
-}

+ 60 - 0
modules/module_users_act_delete.go

@@ -0,0 +1,60 @@
+package modules
+
+import (
+	"golang-fave/engine/wrapper"
+	"golang-fave/utils"
+)
+
+func (this *Modules) RegisterAction_UsersDelete() *Action {
+	return this.newAction(AInfo{
+		WantDB:    true,
+		Mount:     "users-delete",
+		WantAdmin: true,
+	}, func(wrap *wrapper.Wrapper) {
+		pf_id := wrap.R.FormValue("id")
+
+		if !utils.IsNumeric(pf_id) {
+			wrap.MsgError(`Inner system error`)
+			return
+		}
+
+		err := wrap.DB.Transaction(func(tx *wrapper.Tx) error {
+			// Block rows
+			if _, err := tx.Exec("SELECT id FROM blog_cats WHERE user = ? FOR UPDATE;", pf_id); err != nil {
+				return err
+			}
+			if _, err := tx.Exec("SELECT id FROM blog_posts WHERE user = ? FOR UPDATE;", pf_id); err != nil {
+				return err
+			}
+			if _, err := tx.Exec("SELECT id FROM pages WHERE user = ? FOR UPDATE;", pf_id); err != nil {
+				return err
+			}
+			if _, err := tx.Exec("SELECT id FROM users WHERE id = ? and id > 1 FOR UPDATE;", pf_id); err != nil {
+				return err
+			}
+
+			// Process
+			if _, err := tx.Exec("UPDATE blog_cats SET user = 1 WHERE user = ?;", pf_id); err != nil {
+				return err
+			}
+			if _, err := tx.Exec("UPDATE blog_posts SET user = 1 WHERE user = ?;", pf_id); err != nil {
+				return err
+			}
+			if _, err := tx.Exec("UPDATE pages SET user = 1 WHERE user = ?;", pf_id); err != nil {
+				return err
+			}
+			if _, err := tx.Exec("DELETE FROM users WHERE id = ? and id > 1;", pf_id); err != nil {
+				return err
+			}
+			return nil
+		})
+
+		if err != nil {
+			wrap.MsgError(err.Error())
+			return
+		}
+
+		// Reload current page
+		wrap.Write(`window.location.reload(false);`)
+	})
+}

+ 127 - 0
modules/module_users_act_modify.go

@@ -0,0 +1,127 @@
+package modules
+
+import (
+	"golang-fave/engine/wrapper"
+	"golang-fave/utils"
+)
+
+func (this *Modules) RegisterAction_UsersModify() *Action {
+	return this.newAction(AInfo{
+		WantDB:    true,
+		Mount:     "users-modify",
+		WantAdmin: true,
+	}, func(wrap *wrapper.Wrapper) {
+		pf_id := wrap.R.FormValue("id")
+		pf_first_name := wrap.R.FormValue("first_name")
+		pf_last_name := wrap.R.FormValue("last_name")
+		pf_email := wrap.R.FormValue("email")
+		pf_password := wrap.R.FormValue("password")
+		pf_admin := wrap.R.FormValue("admin")
+		pf_active := wrap.R.FormValue("active")
+
+		if pf_admin == "" {
+			pf_admin = "0"
+		}
+
+		if pf_active == "" {
+			pf_active = "0"
+		}
+
+		if !utils.IsNumeric(pf_id) {
+			wrap.MsgError(`Inner system error`)
+			return
+		}
+
+		if pf_email == "" {
+			wrap.MsgError(`Please specify user email`)
+			return
+		}
+
+		if !utils.IsValidEmail(pf_email) {
+			wrap.MsgError(`Please specify correct user email`)
+			return
+		}
+
+		// First user always super admin
+		// Rewrite active and admin status
+		if pf_id == "1" {
+			pf_admin = "1"
+			pf_active = "1"
+		}
+
+		if pf_id == "0" {
+			// Add new user
+			if pf_password == "" {
+				wrap.MsgError(`Please specify user password`)
+				return
+			}
+			_, err := wrap.DB.Exec(
+				`INSERT INTO users SET
+					first_name = ?,
+					last_name = ?,
+					email = ?,
+					password = MD5(?),
+					admin = ?,
+					active = ?
+				;`,
+				pf_first_name,
+				pf_last_name,
+				pf_email,
+				pf_password,
+				pf_admin,
+				pf_active,
+			)
+			if err != nil {
+				wrap.MsgError(err.Error())
+				return
+			}
+			wrap.Write(`window.location='/cp/users/';`)
+		} else {
+			// Update user
+			if pf_password == "" {
+				_, err := wrap.DB.Exec(
+					`UPDATE users SET
+						first_name = ?,
+						last_name = ?,
+						email = ?,
+						admin = ?,
+						active = ?
+					WHERE
+						id = ?
+					;`,
+					pf_first_name,
+					pf_last_name,
+					pf_email,
+					pf_admin,
+					pf_active,
+					utils.StrToInt(pf_id),
+				)
+				if err != nil {
+					wrap.MsgError(err.Error())
+					return
+				}
+			} else {
+				_, err := wrap.DB.Exec(
+					`UPDATE users SET
+						first_name = ?,
+						last_name = ?,
+						email = ?,
+						password = MD5(?)
+					WHERE
+						id = ?
+					;`,
+					pf_first_name,
+					pf_last_name,
+					pf_email,
+					pf_password,
+					utils.StrToInt(pf_id),
+				)
+				if err != nil {
+					wrap.MsgError(err.Error())
+					return
+				}
+			}
+			wrap.Write(`window.location='/cp/users/modify/` + pf_id + `/';`)
+		}
+	})
+}

+ 19 - 16
modules/modules.go

@@ -19,6 +19,7 @@ type MISub struct {
 	Name  string
 	Icon  string
 	Show  bool
+	Sep   bool
 }
 
 type MInfo struct {
@@ -143,21 +144,25 @@ func (this *Modules) getSidebarModuleSubMenu(wrap *wrapper.Wrapper, mod *MInfo)
 	if mod.Sub != nil {
 		for _, item := range *mod.Sub {
 			if item.Show {
-				class := ""
-				if (item.Mount == "default" && len(wrap.UrlArgs) <= 1) || (len(wrap.UrlArgs) >= 2 && item.Mount == wrap.UrlArgs[1]) {
-					class = " active"
-				}
-				icon := item.Icon
-				if icon == "" {
-					icon = assets.SysSvgIconGear
-				}
-				href := "/cp/" + mod.Mount + "/" + item.Mount + "/"
-				if mod.Mount == "index" && item.Mount == "default" {
-					href = "/cp/"
-				} else if item.Mount == "default" {
-					href = "/cp/" + mod.Mount + "/"
+				if !item.Sep {
+					class := ""
+					if (item.Mount == "default" && len(wrap.UrlArgs) <= 1) || (len(wrap.UrlArgs) >= 2 && item.Mount == wrap.UrlArgs[1]) {
+						class = " active"
+					}
+					icon := item.Icon
+					if icon == "" {
+						icon = assets.SysSvgIconGear
+					}
+					href := "/cp/" + mod.Mount + "/" + item.Mount + "/"
+					if mod.Mount == "index" && item.Mount == "default" {
+						href = "/cp/"
+					} else if item.Mount == "default" {
+						href = "/cp/" + mod.Mount + "/"
+					}
+					html += `<li class="nav-item` + class + `"><a class="nav-link" href="` + href + `">` + icon + item.Name + `</a></li>`
+				} else {
+					html += `<li class="nav-separator"></li>`
 				}
-				html += `<li class="nav-item` + class + `"><a class="nav-link" href="` + href + `">` + icon + item.Name + `</a></li>`
 			}
 		}
 		if html != "" {
@@ -265,7 +270,6 @@ func (this *Modules) XXXActionFire(wrap *wrapper.Wrapper) bool {
 							wrap.MsgError(err.Error())
 							return true
 						}
-						defer wrap.DB.Close()
 					}
 					if act.Info.WantUser || act.Info.WantAdmin {
 						if !wrap.LoadSessionUser() {
@@ -308,7 +312,6 @@ func (this *Modules) XXXFrontEnd(wrap *wrapper.Wrapper) bool {
 					utils.SystemErrorPageEngine(wrap.W, err)
 					return true
 				}
-				defer wrap.DB.Close()
 			}
 			mod.Front(wrap)
 			return true

+ 5 - 0
package.json

@@ -0,0 +1,5 @@
+{
+  "devDependencies": {
+    "cypress": "^3.2.0"
+  }
+}

+ 0 - 3
run.mac.sh

@@ -1,3 +0,0 @@
-#!/bin/bash
-
-bin/fave.darwin-amd64 -host 0.0.0.0 -port 8080 -dir ./hosts

+ 0 - 3
run.unix.sh

@@ -1,3 +0,0 @@
-#!/bin/bash
-
-bin/fave.linux-amd64 -host 0.0.0.0 -port 8080 -dir ./hosts

+ 0 - 2
run.windows.bat

@@ -1,2 +0,0 @@
-bin/fave.windows-amd64.exe -host 0.0.0.0 -port 8080 -dir hosts
-TIMEOUT /T 5

+ 25 - 18
session.go

@@ -11,29 +11,36 @@ import (
 	"github.com/vladimirok5959/golang-server-sessions/session"
 )
 
+func session_clean_do(www_dir string, stop chan bool) {
+	files, err := ioutil.ReadDir(www_dir)
+	if err == nil {
+		for _, file := range files {
+			select {
+			case <-stop:
+				break
+			default:
+				tmpdir := www_dir + string(os.PathSeparator) + file.Name() + string(os.PathSeparator) + "tmp"
+				if utils.IsDirExists(tmpdir) {
+					session.Clean(tmpdir)
+				}
+			}
+		}
+	}
+}
+
 func session_clean_start(www_dir string) (chan bool, chan bool) {
 	ch := make(chan bool)
 	stop := make(chan bool)
+
+	// Cleanup at start
+	session_clean_do(www_dir, stop)
+
 	go func() {
 		for {
-			// Destroy old session files on each host
-			// Every one hour
 			select {
 			case <-time.After(1 * time.Hour):
-				files, err := ioutil.ReadDir(www_dir)
-				if err == nil {
-					for _, file := range files {
-						select {
-						case <-stop:
-							break
-						default:
-							tmpdir := www_dir + string(os.PathSeparator) + file.Name() + string(os.PathSeparator) + "tmp"
-							if utils.IsDirExists(tmpdir) {
-								session.Clean(tmpdir)
-							}
-						}
-					}
-				}
+				// Cleanup every one hour
+				session_clean_do(www_dir, stop)
 			case <-ch:
 				ch <- true
 				return
@@ -50,8 +57,8 @@ func session_clean_stop(ch, stop chan bool) {
 		case ch <- true:
 			<-ch
 			return
-		case <-time.After(10 * time.Second):
-			fmt.Println("Session cleaner error: force exit by timeout after 10 seconds")
+		case <-time.After(8 * time.Second):
+			fmt.Println("Session cleaner error: force exit by timeout after 8 seconds")
 			return
 		}
 	}

+ 61 - 0
support/schema.sql

@@ -0,0 +1,61 @@
+CREATE TABLE `blog_cats` (
+	`id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'AI',
+	`user` int(11) NOT NULL COMMENT 'User id',
+	`name` varchar(255) NOT NULL COMMENT 'Category name',
+	`alias` varchar(255) NOT NULL COMMENT 'Category alias',
+	`lft` int(11) NOT NULL COMMENT 'For nested set model',
+	`rgt` int(11) NOT NULL COMMENT 'For nested set model',
+	PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+ALTER TABLE `blog_cats` ADD UNIQUE KEY `alias` (`alias`);
+ALTER TABLE `blog_cats` ADD KEY `lft` (`lft`), ADD KEY `rgt` (`rgt`);
+
+CREATE TABLE `blog_cat_post_rel` (
+	`id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'AI',
+	`post_id` int(11) NOT NULL COMMENT 'Post id',
+	`category_id` int(11) NOT NULL COMMENT 'Category id',
+	PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+ALTER TABLE `blog_cat_post_rel` ADD KEY `post_id` (`post_id`), ADD KEY `category_id` (`category_id`);
+ALTER TABLE `blog_cat_post_rel` ADD UNIQUE KEY `post_category` (`post_id`,`category_id`) USING BTREE;
+
+CREATE TABLE `blog_posts` (
+	`id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'AI',
+	`user` int(11) NOT NULL COMMENT 'User id',
+	`name` varchar(255) NOT NULL COMMENT 'Post name',
+	`alias` varchar(255) NOT NULL COMMENT 'Post alias',
+	`briefly` text NOT NULL COMMENT 'Post brief content',
+	`content` text NOT NULL COMMENT 'Post content',
+	`datetime` datetime NOT NULL COMMENT 'Creation date/time',
+	`active` int(1) NOT NULL COMMENT 'Is active post or not',
+	PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+ALTER TABLE `blog_posts` ADD UNIQUE KEY `alias` (`alias`);
+
+CREATE TABLE `pages` (
+	`id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'AI',
+	`user` int(11) NOT NULL COMMENT 'User id',
+	`name` varchar(255) NOT NULL COMMENT 'Page name',
+	`alias` varchar(255) NOT NULL COMMENT 'Page url part',
+	`content` text NOT NULL COMMENT 'Page content',
+	`meta_title` varchar(255) NOT NULL DEFAULT '' COMMENT 'Page meta title',
+	`meta_keywords` varchar(255) NOT NULL DEFAULT '' COMMENT 'Page meta keywords',
+	`meta_description` varchar(510) NOT NULL DEFAULT '' COMMENT 'Page meta description',
+	`datetime` datetime NOT NULL COMMENT 'Creation date/time',
+	`active` int(1) NOT NULL COMMENT 'Is active page or not',
+	PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+ALTER TABLE `pages` ADD UNIQUE KEY `alias` (`alias`);
+ALTER TABLE `pages` ADD KEY `alias_active` (`alias`,`active`) USING BTREE;
+
+CREATE TABLE `users` (
+	`id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'AI',
+	`first_name` varchar(64) NOT NULL DEFAULT '' COMMENT 'User first name',
+	`last_name` varchar(64) NOT NULL DEFAULT '' COMMENT 'User last name',
+	`email` varchar(64) NOT NULL COMMENT 'User email',
+	`password` varchar(32) NOT NULL COMMENT 'User password (MD5)',
+	`admin` int(1) NOT NULL COMMENT 'Is admin user or not',
+	`active` int(1) NOT NULL COMMENT 'Is active user or not',
+	PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+ALTER TABLE `users` ADD UNIQUE KEY `email` (`email`);

+ 0 - 0
testdata/some-file.txt → support/some-file.txt


BIN
testdata/screenshots-1.gif


+ 10 - 0
utils/mysql_struct_blog_category.go

@@ -0,0 +1,10 @@
+package utils
+
+type MySql_blog_category struct {
+	A_id    int
+	A_user  int
+	A_name  string
+	A_alias string
+	A_lft   int
+	A_rgt   int
+}

+ 12 - 0
utils/mysql_struct_blog_post.go

@@ -0,0 +1,12 @@
+package utils
+
+type MySql_blog_post struct {
+	A_id       int
+	A_user     int
+	A_name     string
+	A_alias    string
+	A_briefly  string
+	A_content  string
+	A_datetime int
+	A_active   int
+}

+ 0 - 0
utils/mysql_struct_pages.go → utils/mysql_struct_page.go


+ 0 - 0
utils/mysql_struct_users.go → utils/mysql_struct_user.go


+ 94 - 0
utils/utils.go

@@ -57,11 +57,28 @@ func IsValidEmail(email string) bool {
 }
 
 func IsValidAlias(alias string) bool {
+	// Control panel
+	regexpeCP := regexp.MustCompile(`^\/cp\/`)
+	if alias == "/cp" || regexpeCP.MatchString(alias) {
+		return false
+	}
+
+	// Blog module
+	regexpeBlog := regexp.MustCompile(`^\/blog\/`)
+	if alias == "/blog" || regexpeBlog.MatchString(alias) {
+		return false
+	}
+
 	regexpeSlash := regexp.MustCompile(`[\/]{2,}`)
 	regexpeChars := regexp.MustCompile(`^\/([a-zA-Z0-9\/\-_\.]+)\/?$`)
 	return (!regexpeSlash.MatchString(alias) && regexpeChars.MatchString(alias)) || alias == "/"
 }
 
+func IsValidSingleAlias(alias string) bool {
+	regexpeChars := regexp.MustCompile(`^([a-zA-Z0-9\-_]{1,})$`)
+	return regexpeChars.MatchString(alias)
+}
+
 func FixPath(path string) string {
 	newPath := strings.TrimSpace(path)
 	if len(newPath) <= 0 {
@@ -218,6 +235,10 @@ func IntToStr(num int) string {
 	return fmt.Sprintf("%d", num)
 }
 
+func Int64ToStr(num int64) string {
+	return fmt.Sprintf("%d", num)
+}
+
 func StrToInt(str string) int {
 	num, err := strconv.Atoi(str)
 	if err == nil {
@@ -261,6 +282,17 @@ func GenerateAlias(str string) string {
 	return alias
 }
 
+func GenerateSingleAlias(str string) string {
+	alias := GenerateAlias(str)
+	if len(alias) > 1 && alias[0] == '/' {
+		alias = alias[1:]
+	}
+	if len(alias) > 1 && alias[len(alias)-1] == '/' {
+		alias = alias[:len(alias)-1]
+	}
+	return alias
+}
+
 func UnixTimestampToMySqlDateTime(sec int64) string {
 	return time.Unix(sec, 0).Format("2006-01-02 15:04:05")
 }
@@ -285,3 +317,65 @@ func JavaScriptVarValue(str string) string {
 		-1,
 	)
 }
+
+func InArrayInt(slice []int, value int) bool {
+	for _, item := range slice {
+		if item == value {
+			return true
+		}
+	}
+	return false
+}
+
+func InArrayString(slice []string, value string) bool {
+	for _, item := range slice {
+		if item == value {
+			return true
+		}
+	}
+	return false
+}
+
+func GetPostArrayInt(name string, r *http.Request) []int {
+	var ids []int
+	if arr, ok := r.PostForm[name]; ok {
+		for _, el := range arr {
+			if IsNumeric(el) {
+				if !InArrayInt(ids, StrToInt(el)) {
+					ids = append(ids, StrToInt(el))
+				}
+			}
+		}
+	}
+	return ids
+}
+
+func GetPostArrayString(name string, r *http.Request) []string {
+	var ids []string
+	if arr, ok := r.PostForm[name]; ok {
+		for _, el := range arr {
+			if !InArrayString(ids, el) {
+				ids = append(ids, el)
+			}
+		}
+	}
+	return ids
+}
+
+func ArrayOfIntToArrayOfString(arr []int) []string {
+	var res []string
+	for _, el := range arr {
+		res = append(res, IntToStr(el))
+	}
+	return res
+}
+
+func ArrayOfStringToArrayOfInt(arr []string) []int {
+	var res []int
+	for _, el := range arr {
+		if IsNumeric(el) {
+			res = append(res, StrToInt(el))
+		}
+	}
+	return res
+}

+ 147 - 16
utils/utils_test.go

@@ -2,6 +2,10 @@ package utils
 
 import (
 	"errors"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"strings"
 	"testing"
 	"time"
 
@@ -10,26 +14,26 @@ import (
 
 func Expect(t *testing.T, actual, expect interface{}) {
 	if actual != expect {
-		t.Fatalf("\033[0;33mExpected \033[0;32m`%v`\033[0;33m but got \033[0;31m`%v`\033[0m",
-			expect, actual)
+		t.Fatalf("\033[0;33mExpected \033[0;32m`(%T) %v`\033[0;33m but got \033[0;31m`(%T) %v`\033[0m",
+			expect, expect, actual, actual)
 	}
 }
 
 func TestIsFileExists(t *testing.T) {
-	Expect(t, IsFileExists("./../testdata/some-file.txt"), true)
-	Expect(t, IsFileExists("./../testdata/no-existed-file"), false)
+	Expect(t, IsFileExists("./../support/some-file.txt"), true)
+	Expect(t, IsFileExists("./../support/no-existed-file"), false)
 }
 
 func TestIsDir(t *testing.T) {
-	Expect(t, IsDir("./../testdata"), true)
-	Expect(t, IsDir("./../testdata/some-file.txt"), false)
-	Expect(t, IsDir("./../testdata/no-existed-dir"), false)
+	Expect(t, IsDir("./../support"), true)
+	Expect(t, IsDir("./../support/some-file.txt"), false)
+	Expect(t, IsDir("./../support/no-existed-dir"), false)
 }
 
 func TestIsDirExists(t *testing.T) {
-	Expect(t, IsDirExists("./../testdata"), true)
-	Expect(t, IsDirExists("./../testdata/some-file.txt"), false)
-	Expect(t, IsDirExists("./../testdata/no-existed-dir"), false)
+	Expect(t, IsDirExists("./../support"), true)
+	Expect(t, IsDirExists("./../support/some-file.txt"), false)
+	Expect(t, IsDirExists("./../support/no-existed-dir"), false)
 }
 
 func TestIsNumeric(t *testing.T) {
@@ -52,6 +56,29 @@ func TestIsValidAlias(t *testing.T) {
 	Expect(t, IsValidAlias(""), false)
 	Expect(t, IsValidAlias("some-page"), false)
 	Expect(t, IsValidAlias("/some page/"), false)
+
+	Expect(t, IsValidAlias("/cp"), false)
+	Expect(t, IsValidAlias("/cp/"), false)
+	Expect(t, IsValidAlias("/cp/some"), false)
+	Expect(t, IsValidAlias("/cp-1"), true)
+	Expect(t, IsValidAlias("/cp-some"), true)
+
+	Expect(t, IsValidAlias("/blog"), false)
+	Expect(t, IsValidAlias("/blog/"), false)
+	Expect(t, IsValidAlias("/blog/some"), false)
+	Expect(t, IsValidAlias("/blog-1"), true)
+	Expect(t, IsValidAlias("/blog-some"), true)
+}
+
+func TestIsValidSingleAlias(t *testing.T) {
+	Expect(t, IsValidSingleAlias("some-category"), true)
+	Expect(t, IsValidSingleAlias("some-category-12345"), true)
+	Expect(t, IsValidSingleAlias("some_category_12345"), true)
+	Expect(t, IsValidSingleAlias(""), false)
+	Expect(t, IsValidSingleAlias("/"), false)
+	Expect(t, IsValidSingleAlias("/some-category/"), false)
+	Expect(t, IsValidSingleAlias("some-category.html"), false)
+	Expect(t, IsValidSingleAlias("some category"), false)
 }
 
 func TestFixPath(t *testing.T) {
@@ -117,19 +144,57 @@ func TestGetCurrentUnixTimestamp(t *testing.T) {
 }
 
 func TestSystemRenderTemplate(t *testing.T) {
-	//
+	request, err := http.NewRequest("GET", "/", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	recorder := httptest.NewRecorder()
+	http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		SystemRenderTemplate(w, []byte(`ok`), nil)
+	}).ServeHTTP(recorder, request)
+	Expect(t, recorder.Code, 200)
+	Expect(t, recorder.Body.String(), `ok`)
 }
 
 func TestSystemErrorPageEngine(t *testing.T) {
-	//
+	request, err := http.NewRequest("GET", "/", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	recorder := httptest.NewRecorder()
+	http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		SystemErrorPageEngine(w, errors.New("Test error"))
+	}).ServeHTTP(recorder, request)
+	Expect(t, recorder.Code, http.StatusInternalServerError)
+	Expect(t, strings.Contains(recorder.Body.String(), "Engine Error"), true)
+	Expect(t, strings.Contains(recorder.Body.String(), "Test error"), true)
 }
 
 func TestSystemErrorPageTemplate(t *testing.T) {
-	//
+	request, err := http.NewRequest("GET", "/", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	recorder := httptest.NewRecorder()
+	http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		SystemErrorPageTemplate(w, errors.New("Test error"))
+	}).ServeHTTP(recorder, request)
+	Expect(t, recorder.Code, http.StatusInternalServerError)
+	Expect(t, strings.Contains(recorder.Body.String(), "Template Error"), true)
+	Expect(t, strings.Contains(recorder.Body.String(), "Test error"), true)
 }
 
 func TestSystemErrorPage404(t *testing.T) {
-	//
+	request, err := http.NewRequest("GET", "/", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	recorder := httptest.NewRecorder()
+	http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		SystemErrorPage404(w)
+	}).ServeHTTP(recorder, request)
+	Expect(t, recorder.Code, http.StatusNotFound)
+	Expect(t, strings.Contains(recorder.Body.String(), "404 Not Found"), true)
 }
 
 func TestUrlToArray(t *testing.T) {
@@ -158,6 +223,10 @@ func TestIntToStr(t *testing.T) {
 	Expect(t, IntToStr(2000), "2000")
 }
 
+func TestInt64ToStr(t *testing.T) {
+	Expect(t, Int64ToStr(2000), "2000")
+}
+
 func TestStrToInt(t *testing.T) {
 	Expect(t, StrToInt("2000"), 2000)
 	Expect(t, StrToInt("string"), 0)
@@ -167,8 +236,16 @@ func TestGenerateAlias(t *testing.T) {
 	Expect(t, GenerateAlias(""), "")
 	Expect(t, GenerateAlias("Some page name"), "/some-page-name/")
 	Expect(t, GenerateAlias("Some page name 2"), "/some-page-name-2/")
-	Expect(t, GenerateAlias("Какая то страница"), "/kakaya-to-stranica/")
-	Expect(t, GenerateAlias("Какая то страница 2"), "/kakaya-to-stranica-2/")
+	Expect(t, GenerateAlias("Какая-то страница"), "/kakayato-stranica/")
+	Expect(t, GenerateAlias("Какая-то страница 2"), "/kakayato-stranica-2/")
+}
+
+func TestGenerateSingleAlias(t *testing.T) {
+	Expect(t, GenerateSingleAlias(""), "")
+	Expect(t, GenerateSingleAlias("Some category name"), "some-category-name")
+	Expect(t, GenerateSingleAlias("Some category name 2"), "some-category-name-2")
+	Expect(t, GenerateSingleAlias("Какая-то категория"), "kakayato-kategoriya")
+	Expect(t, GenerateSingleAlias("Какая-то категория 2"), "kakayato-kategoriya-2")
 }
 
 func TestUnixTimestampToMySqlDateTime(t *testing.T) {
@@ -190,3 +267,57 @@ func TestJavaScriptVarValue(t *testing.T) {
 	Expect(t, JavaScriptVarValue(`It's "string"`), "It&rsquo;s &rdquo;string&rdquo;")
 	Expect(t, JavaScriptVarValue(`It is string`), "It is string")
 }
+
+func TestInArrayInt(t *testing.T) {
+	slice := []int{1, 3, 5, 9, 0}
+	Expect(t, InArrayInt(slice, 1), true)
+	Expect(t, InArrayInt(slice, 9), true)
+	Expect(t, InArrayInt(slice, 2), false)
+	Expect(t, InArrayInt(slice, 8), false)
+}
+
+func TestInArrayString(t *testing.T) {
+	slice := []string{"1", "3", "5", "9", "0"}
+	Expect(t, InArrayString(slice, "1"), true)
+	Expect(t, InArrayString(slice, "9"), true)
+	Expect(t, InArrayString(slice, "2"), false)
+	Expect(t, InArrayString(slice, "8"), false)
+}
+
+func TestGetPostArrayInt(t *testing.T) {
+	request, err := http.NewRequest("POST", "/", strings.NewReader("cats[]=1&cats[]=3&cats[]=5"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	request.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value")
+	request.ParseForm()
+	arr := GetPostArrayInt("cats[]", request)
+	Expect(t, fmt.Sprintf("%T%v", arr, arr), "[]int[1 3 5]")
+}
+
+func TestGetPostArrayString(t *testing.T) {
+	request, err := http.NewRequest("POST", "/", strings.NewReader("cats[]=1&cats[]=3&cats[]=5"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	request.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value")
+	request.ParseForm()
+	arr := GetPostArrayString("cats[]", request)
+	Expect(t, fmt.Sprintf("%T%v", arr, arr), "[]string[1 3 5]")
+}
+
+func TestArrayOfIntToArrayOfString(t *testing.T) {
+	res := ArrayOfIntToArrayOfString([]int{1, 3, 5})
+	Expect(t, len(res), 3)
+	Expect(t, res[0], "1")
+	Expect(t, res[1], "3")
+	Expect(t, res[2], "5")
+}
+
+func TestArrayOfStringToArrayOfInt(t *testing.T) {
+	res := ArrayOfStringToArrayOfInt([]string{"1", "3", "5", "abc"})
+	Expect(t, len(res), 3)
+	Expect(t, res[0], 1)
+	Expect(t, res[1], 3)
+	Expect(t, res[2], 5)
+}

+ 8 - 7
vendor/github.com/vladimirok5959/golang-server-bootstrap/bootstrap/bootstrap.go

@@ -11,22 +11,23 @@ import (
 )
 
 type customHandler func(h http.Handler) http.Handler
-type callbackBeforeAfter func(w http.ResponseWriter, r *http.Request)
+type callbackBeforeAfter func(w http.ResponseWriter, r *http.Request, o interface{})
 type callbackServer func(s *http.Server)
 
 type bootstrap struct {
 	path   string
 	before callbackBeforeAfter
 	after  callbackBeforeAfter
+	object interface{}
 }
 
-func new(path string, before callbackBeforeAfter, after callbackBeforeAfter) *bootstrap {
-	return &bootstrap{path, before, after}
+func new(path string, before callbackBeforeAfter, after callbackBeforeAfter, object interface{}) *bootstrap {
+	return &bootstrap{path, before, after, object}
 }
 
 func (this *bootstrap) handler(w http.ResponseWriter, r *http.Request) {
 	if this.before != nil {
-		this.before(w, r)
+		this.before(w, r, this.object)
 	}
 	if r.URL.Path == "/"+this.path+"/bootstrap.css" {
 		w.Header().Set("Cache-Control", "public, max-age=31536000")
@@ -50,13 +51,13 @@ func (this *bootstrap) handler(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	if this.after != nil {
-		this.after(w, r)
+		this.after(w, r, this.object)
 	}
 }
 
-func Start(h customHandler, host string, timeout time.Duration, path string, before callbackBeforeAfter, after callbackBeforeAfter, cbserv callbackServer) {
+func Start(h customHandler, host string, timeout time.Duration, path string, before callbackBeforeAfter, after callbackBeforeAfter, cbserv callbackServer, object interface{}) {
 	mux := http.NewServeMux()
-	mux.HandleFunc("/", new(path, before, after).handler)
+	mux.HandleFunc("/", new(path, before, after, object).handler)
 
 	var srv *http.Server
 	if h == nil {

+ 4 - 1
vendor/github.com/vladimirok5959/golang-server-sessions/session/bool.go

@@ -17,6 +17,9 @@ func (this *Session) GetBool(name string, def bool) bool {
 }
 
 func (this *Session) SetBool(name string, value bool) {
+	isset := this.IsSetBool(name)
 	this.v.Bool[name] = value
-	this.c = true
+	if isset || value != false {
+		this.c = true
+	}
 }

+ 1 - 3
vendor/github.com/vladimirok5959/golang-server-sessions/session/clean.go

@@ -11,11 +11,9 @@ func Clean(tmpdir string) error {
 	if err != nil {
 		return err
 	}
-	now := time.Now()
-	exp := 7 * 24 * time.Hour
 	for _, file := range files {
 		if len(file.Name()) == 40 {
-			if diff := now.Sub(file.ModTime()); diff > exp {
+			if diff := time.Now().Sub(file.ModTime()); diff > 24*time.Hour {
 				err = os.Remove(tmpdir + string(os.PathSeparator) + file.Name())
 				if err != nil {
 					return err

+ 4 - 1
vendor/github.com/vladimirok5959/golang-server-sessions/session/int.go

@@ -17,6 +17,9 @@ func (this *Session) GetInt(name string, def int) int {
 }
 
 func (this *Session) SetInt(name string, value int) {
+	isset := this.IsSetInt(name)
 	this.v.Int[name] = value
-	this.c = true
+	if isset || value != 0 {
+		this.c = true
+	}
 }

+ 20 - 2
vendor/github.com/vladimirok5959/golang-server-sessions/session/session.go

@@ -32,7 +32,8 @@ func New(w http.ResponseWriter, r *http.Request, tmpdir string) *Session {
 	if err == nil && len(cookie.Value) == 40 {
 		// Load from file
 		sess.i = cookie.Value
-		f, err := os.Open(sess.d + string(os.PathSeparator) + sess.i)
+		fname := sess.d + string(os.PathSeparator) + sess.i
+		f, err := os.Open(fname)
 		if err == nil {
 			defer f.Close()
 			dec := json.NewDecoder(f)
@@ -40,6 +41,13 @@ func New(w http.ResponseWriter, r *http.Request, tmpdir string) *Session {
 			if err == nil {
 				return &sess
 			}
+
+			// Update file last modify time if needs
+			if info, err := os.Stat(fname); err == nil {
+				if time.Now().Sub(info.ModTime()) > 30*time.Minute {
+					_ = os.Chtimes(fname, time.Now(), time.Now())
+				}
+			}
 		}
 	} else {
 		// Create new
@@ -63,7 +71,6 @@ func New(w http.ResponseWriter, r *http.Request, tmpdir string) *Session {
 		Int:    map[string]int{},
 		String: map[string]string{},
 	}
-	sess.c = true
 
 	return &sess
 }
@@ -88,3 +95,14 @@ func (this *Session) Close() bool {
 
 	return false
 }
+
+func (this *Session) Destroy() error {
+	if this.d == "" || this.i == "" {
+		return nil
+	}
+	err := os.Remove(this.d + string(os.PathSeparator) + this.i)
+	if err == nil {
+		this.c = false
+	}
+	return err
+}

+ 4 - 1
vendor/github.com/vladimirok5959/golang-server-sessions/session/string.go

@@ -17,6 +17,9 @@ func (this *Session) GetString(name string, def string) string {
 }
 
 func (this *Session) SetString(name string, value string) {
+	isset := this.IsSetString(name)
 	this.v.String[name] = value
-	this.c = true
+	if isset || value != "" {
+		this.c = true
+	}
 }

+ 2 - 2
vendor/modules.txt

@@ -1,10 +1,10 @@
 # github.com/go-sql-driver/mysql v1.4.1
 github.com/go-sql-driver/mysql
-# github.com/vladimirok5959/golang-server-bootstrap v1.0.4
+# github.com/vladimirok5959/golang-server-bootstrap v1.0.5
 github.com/vladimirok5959/golang-server-bootstrap/bootstrap
 # github.com/vladimirok5959/golang-server-resources v1.0.2
 github.com/vladimirok5959/golang-server-resources/resource
-# github.com/vladimirok5959/golang-server-sessions v1.0.1
+# github.com/vladimirok5959/golang-server-sessions v1.0.4
 github.com/vladimirok5959/golang-server-sessions/session
 # github.com/vladimirok5959/golang-server-static v1.0.0
 github.com/vladimirok5959/golang-server-static/static

Some files were not shown because too many files changed in this diff