├── .example.env ├── .github ├── FUNDING.yml └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── assets └── logo-light.svg ├── beubo.go ├── beubo_test.go ├── cmd └── beubo │ ├── .gitkeep │ └── main.go ├── database.go ├── go.mod ├── go.sum ├── pkg ├── .gitkeep ├── middleware │ ├── access.go │ ├── auth.go │ ├── main.go │ ├── plugin.go │ ├── site.go │ ├── throttle.go │ └── whitelist.go ├── plugin │ ├── helper.go │ ├── loader.go │ ├── page.go │ ├── request.go │ └── structs.go ├── routes │ ├── admin.go │ ├── auth.go │ ├── base.go │ ├── page.go │ ├── plugin.go │ ├── router.go │ ├── setting.go │ ├── site.go │ └── user.go ├── structs │ ├── README.md │ ├── config.go │ ├── page.go │ ├── page │ │ ├── component.go │ │ ├── component │ │ │ ├── button.go │ │ │ ├── checkboxfield.go │ │ │ ├── form.go │ │ │ ├── functions.go │ │ │ ├── hiddenfield.go │ │ │ ├── radiofield.go │ │ │ ├── selectfield.go │ │ │ ├── table.go │ │ │ ├── text.go │ │ │ ├── textareafield.go │ │ │ └── textfield.go │ │ ├── menu.go │ │ └── menu │ │ │ └── default.go │ ├── role.go │ ├── session.go │ ├── setting.go │ ├── site.go │ ├── theme.go │ └── user.go ├── template │ ├── page.go │ ├── render.go │ └── site.go └── utility │ ├── email.go │ ├── errors.go │ ├── requests.go │ └── strings.go ├── plugin.go ├── plugins ├── .gitignore └── README.md ├── routes.go ├── settings.go └── themes └── README.md /.example.env: -------------------------------------------------------------------------------- 1 | ASSETS_DIR= 2 | THEME= 3 | 4 | ENVIRONMENT=local 5 | 6 | # Can be mysql,sqlite3 7 | DB_DRIVER=sqlite3 8 | DB_HOST=localhost 9 | DB_NAME=beubo 10 | DB_USER=test 11 | DB_PASSWORD=test 12 | 13 | TEST_USER=test@test.com 14 | TEST_PASS=Test1234! 15 | 16 | SESSION_KEY= -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: uberswe 2 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '27 11 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'go' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .idea 3 | godirectory 4 | godirectory.db 5 | beubo.db -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Beubo 2 | Thank you for wanting to help! I want to make contributing to this project as easy and transparent as possible, whether it's: 3 | 4 | - Reporting a bug 5 | - Discussing the current state of the code 6 | - Submitting a fix 7 | - Proposing new features 8 | - Becoming a maintainer 9 | 10 | ## Developed with Github 11 | Create and discuss changes via Github. Please open an issue if you would like to report a bug or suggest new features and improvements. If you would like to contribute code please open a pull request. See [SECURITY.md](https://github.com/uberswe/beubo/blob/master/SECURITY.md) for directions on how to report security vulnerabilities. 12 | 13 | ## Any contributions you make will be under the MIT Software License 14 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 15 | 16 | ## Report bugs using Github's [issues](https://github.com/uberswe/beubo/issues) 17 | We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/uberswe/beubo/issues/new). 18 | 19 | ## Ensure your code is well formatted 20 | I use [lint](https://github.com/golang/lint) to ensure code is formatted well. 21 | 22 | ## License 23 | By contributing, you agree that your contributions will be licensed under its MIT License. 24 | 25 | ## References 26 | 27 | 28 | This document was created from [this template](https://gist.github.com/briandk/3d2e8b3ec8daf5a27a62) which was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/a9316a723f9e918afde44dea68b5f9f39b7d9b00/CONTRIBUTING.md) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Markus Tenghamn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Beubo 2 | 3 | ![Beubo logo](./assets/logo-light.svg) 4 | 5 | **Beubo is in Alpha and not recommended for production use, expect breaking changes and bugs** 6 | 7 | I created Beubo to get better at Go. To learn more and to make it easier to get going with 8 | new projects. None of the platforms or libraries in the Go ecosystem felt right for me. 9 | That's why I set out to make my own CMS/Library, Beubo. 10 | 11 | Beubo is a CMS that aims to be easy to use and written in Go. I wanted it 12 | to be as easy to use as Wordpress but with much better performance and with support 13 | for multiple websites right from the start. I try to keep the capabilities of Beubo 14 | as small as possible. I hope I can make Beubo easy to build on using plugins so that 15 | it can be used for anything and everything. 16 | 17 | Here are a few of the features I want to support: 18 | - Site management, routing based on domain 19 | - Page creation, editing, deletion 20 | - Themes 21 | - Plugins 22 | - User management with roles and permissions 23 | 24 | That's pretty much it. 25 | 26 | Simply run `go run cmd/beubo/main.go` to get started. 27 | 28 | ## Database 29 | 30 | Beubo uses [GORM](https://gorm.io/) to handle database operations. Currently I am supporting sqlite3 and mysql but other drivers may work but have not been tested. 31 | 32 | ## Installation 33 | 34 | When running Beubo for the first time an installation page will open at the specified port. The 35 | page asks for various details needed to configure your site including database details. You will 36 | need to create a database on a MariaDB server and provide details 37 | so that Beubo can connect to it. You can also use sqlite3 but I only recommend it for local development. 38 | 39 | Once the installation is complete it will no longer be available, delete the .env file to redo the 40 | installation process. To start with a fresh database simply truncate your current database and it will 41 | auto migrate and seed a fresh database. 42 | 43 | ## CLI options 44 | 45 | ``` 46 | -port=8080 Allows you to specify which port Beubo should listen on 47 | ``` 48 | 49 | ## Templating 50 | 51 | Beubo uses the go html templates to build pages. These templates use functions to render sections of 52 | content which plugins can hook into when a request is made. 53 | 54 | ## Plugins 55 | 56 | Beubo supports go plugins. Simply place your `.so` under `/plugins` and Beubo will try to load this 57 | plugin as it starts. A plugin will need to expose a `Register` method in order to run. Please see 58 | the example plugin to learn more. -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Currently all versions are supported with security updates. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Please contact me via email or a twitter DM, the contact information can be found on my Github profile https://github.com/uberswe 10 | -------------------------------------------------------------------------------- /assets/logo-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 11 | 13 | 15 | 17 | 19 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /beubo.go: -------------------------------------------------------------------------------- 1 | package beubo 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "flag" 7 | "fmt" 8 | "github.com/manifoldco/promptui" 9 | "io" 10 | "io/ioutil" 11 | "log" 12 | "net/http" 13 | "os" 14 | "path/filepath" 15 | "strings" 16 | ) 17 | 18 | var port = 3000 19 | 20 | // Init is called to start Beubo, this calls various other functions that initialises some basic settings 21 | func Init() { 22 | readCLIFlags() 23 | } 24 | 25 | // Run runs the main application 26 | func Run() { 27 | checkFiles() 28 | settingsInit() 29 | databaseInit() 30 | databaseSeed() 31 | loadPlugins() 32 | routesInit() 33 | } 34 | 35 | // readCLIFlags parses command line flags such as port number 36 | func readCLIFlags() { 37 | flag.IntVar(&port, "port", port, "The port you would like the application to listen on") 38 | flag.Parse() 39 | } 40 | 41 | // checkFiles checks if there is a theme present and will otherwise prompt to download that theme 42 | func checkFiles() { 43 | _, err := os.Stat("./themes") 44 | if os.IsNotExist(err) { 45 | if ask("There is no themes folder present, would you like to create it?") { 46 | err := os.Mkdir("./themes", 0755) 47 | if err != nil { 48 | log.Fatal(err) 49 | } 50 | } 51 | } 52 | if !hasDirectory("./themes/") { 53 | if ask("There is no theme installed, would you like to download the default theme?") { 54 | resp, err := http.Get("https://github.com/uberswe/beubo-default/archive/master.zip") 55 | if err != nil { 56 | log.Fatal(err) 57 | } 58 | defer resp.Body.Close() 59 | 60 | body, err := ioutil.ReadAll(resp.Body) 61 | if err != nil { 62 | log.Fatal(err) 63 | } 64 | 65 | zipReader, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) 66 | if err != nil { 67 | log.Fatal(err) 68 | } 69 | 70 | // Read all the files from zip archive 71 | for _, zipFile := range zipReader.File { 72 | err := extractAndWriteFile(zipFile) 73 | if err != nil { 74 | log.Println(err) 75 | continue 76 | } 77 | } 78 | } 79 | } 80 | _, err = os.Stat("./themes/install") 81 | if os.IsNotExist(err) { 82 | if ask("There is no install theme installed, would you like to download the default install theme?") { 83 | resp, err := http.Get("https://github.com/uberswe/beubo-install/archive/master.zip") 84 | if err != nil { 85 | log.Fatal(err) 86 | } 87 | defer resp.Body.Close() 88 | 89 | body, err := ioutil.ReadAll(resp.Body) 90 | if err != nil { 91 | log.Fatal(err) 92 | } 93 | 94 | zipReader, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) 95 | if err != nil { 96 | log.Fatal(err) 97 | } 98 | 99 | // Read all the files from zip archive 100 | for _, zipFile := range zipReader.File { 101 | err := extractAndWriteFile(zipFile) 102 | if err != nil { 103 | log.Println(err) 104 | continue 105 | } 106 | } 107 | } 108 | } 109 | } 110 | 111 | // ask prompts the user for an answer to a question via the command line. Returns true for yes and false for no 112 | func ask(question string) bool { 113 | prompt := promptui.Select{ 114 | Label: fmt.Sprintf("%s [Yes/No]", question), 115 | Items: []string{"Yes", "No"}, 116 | } 117 | _, result, err := prompt.Run() 118 | if err != nil { 119 | return false 120 | } 121 | if result == "Yes" { 122 | return true 123 | } 124 | return false 125 | } 126 | 127 | // hasDirectory takes a path and checks if a directory exists in the path 128 | func hasDirectory(path string) bool { 129 | files, err := ioutil.ReadDir(path) 130 | if err != nil { 131 | return false 132 | } 133 | for _, f := range files { 134 | if f.IsDir() { 135 | return true 136 | } 137 | } 138 | return false 139 | } 140 | 141 | func extractAndWriteFile(f *zip.File) error { 142 | dest := "./themes/" 143 | rc, err := f.Open() 144 | if err != nil { 145 | return err 146 | } 147 | defer func() { 148 | if err := rc.Close(); err != nil { 149 | panic(err) 150 | } 151 | }() 152 | 153 | filename := strings.Replace(f.Name, "beubo-default-master/", "default/", 1) 154 | filename = strings.Replace(filename, "beubo-install-master/", "install/", 1) 155 | 156 | path := filepath.Join(dest, filename) 157 | 158 | // Check for ZipSlip (Directory traversal) 159 | if !strings.HasPrefix(path, filepath.Clean(dest)+string(os.PathSeparator)) { 160 | return fmt.Errorf("illegal file path: %s", path) 161 | } 162 | 163 | if f.FileInfo().IsDir() { 164 | os.MkdirAll(path, f.Mode()) 165 | } else { 166 | os.MkdirAll(filepath.Dir(path), f.Mode()) 167 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) 168 | if err != nil { 169 | return err 170 | } 171 | defer func() { 172 | if err := f.Close(); err != nil { 173 | panic(err) 174 | } 175 | }() 176 | 177 | _, err = io.Copy(f, rc) 178 | if err != nil { 179 | return err 180 | } 181 | } 182 | return nil 183 | } 184 | -------------------------------------------------------------------------------- /beubo_test.go: -------------------------------------------------------------------------------- 1 | package beubo 2 | 3 | import "testing" 4 | 5 | func TestSetSetting(t *testing.T) { 6 | key := "key" 7 | value := "value" 8 | expected := key 9 | if result := setSetting(key, value); result != expected { 10 | t.Errorf("setSetting = %q, expected %q", result, expected) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /cmd/beubo/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uberswe/beubo/7b0100af941c71b906e4a49c4e0e3857a04fd098/cmd/beubo/.gitkeep -------------------------------------------------------------------------------- /cmd/beubo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/uberswe/beubo" 4 | 5 | func init() { 6 | beubo.Init() 7 | } 8 | 9 | func main() { 10 | beubo.Run() 11 | } 12 | -------------------------------------------------------------------------------- /database.go: -------------------------------------------------------------------------------- 1 | package beubo 2 | 3 | import ( 4 | "fmt" 5 | "github.com/uberswe/beubo/pkg/plugin" 6 | "github.com/uberswe/beubo/pkg/structs" 7 | "github.com/uberswe/beubo/pkg/utility" 8 | "golang.org/x/crypto/bcrypt" 9 | "gorm.io/driver/mysql" 10 | "gorm.io/driver/sqlite" 11 | "gorm.io/gorm" 12 | "gorm.io/gorm/logger" 13 | "io/ioutil" 14 | "log" 15 | "os" 16 | "time" 17 | ) 18 | 19 | var ( 20 | seedEmail = "seed@beubo.com" 21 | seedPassword = "Beubo1234!" 22 | // TODO change this to a config 23 | shouldSeed = false 24 | shouldRefreshDatabase = false 25 | // DB is used to perform database queries globally. In the future this should probably 26 | // be changed so that database.go declares methods that can be used to perform types of 27 | // queries 28 | DB *gorm.DB 29 | ) 30 | 31 | func setupDB() *gorm.DB { 32 | log.Println("Opening database") 33 | dialector := getDialector(databaseUser, databasePassword, databaseHost, databasePort, databaseName, databaseDriver) 34 | newLogger := logger.New( 35 | log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer 36 | logger.Config{ 37 | SlowThreshold: time.Second, // Slow SQL threshold 38 | LogLevel: logger.Silent, // Log level 39 | Colorful: true, 40 | }, 41 | ) 42 | config := gorm.Config{ 43 | Logger: newLogger, 44 | } 45 | if databaseDriver == "sqlite3" { 46 | config = gorm.Config{ 47 | DisableForeignKeyConstraintWhenMigrating: true, 48 | Logger: newLogger, 49 | } 50 | } 51 | db, err := gorm.Open(dialector, &config) 52 | utility.ErrorHandler(err, true) 53 | return db 54 | } 55 | 56 | func getDialector(user string, pass string, host string, port string, name string, driver string) gorm.Dialector { 57 | connectString := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8&parseTime=True&loc=Local", user, pass, host, port, name) 58 | dialector := mysql.Open(connectString) 59 | if driver == "sqlite3" { 60 | connectString = databaseName 61 | dialector = sqlite.Open(connectString) 62 | } 63 | return dialector 64 | } 65 | 66 | func databaseInit() { 67 | DB = setupDB() 68 | 69 | if shouldRefreshDatabase { 70 | type Result struct { 71 | DropQuery string 72 | } 73 | var result []Result 74 | 75 | log.Println("Dropping all database tables") 76 | 77 | if databaseDriver == "sqlite3" { 78 | DB.Raw("SELECT 'DROP TABLE IF EXISTS `' || name || '`;' as drop_query FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';").Scan(&result) 79 | } else { 80 | DB.Raw("SELECT concat('DROP TABLE IF EXISTS `', table_name, '`;') as drop_query FROM information_schema.tables WHERE table_schema = 'beubo';").Scan(&result) 81 | } 82 | 83 | for _, r := range result { 84 | DB.Exec(r.DropQuery) 85 | } 86 | } 87 | 88 | log.Println("Running database migrations") 89 | 90 | err := DB.AutoMigrate( 91 | &structs.User{}, 92 | &structs.UserActivation{}, 93 | &structs.Config{}, 94 | &structs.Page{}, 95 | &structs.Theme{}, 96 | &structs.Session{}, 97 | &structs.Site{}, 98 | &structs.Tag{}, 99 | &structs.Comment{}, 100 | &structs.Setting{}, 101 | &plugin.PluginSite{}, 102 | &structs.Role{}, 103 | &structs.Feature{}) 104 | utility.ErrorHandler(err, true) 105 | } 106 | 107 | func prepareSeed(email string, password string) { 108 | shouldSeed = true 109 | seedEmail = email 110 | seedPassword = password 111 | } 112 | 113 | func databaseSeed() { 114 | theme := addThemes() 115 | 116 | // user registration is disabled by default 117 | disableRegistration := structs.Setting{Key: "enable_user_registration", Value: "false"} 118 | DB.Where("key = ?", disableRegistration.Key).First(&disableRegistration) 119 | if disableRegistration.ID == 0 { 120 | DB.Create(&disableRegistration) 121 | } 122 | 123 | // users who register should have a member role 124 | newUserRole := structs.Setting{Key: "new_user_role", Value: "Member"} 125 | DB.Where("key = ?", newUserRole.Key).First(&newUserRole) 126 | if newUserRole.ID == 0 { 127 | DB.Create(&newUserRole) 128 | } 129 | 130 | features := []*structs.Feature{ 131 | {Key: "manage_sites"}, 132 | {Key: "manage_pages"}, 133 | {Key: "manage_users"}, 134 | {Key: "manage_user_roles"}, 135 | {Key: "manage_plugins"}, 136 | {Key: "manage_settings"}, 137 | } 138 | 139 | for _, feature := range features { 140 | DB.Where("key = ?", feature.Key).First(&feature) 141 | if feature.ID == 0 { 142 | DB.Create(&feature) 143 | } 144 | } 145 | 146 | // Add default roles if not exist 147 | adminRole := structs.Role{} 148 | DB.Where("name = ?", "Administrator").First(&adminRole) 149 | if adminRole.ID == 0 { 150 | adminRole = structs.Role{Name: "Administrator", Features: features} 151 | DB.Create(&adminRole) 152 | } 153 | role := structs.Role{} 154 | DB.Where("name = ?", "Member").First(&role) 155 | if role.ID == 0 { 156 | role = structs.Role{Name: "Member"} 157 | DB.Create(&role) 158 | } 159 | 160 | // Add the specified default test user if the environment is also not set to production 161 | if environment != "production" && testuser != "" && testpass != "" { 162 | var err error 163 | 164 | // ASVS 4.0 point 2.4.4 states cost should be at least 13 https://github.com/OWASP/ASVS/ 165 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(testpass), 14) 166 | 167 | utility.ErrorHandler(err, true) 168 | 169 | user := structs.User{Email: testuser, Password: string(hashedPassword)} 170 | DB.Where("email = ?", user.Email).First(&user) 171 | if user.ID == 0 { 172 | user.Roles = []*structs.Role{ 173 | &adminRole, 174 | } 175 | DB.Create(&user) 176 | } 177 | } 178 | 179 | log.Println("should seed", shouldSeed) 180 | 181 | // If seeding is enabled we perform the seed with default info 182 | if shouldSeed { 183 | seedData(theme, adminRole) 184 | } 185 | 186 | shouldSeed = false 187 | seedEmail = "" 188 | seedPassword = "" 189 | } 190 | 191 | func seedData(theme structs.Theme, adminRole structs.Role) { 192 | log.Println("Seeding database") 193 | var err error 194 | 195 | // Create a site 196 | 197 | site := structs.Site{ 198 | Title: "Beubo", 199 | Domain: "beubo.localhost", 200 | Theme: theme, 201 | Type: 1, 202 | } 203 | 204 | DB.Create(&site) 205 | 206 | // Create a page 207 | content := `

Welcome to Beubo! Beubo is a free, simple, and minimal CMS with unlimited extensibility using plugins. This is the default page and can be changed in the admin area for this site.

` 208 | content += `

Beubo is open source and the project can be found on Github. If you find any problems or have an idea on how Beubo can be improved, please feel free to open an issue here.

` 209 | content += `

Feel free to open a pull request if you would like to contribute your own changes.

` 210 | content += `

For more information on how to use, customize and extend Beubo please see the wiki

` 211 | 212 | page := structs.Page{ 213 | Model: gorm.Model{}, 214 | Title: "Default page", 215 | Content: content, 216 | Slug: "/", 217 | Template: "page", 218 | SiteID: int(site.ID), 219 | } 220 | DB.Create(&page) 221 | 222 | // ASVS 4.0 point 2.4.4 states cost should be at least 13 https://github.com/OWASP/ASVS/ 223 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(seedPassword), 14) 224 | 225 | utility.ErrorHandler(err, true) 226 | 227 | user := structs.User{Email: seedEmail, Password: string(hashedPassword)} 228 | DB.Where("email = ?", user.Email).First(&user) 229 | if user.ID == 0 { 230 | user.Roles = []*structs.Role{ 231 | &adminRole, 232 | } 233 | DB.Create(&user) 234 | } 235 | } 236 | 237 | func addThemes() (theme structs.Theme) { 238 | // Add initial themes 239 | files, err := ioutil.ReadDir(rootDir) 240 | if err != nil { 241 | log.Fatal(err) 242 | } 243 | for _, file := range files { 244 | // Ignore the install directory, only used for installation 245 | if file.IsDir() && file.Name() != "install" { 246 | theme = structs.Theme{} 247 | DB.Where("slug = ?", file.Name()).First(&theme) 248 | if theme.ID == 0 { 249 | theme = structs.Theme{Slug: file.Name(), Title: file.Name()} 250 | DB.Create(&theme) 251 | } 252 | } 253 | } 254 | return theme 255 | } 256 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/uberswe/beubo 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/davecgh/go-spew v1.1.1 // indirect 7 | github.com/goincremental/negroni-sessions v0.0.0-20171223143234-40b49004abee 8 | github.com/gorilla/context v1.1.1 // indirect 9 | github.com/gorilla/mux v1.8.0 10 | github.com/gorilla/securecookie v1.1.1 11 | github.com/gorilla/sessions v1.2.1 // indirect 12 | github.com/joho/godotenv v1.3.0 13 | github.com/kr/pretty v0.2.0 // indirect 14 | github.com/lunixbochs/vtclean v1.0.0 // indirect 15 | github.com/manifoldco/promptui v0.8.0 16 | github.com/mattn/go-colorable v0.1.8 // indirect 17 | github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect 18 | github.com/stretchr/stew v0.0.0-20130812190256-80ef0842b48b 19 | github.com/stretchr/testify v1.5.1 // indirect 20 | github.com/urfave/negroni v1.0.0 21 | golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad 22 | golang.org/x/net v0.0.0-20210119194325-5f4716e94777 23 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect 24 | golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 25 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 26 | gopkg.in/yaml.v2 v2.2.8 // indirect 27 | gorm.io/driver/mysql v1.0.3 28 | gorm.io/driver/sqlite v1.1.4 29 | gorm.io/gorm v1.20.11 30 | ) 31 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= 2 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 3 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= 4 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 5 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= 6 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= 11 | github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 12 | github.com/goincremental/negroni-sessions v0.0.0-20171223143234-40b49004abee h1:aBLa9mJSmSV0I7vZrb+jMakH/B5rR+mRI6Q/DxQ+O7g= 13 | github.com/goincremental/negroni-sessions v0.0.0-20171223143234-40b49004abee/go.mod h1:32Cq/6avji0Xp32YipwaOV3PsvpGfuYKTJP9XJrdXe8= 14 | github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= 15 | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= 16 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 17 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 18 | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= 19 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 20 | github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= 21 | github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 22 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 23 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 24 | github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E= 25 | github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 26 | github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= 27 | github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= 28 | github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU= 29 | github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= 30 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 31 | github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= 32 | github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 33 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 34 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 35 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 36 | github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw= 37 | github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= 38 | github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8= 39 | github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= 40 | github.com/manifoldco/promptui v0.8.0 h1:R95mMF+McvXZQ7j1g8ucVZE1gLP3Sv6j9vlF9kyRqQo= 41 | github.com/manifoldco/promptui v0.8.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= 42 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 43 | github.com/mattn/go-colorable v0.0.10-0.20170816031813-ad5389df28cd h1:Y4ZRx+RIPFlPL4gnD/I7bdqSNXHlNop1Q6NjQuHds00= 44 | github.com/mattn/go-colorable v0.0.10-0.20170816031813-ad5389df28cd/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 45 | github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= 46 | github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 47 | github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= 48 | github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 49 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= 50 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 51 | github.com/mattn/go-sqlite3 v1.14.5/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI= 52 | github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= 53 | github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 54 | github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= 55 | github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 56 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 57 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 58 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 59 | github.com/stretchr/stew v0.0.0-20130812190256-80ef0842b48b h1:DmfFjW6pLdaJNVHfKgCxTdKFI6tM+0YbMd0kx7kE78s= 60 | github.com/stretchr/stew v0.0.0-20130812190256-80ef0842b48b/go.mod h1:yS/5aMz+lfJhykLjlAGbnhUhZIvVapOvtmk0MtzHktE= 61 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= 62 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 63 | github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc= 64 | github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= 65 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 66 | golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY= 67 | golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= 68 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 69 | golang.org/x/net v0.0.0-20201224014010-6772e930b67b h1:iFwSg7t5GZmB/Q5TjiEAsdoLDrdJRC1RiF2WhuV29Qw= 70 | golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 71 | golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew= 72 | golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 73 | golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 74 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 75 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 76 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 77 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 78 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= 79 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 80 | golang.org/x/sys v0.0.0-20201223074533-0d417f636930 h1:vRgIt+nup/B/BwIS0g2oC0haq0iqbV3ZA+u6+0TlNCo= 81 | golang.org/x/sys v0.0.0-20201223074533-0d417f636930/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 82 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk= 83 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 84 | golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= 85 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 86 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 87 | golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= 88 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 89 | golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE= 90 | golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 91 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 92 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 93 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 94 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 95 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 96 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 97 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 98 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 99 | gorm.io/driver/mysql v1.0.3 h1:+JKBYPfn1tygR1/of/Fh2T8iwuVwzt+PEJmKaXzMQXg= 100 | gorm.io/driver/mysql v1.0.3/go.mod h1:twGxftLBlFgNVNakL7F+P/x9oYqoymG3YYT8cAfI9oI= 101 | gorm.io/driver/sqlite v1.1.4 h1:PDzwYE+sI6De2+mxAneV9Xs11+ZyKV6oxD3wDGkaNvM= 102 | gorm.io/driver/sqlite v1.1.4/go.mod h1:mJCeTFr7+crvS+TRnWc5Z3UvwxUN1BGBLMrf5LA9DYw= 103 | gorm.io/gorm v1.20.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= 104 | gorm.io/gorm v1.20.7/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= 105 | gorm.io/gorm v1.20.10 h1:oJDuZyiUVVH1t20aRv439xIhmx5HFqv3iDxwAZ5sBb0= 106 | gorm.io/gorm v1.20.10/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= 107 | gorm.io/gorm v1.20.11 h1:jYHQ0LLUViV85V8dM1TP9VBBkfzKTnuTXDjYObkI6yc= 108 | gorm.io/gorm v1.20.11/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= 109 | -------------------------------------------------------------------------------- /pkg/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uberswe/beubo/7b0100af941c71b906e4a49c4e0e3857a04fd098/pkg/.gitkeep -------------------------------------------------------------------------------- /pkg/middleware/access.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/uberswe/beubo/pkg/structs" 5 | "gorm.io/gorm" 6 | "net/http" 7 | ) 8 | 9 | // CanAccess checks if a user is allowed to access a specified feature 10 | func CanAccess(db *gorm.DB, FeatureKey string, r *http.Request) bool { 11 | self := r.Context().Value(UserContextKey) 12 | if self != nil && self.(structs.User).ID > 0 { 13 | if self.(structs.User).CanAccess(db, FeatureKey) { 14 | return true 15 | } 16 | } 17 | return false 18 | } 19 | -------------------------------------------------------------------------------- /pkg/middleware/auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | sessions "github.com/goincremental/negroni-sessions" 6 | "github.com/uberswe/beubo/pkg/structs" 7 | "golang.org/x/net/context" 8 | "log" 9 | "net/http" 10 | "strings" 11 | ) 12 | 13 | // Auth checks if a user is authenticated and performs redirects if needed. The user struct is set to the request context if authenticated. 14 | func (bmw *BeuboMiddleware) Auth(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { 15 | session := sessions.GetSession(r) 16 | token := session.Get("SES_ID") 17 | 18 | user := structs.FetchUserFromSession(bmw.DB, fmt.Sprintf("%v", token)) 19 | 20 | // TODO in the future the admin path should be configurable and also not apply to every website 21 | if user.ID == 0 && strings.HasPrefix(r.URL.Path, "/admin") { 22 | log.Println("user is not logged in, redirect to /login") 23 | http.Redirect(rw, r, "/login", 302) 24 | return 25 | } else if user.ID > 0 { 26 | // If path is login then redirect to /admin 27 | if strings.HasPrefix(r.URL.Path, "/login") { 28 | http.Redirect(rw, r, "/admin", 302) 29 | return 30 | } 31 | ctx := context.WithValue(r.Context(), UserContextKey, user) 32 | r = r.WithContext(ctx) 33 | } 34 | 35 | next(rw, r) 36 | } 37 | -------------------------------------------------------------------------------- /pkg/middleware/main.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/uberswe/beubo/pkg/plugin" 5 | "gorm.io/gorm" 6 | ) 7 | 8 | type key string 9 | 10 | const ( 11 | // UserContextKey is used to fetch and store the user struct from context 12 | UserContextKey key = "user" 13 | // SiteContextKey is used to fetch and store the site struct from context 14 | SiteContextKey key = "site" 15 | ) 16 | 17 | // BeuboMiddleware holds parameters relevant to Beubo middlewares 18 | type BeuboMiddleware struct { 19 | DB *gorm.DB 20 | PluginHandler *plugin.Handler 21 | } 22 | -------------------------------------------------------------------------------- /pkg/middleware/plugin.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import "net/http" 4 | 5 | // Plugin allows plugins to perform actions as a middleware 6 | func (bmw *BeuboMiddleware) Plugin(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { 7 | bmw.PluginHandler.BeforeRequest(w, r) 8 | next.ServeHTTP(w, r) 9 | bmw.PluginHandler.AfterRequest(w, r) 10 | } 11 | -------------------------------------------------------------------------------- /pkg/middleware/site.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | "github.com/uberswe/beubo/pkg/structs" 6 | "golang.org/x/net/context" 7 | "net/http" 8 | ) 9 | 10 | // Site determines if the domain is an existing site and performs relevant actions based on this 11 | func (bmw *BeuboMiddleware) Site(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { 12 | site := structs.FetchSiteByHost(bmw.DB, r.Host) 13 | if site.ID == 0 { 14 | // No site detected 15 | // TODO maybe we should redirect or something if this is the case? Make it configurable 16 | } else { 17 | if site.Type == 3 { 18 | // The site is a redirect 19 | http.Redirect(rw, r, fmt.Sprintf("https://%s", site.DestinationDomain), 302) 20 | } 21 | // Site exists 22 | ctx := context.WithValue(r.Context(), SiteContextKey, site) 23 | r = r.WithContext(ctx) 24 | } 25 | next(rw, r) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/middleware/throttle.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "golang.org/x/time/rate" 5 | "net/http" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | // Throttle holds relevant parameters for configuring how the throttle middleware behaves 11 | type Throttle struct { 12 | IPs map[string]ThrottleClient 13 | Mu *sync.RWMutex 14 | Rate rate.Limit 15 | Burst int 16 | Cleanup time.Duration 17 | } 18 | 19 | // ThrottleClient holds info about an ip such as it's rate limiter and the last time a request was made 20 | type ThrottleClient struct { 21 | Limiter *rate.Limiter 22 | last time.Time 23 | } 24 | 25 | func (t Throttle) add(ip string) *rate.Limiter { 26 | t.Mu.Lock() 27 | defer t.Mu.Unlock() 28 | limiter := rate.NewLimiter(t.Rate, t.Burst) 29 | t.IPs[ip] = ThrottleClient{ 30 | Limiter: limiter, 31 | last: time.Now(), 32 | } 33 | return limiter 34 | } 35 | 36 | func (t Throttle) getLimiter(ip string) *rate.Limiter { 37 | t.Mu.Lock() 38 | c, exists := t.IPs[ip] 39 | if !exists { 40 | t.Mu.Unlock() 41 | return t.add(ip) 42 | } 43 | c.last = time.Now() 44 | t.IPs[ip] = c 45 | t.Mu.Unlock() 46 | return c.Limiter 47 | } 48 | 49 | func (t Throttle) cleanup() { 50 | t.Mu.Lock() 51 | for ip, v := range t.IPs { 52 | if time.Since(v.last) > t.Cleanup { 53 | delete(t.IPs, ip) 54 | } 55 | } 56 | t.Mu.Unlock() 57 | } 58 | 59 | // Throttle prevents multiple repeated requests in a certain time period 60 | func (t Throttle) Throttle(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { 61 | // TODO if we are behind a load balancer we need to support X-Forwarded-For headers 62 | limiter := t.getLimiter(r.RemoteAddr) 63 | if !limiter.Allow() { 64 | http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests) 65 | return 66 | } 67 | next.ServeHTTP(w, r) 68 | } 69 | -------------------------------------------------------------------------------- /pkg/middleware/whitelist.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | sessions "github.com/goincremental/negroni-sessions" 5 | "github.com/stretchr/stew/slice" 6 | "github.com/uberswe/beubo/pkg/structs" 7 | "log" 8 | "net" 9 | "net/http" 10 | "strings" 11 | ) 12 | 13 | // Whitelist checks the ip whitelist configuration. If ip whitelisting is enabled, it ensures the ip is whitelisted when accessing administrator pages 14 | func (bmw *BeuboMiddleware) Whitelist(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { 15 | settings := structs.FetchSettings(bmw.DB) 16 | 17 | whitelistEnabled := false 18 | var whitelistedIPs []string 19 | 20 | for _, s := range settings { 21 | if s.Key == "ip_whitelist" && s.Value == "true" { 22 | whitelistEnabled = true 23 | } 24 | if s.Key == "whitelisted_ip" { 25 | whitelistedIPs = append(whitelistedIPs, s.Value) 26 | } 27 | } 28 | 29 | // TODO if we are behind a load balancer we need to support X-Forwarded-For headers 30 | // TODO in the future the admin path should be configurable and also not apply to every website 31 | if strings.HasPrefix(r.URL.Path, "/admin") && whitelistEnabled && !slice.Contains(whitelistedIPs, hostWithoutPort(r.RemoteAddr)) { 32 | session := sessions.GetSession(r) 33 | session.Delete("SES_ID") 34 | session.Clear() 35 | log.Printf("IP %s is not whitelisted, redirect to /login\n", hostWithoutPort(r.RemoteAddr)) 36 | http.Redirect(rw, r, "/login", 302) 37 | return 38 | } 39 | next(rw, r) 40 | } 41 | 42 | func hostWithoutPort(host string) string { 43 | if strings.Contains(host, ":") { 44 | h, _, err := net.SplitHostPort(host) 45 | if err == nil { 46 | return h 47 | } 48 | } 49 | return host 50 | } 51 | -------------------------------------------------------------------------------- /pkg/plugin/helper.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "github.com/uberswe/beubo/pkg/structs" 5 | "net/http" 6 | ) 7 | 8 | func (p Handler) isActive(r *http.Request, plugin Plugin) bool { 9 | site := structs.FetchSiteByHost(p.DB, r.Host) 10 | ps := FetchPluginSites(p.DB, plugin.Definition) 11 | if site.ID >= 0 { 12 | for _, pluginSite := range ps { 13 | if pluginSite.SiteID == site.ID && pluginSite.Active { 14 | return true 15 | } 16 | } 17 | } 18 | return false 19 | } 20 | -------------------------------------------------------------------------------- /pkg/plugin/loader.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "github.com/uberswe/beubo/pkg/structs" 5 | "log" 6 | "path/filepath" 7 | "plugin" 8 | ) 9 | 10 | // Load is a function that loads any available plugins in the plugins/ folder 11 | func Load(pluginHandler Handler) Handler { 12 | // The plugins (the *.so files) must be in a 'plugins' sub-directory 13 | allPlugins, err := filepath.Glob("plugins/*.so") 14 | if err != nil { 15 | panic(err) 16 | } 17 | 18 | log.Println("Loading plugins...") 19 | for _, filename := range allPlugins { 20 | plug, err := plugin.Open(filename) 21 | if err != nil { 22 | log.Println(err) 23 | continue 24 | } 25 | 26 | symbol, err := plug.Lookup("Register") 27 | if err != nil { 28 | log.Println("Plugin has no 'Register() map[string]string' function") 29 | continue 30 | } 31 | 32 | registerFunc, ok := symbol.(func() map[string]string) 33 | if !ok { 34 | log.Println("Plugin has no 'Register() map[string]string' function") 35 | continue 36 | } 37 | 38 | pluginData := registerFunc() 39 | log.Println(filename, pluginData) 40 | 41 | if pluginHandler.Plugins == nil { 42 | pluginHandler.Plugins = map[string]Plugin{} 43 | } 44 | 45 | pluginHandler.Plugins[filename] = Plugin{ 46 | Plugin: plug, 47 | Definition: pluginData["identifier"], 48 | Data: pluginData, 49 | } 50 | } 51 | 52 | // TODO the following code requires an active database connection but what if a plugin wants to modify a connection? It would be good if this could be moved so the loading of plugins happens before the active check is performed. 53 | if pluginHandler.Plugins != nil { 54 | sites := structs.FetchSites(pluginHandler.DB) 55 | var plugins []string 56 | var existingSites []uint 57 | for _, s := range sites { 58 | existingSites = append(existingSites, s.ID) 59 | } 60 | for _, p := range pluginHandler.Plugins { 61 | plugins = append(plugins, p.Definition) 62 | for _, s := range sites { 63 | ps := PluginSite{} 64 | pluginHandler.DB.Where("plugin_identifier = ?", p.Definition).Find(&ps) 65 | if ps.ID <= 0 { 66 | ps.Active = false 67 | ps.SiteID = s.ID 68 | ps.PluginIdentifier = p.Definition 69 | pluginHandler.DB.Create(&p) 70 | } 71 | } 72 | } 73 | if len(plugins) > 0 { 74 | pluginHandler.DB.Where("plugin_identifier NOT IN ?", plugins).Delete([]PluginSite{}) 75 | } else { 76 | // delete everything if there are no plugins 77 | pluginHandler.DB.Where("1=1").Delete([]PluginSite{}) 78 | } 79 | if len(existingSites) > 0 { 80 | pluginHandler.DB.Where("site_id NOT IN ?", existingSites).Delete([]PluginSite{}) 81 | } else { 82 | // delete everything if there are no sites 83 | pluginHandler.DB.Where("1=1").Delete([]PluginSite{}) 84 | } 85 | } 86 | 87 | return pluginHandler 88 | } 89 | -------------------------------------------------------------------------------- /pkg/plugin/page.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "github.com/uberswe/beubo/pkg/structs" 5 | "net/http" 6 | ) 7 | 8 | // PageData allows the modification of page data before it is passed to the execute function of the template handler 9 | func (p Handler) PageData(r *http.Request, pd structs.PageData) structs.PageData { 10 | for _, plug := range p.Plugins { 11 | if p.isActive(r, plug) { 12 | symbol, err := plug.Plugin.Lookup("PageData") 13 | if err != nil { 14 | continue 15 | } 16 | 17 | pageDataFunc, ok := symbol.(func(*http.Request, structs.PageData) structs.PageData) 18 | if !ok { 19 | continue 20 | } 21 | return pageDataFunc(r, pd) 22 | } 23 | } 24 | return pd 25 | } 26 | -------------------------------------------------------------------------------- /pkg/plugin/request.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // BeforeRequest is called by Beubo as early as possible in the request stack after other middlewares have executed 8 | func (p Handler) BeforeRequest(w http.ResponseWriter, r *http.Request) { 9 | for _, plug := range p.Plugins { 10 | if p.isActive(r, plug) { 11 | symbol, err := plug.Plugin.Lookup("BeforeRequest") 12 | if err != nil { 13 | continue 14 | } 15 | 16 | beforeRequestFunc, ok := symbol.(func(http.ResponseWriter, *http.Request)) 17 | if !ok { 18 | continue 19 | } 20 | beforeRequestFunc(w, r) 21 | } 22 | } 23 | } 24 | 25 | // AfterRequest is called by Beubo at the last possible place in the request stack before returning the response writer 26 | func (p Handler) AfterRequest(w http.ResponseWriter, r *http.Request) { 27 | for _, plug := range p.Plugins { 28 | if p.isActive(r, plug) { 29 | symbol, err := plug.Plugin.Lookup("AfterRequest") 30 | if err != nil { 31 | continue 32 | } 33 | 34 | afterRequestFunc, ok := symbol.(func(http.ResponseWriter, *http.Request)) 35 | if !ok { 36 | continue 37 | } 38 | afterRequestFunc(w, r) 39 | } 40 | } 41 | } 42 | 43 | // PageHandler is called when a non-default route is called in Beubo, returning true will prevent any other handler from executing 44 | func (p Handler) PageHandler(w http.ResponseWriter, r *http.Request) (handled bool) { 45 | for _, plug := range p.Plugins { 46 | if p.isActive(r, plug) { 47 | symbol, err := plug.Plugin.Lookup("PageHandler") 48 | if err != nil { 49 | continue 50 | } 51 | 52 | pageHandlerFunc, ok := symbol.(func(http.ResponseWriter, *http.Request) (handled bool)) 53 | if !ok { 54 | continue 55 | } 56 | return pageHandlerFunc(w, r) 57 | } 58 | } 59 | return false 60 | } 61 | -------------------------------------------------------------------------------- /pkg/plugin/structs.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "github.com/uberswe/beubo/pkg/structs" 5 | "gorm.io/gorm" 6 | "plugin" 7 | ) 8 | 9 | // Handler holds all the needed information for plugins to function in Beubo 10 | type Handler struct { 11 | DB *gorm.DB 12 | Plugins map[string]Plugin 13 | } 14 | 15 | // Plugin represents data for a single plugin in Beubo 16 | type Plugin struct { 17 | Plugin *plugin.Plugin 18 | Definition string 19 | Data map[string]string 20 | } 21 | 22 | // PluginSite defines which site a plugin is activated for 23 | type PluginSite struct { 24 | gorm.Model 25 | Site structs.Site 26 | SiteID uint 27 | PluginIdentifier string 28 | Active bool 29 | } 30 | 31 | // FetchPluginSites gets a PluginSite definition based on the specified plugin identifier 32 | func FetchPluginSites(db *gorm.DB, plugin string) (ps []PluginSite) { 33 | db.Preload("Site").Where("plugin_identifier = ?", plugin).Find(&ps) 34 | return ps 35 | } 36 | -------------------------------------------------------------------------------- /pkg/routes/admin.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "fmt" 5 | "github.com/uberswe/beubo/pkg/middleware" 6 | "github.com/uberswe/beubo/pkg/plugin" 7 | "github.com/uberswe/beubo/pkg/structs" 8 | "github.com/uberswe/beubo/pkg/structs/page" 9 | "github.com/uberswe/beubo/pkg/structs/page/component" 10 | "github.com/uberswe/beubo/pkg/utility" 11 | "html" 12 | "html/template" 13 | "net/http" 14 | ) 15 | 16 | // Admin is the default admin route and template 17 | func (br *BeuboRouter) Admin(w http.ResponseWriter, r *http.Request) { 18 | if !middleware.CanAccess(br.DB, "manage_sites", r) { 19 | w.WriteHeader(http.StatusForbidden) 20 | return 21 | } 22 | 23 | var sites []structs.Site 24 | 25 | if err := br.DB.Find(&sites).Error; err != nil { 26 | utility.ErrorHandler(err, false) 27 | } 28 | 29 | var rows []component.Row 30 | 31 | self := r.Context().Value(middleware.UserContextKey) 32 | if self != nil && self.(structs.User).ID > 0 { 33 | for _, site := range sites { 34 | if !self.(structs.User).CanAccessSite(br.DB, site) { 35 | continue 36 | } 37 | sid := fmt.Sprintf("%d", site.ID) 38 | rows = append(rows, component.Row{ 39 | Columns: []component.Column{ 40 | {Name: "ID", Value: sid}, 41 | {Name: "Site", Value: html.EscapeString(site.Title)}, 42 | {Name: "Domain", Value: html.EscapeString(site.Domain)}, 43 | {Name: "", Field: component.Button{ 44 | Link: template.URL(fmt.Sprintf("%s://%s/", "https", html.EscapeString(site.Domain))), 45 | Class: "btn btn-primary", 46 | Content: "View", 47 | T: br.Renderer.T, 48 | }}, 49 | {Name: "", Field: component.Button{ 50 | Link: template.URL(fmt.Sprintf("/admin/sites/a/%s", sid)), 51 | Class: "btn btn-primary", 52 | Content: "Manage", 53 | T: br.Renderer.T, 54 | }}, 55 | {Name: "", Field: component.Button{ 56 | Link: template.URL(fmt.Sprintf("/admin/sites/edit/%s", sid)), 57 | Class: "btn btn-primary", 58 | Content: "Edit", 59 | T: br.Renderer.T, 60 | }}, 61 | {Name: "", Field: component.Button{ 62 | Link: template.URL(fmt.Sprintf("/admin/sites/delete/%s", sid)), 63 | Class: "btn btn-primary", 64 | Content: "Delete", 65 | T: br.Renderer.T, 66 | }}, 67 | }, 68 | }) 69 | } 70 | } 71 | 72 | table := component.Table{ 73 | Section: "main", 74 | Header: []component.Column{ 75 | {Name: "ID"}, 76 | {Name: "Site"}, 77 | {Name: "Domain"}, 78 | {Name: ""}, 79 | {Name: ""}, 80 | {Name: ""}, 81 | {Name: ""}, 82 | }, 83 | Rows: rows, 84 | PageNumber: 1, 85 | PageDisplayCount: 10, 86 | T: br.Renderer.T, 87 | } 88 | 89 | pageData := structs.PageData{ 90 | Template: "admin.page", 91 | Title: "Admin - Sites", 92 | Components: []page.Component{ 93 | component.Button{ 94 | Section: "main", 95 | Link: template.URL("/admin/sites/add"), 96 | Class: "btn btn-primary", 97 | Content: "Add Site", 98 | T: br.Renderer.T, 99 | }, 100 | table, 101 | }, 102 | } 103 | 104 | br.Renderer.RenderHTMLPage(w, r, pageData) 105 | } 106 | 107 | // Settings is the route for loading the admin settings page 108 | func (br *BeuboRouter) Settings(w http.ResponseWriter, r *http.Request) { 109 | if !middleware.CanAccess(br.DB, "manage_settings", r) { 110 | w.WriteHeader(http.StatusForbidden) 111 | return 112 | } 113 | 114 | var settings []structs.Setting 115 | 116 | if err := br.DB.Find(&settings).Error; err != nil { 117 | utility.ErrorHandler(err, false) 118 | } 119 | 120 | var rows []component.Row 121 | for _, setting := range settings { 122 | sid := fmt.Sprintf("%d", setting.ID) 123 | rows = append(rows, component.Row{ 124 | Columns: []component.Column{ 125 | {Name: "ID", Value: sid}, 126 | {Name: "Site", Value: html.EscapeString(setting.Key)}, 127 | {Name: "Domain", Value: html.EscapeString(setting.Value)}, 128 | {Name: "", Field: component.Button{ 129 | Link: template.URL(fmt.Sprintf("/admin/settings/edit/%s", sid)), 130 | Class: "btn btn-primary", 131 | Content: "Edit", 132 | T: br.Renderer.T, 133 | }}, 134 | {Name: "", Field: component.Button{ 135 | Link: template.URL(fmt.Sprintf("/admin/settings/delete/%s", sid)), 136 | Class: "btn btn-primary", 137 | Content: "Delete", 138 | T: br.Renderer.T, 139 | }}, 140 | }, 141 | }) 142 | } 143 | 144 | table := component.Table{ 145 | Section: "main", 146 | Header: []component.Column{ 147 | {Name: "ID"}, 148 | {Name: "Key"}, 149 | {Name: "Value"}, 150 | {Name: ""}, 151 | {Name: ""}, 152 | }, 153 | Rows: rows, 154 | PageNumber: 1, 155 | PageDisplayCount: 10, 156 | T: br.Renderer.T, 157 | } 158 | 159 | pageData := structs.PageData{ 160 | Template: "admin.page", 161 | Title: "Admin - Settings", 162 | Components: []page.Component{ 163 | component.Button{ 164 | Section: "main", 165 | Link: template.URL("/admin/settings/add"), 166 | Class: "btn btn-primary", 167 | Content: "Add Setting", 168 | T: br.Renderer.T, 169 | }, 170 | table, 171 | component.Text{ 172 | Section: "main", 173 | Content: "This is the very early stages of how settings works. Currently it is just plain key and values with no checks.
" + 174 | "To use IP whitelisting first set any IPs to whitelist with whitelisted_ip as the key and the IP as value.
" + 175 | "Then set ip_whitelist to true to enable the whitelist blocking", 176 | T: br.Renderer.T, 177 | }, 178 | }, 179 | } 180 | br.Renderer.RenderHTMLPage(w, r, pageData) 181 | } 182 | 183 | // Users is the route for loading the admin users page 184 | func (br *BeuboRouter) Users(w http.ResponseWriter, r *http.Request) { 185 | if !middleware.CanAccess(br.DB, "manage_users", r) { 186 | w.WriteHeader(http.StatusForbidden) 187 | return 188 | } 189 | 190 | var users []structs.User 191 | 192 | if err := br.DB.Find(&users).Error; err != nil { 193 | utility.ErrorHandler(err, false) 194 | } 195 | 196 | self := r.Context().Value(middleware.UserContextKey) 197 | 198 | var rows []component.Row 199 | if self != nil && self.(structs.User).ID > 0 { 200 | for _, user := range users { 201 | sid := fmt.Sprintf("%d", user.ID) 202 | if self.(structs.User).ID == user.ID { 203 | rows = append(rows, component.Row{ 204 | Columns: []component.Column{ 205 | {Name: "ID", Value: sid}, 206 | {Name: "Email", Value: user.Email}, 207 | {Name: "", Field: component.Button{ 208 | Link: template.URL(fmt.Sprintf("/admin/users/edit/%s", sid)), 209 | Class: "btn btn-primary", 210 | Content: "Edit", 211 | T: br.Renderer.T, 212 | }}, 213 | {}, 214 | }, 215 | }) 216 | } else { 217 | rows = append(rows, component.Row{ 218 | Columns: []component.Column{ 219 | {Name: "ID", Value: sid}, 220 | {Name: "Email", Value: user.Email}, 221 | {Name: "", Field: component.Button{ 222 | Link: template.URL(fmt.Sprintf("/admin/users/edit/%s", sid)), 223 | Class: "btn btn-primary", 224 | Content: "Edit", 225 | T: br.Renderer.T, 226 | }}, 227 | {Name: "", Field: component.Button{ 228 | Link: template.URL(fmt.Sprintf("/admin/users/delete/%s", sid)), 229 | Class: "btn btn-primary", 230 | Content: "Delete", 231 | T: br.Renderer.T, 232 | }}, 233 | }, 234 | }) 235 | } 236 | } 237 | } 238 | 239 | table := component.Table{ 240 | Section: "main", 241 | Header: []component.Column{ 242 | {Name: "ID"}, 243 | {Name: "Email"}, 244 | {Name: ""}, 245 | {Name: ""}, 246 | }, 247 | Rows: rows, 248 | PageNumber: 1, 249 | PageDisplayCount: 10, 250 | T: br.Renderer.T, 251 | } 252 | 253 | pageData := structs.PageData{ 254 | Template: "admin.page", 255 | Title: "Admin - Users", 256 | Components: []page.Component{ 257 | component.Button{ 258 | Section: "main", 259 | Link: template.URL("/admin/users/add"), 260 | Class: "btn btn-primary", 261 | Content: "Add User", 262 | T: br.Renderer.T, 263 | }, 264 | table, 265 | }, 266 | } 267 | 268 | br.Renderer.RenderHTMLPage(w, r, pageData) 269 | 270 | } 271 | 272 | // Plugins is the route for loading the admin plugins page 273 | func (br *BeuboRouter) Plugins(w http.ResponseWriter, r *http.Request) { 274 | if !middleware.CanAccess(br.DB, "manage_plugins", r) { 275 | w.WriteHeader(http.StatusForbidden) 276 | return 277 | } 278 | 279 | if r.Method == http.MethodPost { 280 | // TODO handle post 281 | return 282 | } 283 | 284 | // reload plugins when viewing the plugin page 285 | plh := plugin.Load(*br.PluginHandler) 286 | br.PluginHandler = &plh 287 | 288 | var rows []component.Row 289 | for _, p := range br.PluginHandler.Plugins { 290 | comprow := component.Row{ 291 | Columns: []component.Column{ 292 | {Name: "Name", Value: fmt.Sprintf("%s", p.Definition, p.Definition)}, 293 | }, 294 | } 295 | rows = append(rows, comprow) 296 | } 297 | 298 | table := component.Table{ 299 | Section: "main", 300 | Header: []component.Column{ 301 | {Name: "Name"}, 302 | }, 303 | Rows: rows, 304 | PageNumber: 1, 305 | PageDisplayCount: 10, 306 | T: br.Renderer.T, 307 | } 308 | 309 | pageData := structs.PageData{ 310 | Template: "admin.page", 311 | Title: "Admin - Plugins", 312 | Components: []page.Component{ 313 | table, 314 | }, 315 | } 316 | 317 | br.Renderer.RenderHTMLPage(w, r, pageData) 318 | } 319 | -------------------------------------------------------------------------------- /pkg/routes/auth.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | sessions "github.com/goincremental/negroni-sessions" 5 | "github.com/uberswe/beubo/pkg/structs" 6 | "github.com/uberswe/beubo/pkg/utility" 7 | "net/http" 8 | ) 9 | 10 | // Login is the default login route 11 | func (br *BeuboRouter) Login(w http.ResponseWriter, r *http.Request) { 12 | pageData := structs.PageData{ 13 | Template: "login", 14 | Title: "Login", 15 | } 16 | 17 | br.Renderer.RenderHTMLPage(w, r, pageData) 18 | } 19 | 20 | // LoginPost handles authentication via post request and verifies a username/password via the database 21 | func (br *BeuboRouter) LoginPost(w http.ResponseWriter, r *http.Request) { 22 | session := sessions.GetSession(r) 23 | 24 | invalidError := "Email or password incorrect, please try again or contact support" 25 | 26 | email := r.FormValue("email") 27 | password := r.FormValue("password") 28 | 29 | user := structs.AuthUser(br.DB, email, password) 30 | 31 | if user == nil || user.ID == 0 { 32 | utility.SetFlash(w, "error", []byte(invalidError)) 33 | http.Redirect(w, r, "/login", 302) 34 | return 35 | } 36 | 37 | ses := structs.CreateSession(br.DB, int(user.ID)) 38 | 39 | if ses.ID == 0 { 40 | w.WriteHeader(http.StatusInternalServerError) 41 | _, _ = w.Write([]byte("500 - Could not create session")) 42 | return 43 | } 44 | 45 | session.Set("SES_ID", ses.Token) 46 | http.Redirect(w, r, "/admin/", 302) 47 | } 48 | 49 | // Register renders the default registration page 50 | func (br *BeuboRouter) Register(w http.ResponseWriter, r *http.Request) { 51 | setting := structs.FetchSettingByKey(br.DB, "enable_user_registration") 52 | if setting.ID == 0 || setting.Value == "false" { 53 | // Registration is not allowed 54 | http.Redirect(w, r, "/login", 302) 55 | return 56 | } 57 | 58 | pageData := structs.PageData{ 59 | Template: "register", 60 | Title: "Register", 61 | } 62 | 63 | br.Renderer.RenderHTMLPage(w, r, pageData) 64 | } 65 | 66 | // RegisterPost handles a registration request and inserts the user into the database 67 | func (br *BeuboRouter) RegisterPost(w http.ResponseWriter, r *http.Request) { 68 | setting := structs.FetchSettingByKey(br.DB, "enable_user_registration") 69 | if setting.ID == 0 || setting.Value == "false" { 70 | // Registration is not allowed 71 | http.Redirect(w, r, "/login", 302) 72 | return 73 | } 74 | 75 | invalidError := "Please make sure the email is correct or that it does not already belong to a registered account" 76 | 77 | email := r.FormValue("email") 78 | password := r.FormValue("password") 79 | roles := []*structs.Role{} 80 | sites := []*structs.Site{} 81 | 82 | if !utility.IsEmailValid(email) { 83 | utility.SetFlash(w, "error", []byte(invalidError)) 84 | http.Redirect(w, r, "/login", 302) 85 | return 86 | } 87 | 88 | // TODO default role should be added on register 89 | if !structs.CreateUser(br.DB, email, password, roles, sites) { 90 | utility.SetFlash(w, "error", []byte(invalidError)) 91 | http.Redirect(w, r, "/login", 302) 92 | return 93 | } 94 | 95 | utility.SetFlash(w, "message", []byte("Registration success, please check your email for further instructions")) 96 | 97 | http.Redirect(w, r, "/login", 302) 98 | } 99 | 100 | // Logout handles a GET logout request and removes the user session 101 | func (br *BeuboRouter) Logout(w http.ResponseWriter, r *http.Request) { 102 | session := sessions.GetSession(r) 103 | session.Delete("SES_ID") 104 | session.Clear() 105 | http.Redirect(w, r, "/", 302) 106 | } 107 | -------------------------------------------------------------------------------- /pkg/routes/base.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/uberswe/beubo/pkg/structs" 5 | beuboPage "github.com/uberswe/beubo/pkg/structs/page" 6 | "github.com/uberswe/beubo/pkg/structs/page/component" 7 | "html/template" 8 | "net/http" 9 | ) 10 | 11 | // NotFoundHandler overrides the default not found handler 12 | func (br *BeuboRouter) NotFoundHandler(w http.ResponseWriter, r *http.Request) { 13 | pageData := structs.PageData{ 14 | Template: "404", 15 | Title: "404 Not Found", 16 | } 17 | 18 | w.Header().Set("Content-Type", "text/html") 19 | w.WriteHeader(http.StatusNotFound) 20 | 21 | br.Renderer.RenderHTMLPage(w, r, pageData) 22 | } 23 | 24 | // PageHandler checks if a page exists for the given slug 25 | func (br *BeuboRouter) PageHandler(w http.ResponseWriter, r *http.Request) { 26 | if !br.PluginHandler.PageHandler(w, r) { 27 | site := structs.FetchSiteByHost(br.DB, r.Host) 28 | if site.ID != 0 { 29 | page := structs.FetchPageBySiteIDAndSlug(br.DB, int(site.ID), r.URL.Path) 30 | if page.ID != 0 { 31 | pageData := structs.PageData{ 32 | Template: "page", 33 | Title: page.Title, 34 | // TODO Components should be defined on the page edit page and defined in the db 35 | Components: []beuboPage.Component{component.Text{ 36 | Content: template.HTML(page.Content), 37 | Theme: "", 38 | Template: "", 39 | Class: "", 40 | Section: "main", 41 | T: br.Renderer.T, 42 | }}, 43 | } 44 | 45 | br.Renderer.RenderHTMLPage(w, r, pageData) 46 | return 47 | } 48 | } 49 | br.NotFoundHandler(w, r) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pkg/routes/page.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/gorilla/mux" 7 | "github.com/uberswe/beubo/pkg/middleware" 8 | "github.com/uberswe/beubo/pkg/structs" 9 | "github.com/uberswe/beubo/pkg/utility" 10 | "log" 11 | "net/http" 12 | "strconv" 13 | ) 14 | 15 | // SiteAdminPageNew is a route for creating new pages 16 | func (br *BeuboRouter) SiteAdminPageNew(w http.ResponseWriter, r *http.Request) { 17 | if !middleware.CanAccess(br.DB, "manage_pages", r) { 18 | w.WriteHeader(http.StatusForbidden) 19 | return 20 | } 21 | 22 | params := mux.Vars(r) 23 | siteID := params["id"] 24 | extra := map[string]string{ 25 | "SiteID": siteID, 26 | } 27 | pageData := structs.PageData{ 28 | Template: "admin.site.page.add", 29 | Templates: br.Renderer.GetPageTemplates(), 30 | Title: "Admin - Add Page", 31 | Extra: extra, 32 | } 33 | 34 | br.Renderer.RenderHTMLPage(w, r, pageData) 35 | } 36 | 37 | // SiteAdminPageNewPost is a route for handling the post request for creating new pages 38 | func (br *BeuboRouter) SiteAdminPageNewPost(w http.ResponseWriter, r *http.Request) { 39 | if !middleware.CanAccess(br.DB, "manage_pages", r) { 40 | w.WriteHeader(http.StatusForbidden) 41 | return 42 | } 43 | 44 | params := mux.Vars(r) 45 | siteID := params["id"] 46 | 47 | successMessage := "Page created" 48 | invalidError := "an error occurred and the site could not be created." 49 | 50 | siteIDInt, err := strconv.Atoi(siteID) 51 | if err != nil { 52 | invalidError = "Invalid site id" 53 | utility.SetFlash(w, "error", []byte(invalidError)) 54 | http.Redirect(w, r, "/admin/sites", 302) 55 | return 56 | } 57 | 58 | title := r.FormValue("titleField") 59 | slug := r.FormValue("slugField") 60 | content := r.FormValue("contentField") 61 | template := r.FormValue("templateField") 62 | tags := r.FormValue("tagField") 63 | var tagSlice []structs.Tag 64 | err = json.Unmarshal([]byte(tags), &tagSlice) 65 | if err != nil { 66 | log.Println(err) 67 | } 68 | 69 | for i, tag := range tagSlice { 70 | tempTag := structs.Tag{} 71 | br.DB.Where("value = ?", tag.Value).First(&tempTag) 72 | if tempTag.ID == 0 { 73 | br.DB.Create(&tag) 74 | tagSlice[i] = tag 75 | } else { 76 | tagSlice[i] = tempTag 77 | } 78 | } 79 | 80 | if len(title) < 1 { 81 | invalidError = "The title is too short" 82 | utility.SetFlash(w, "error", []byte(invalidError)) 83 | http.Redirect(w, r, fmt.Sprintf("/admin/sites/a/%s/page/new", siteID), 302) 84 | return 85 | } 86 | 87 | if structs.CreatePage(br.DB, title, slug, tagSlice, template, content, siteIDInt) { 88 | utility.SetFlash(w, "message", []byte(successMessage)) 89 | http.Redirect(w, r, fmt.Sprintf("/admin/sites/a/%s", siteID), 302) 90 | return 91 | } 92 | 93 | utility.SetFlash(w, "error", []byte(invalidError)) 94 | http.Redirect(w, r, fmt.Sprintf("/admin/sites/a/%s/page/new", siteID), 302) 95 | return 96 | } 97 | 98 | // AdminSitePageEdit is the route for editing a page 99 | func (br *BeuboRouter) AdminSitePageEdit(w http.ResponseWriter, r *http.Request) { 100 | if !middleware.CanAccess(br.DB, "manage_pages", r) { 101 | w.WriteHeader(http.StatusForbidden) 102 | return 103 | } 104 | 105 | params := mux.Vars(r) 106 | siteID := params["id"] 107 | pageID := params["pageId"] 108 | 109 | pageIDInt, err := strconv.Atoi(pageID) 110 | if err != nil { 111 | invalidError := "Invalid page id" 112 | utility.SetFlash(w, "error", []byte(invalidError)) 113 | http.Redirect(w, r, "/admin/sites", 302) 114 | return 115 | } 116 | 117 | siteIDInt, err := strconv.Atoi(siteID) 118 | if err != nil { 119 | invalidError := "Invalid site id" 120 | utility.SetFlash(w, "error", []byte(invalidError)) 121 | http.Redirect(w, r, "/admin/sites", 302) 122 | return 123 | } 124 | 125 | page := structs.FetchPage(br.DB, pageIDInt) 126 | 127 | site := structs.FetchSite(br.DB, siteIDInt) 128 | 129 | // This should not be a nil slice since we are json encoding it even if it is empty 130 | var tags []structs.JSONTag 131 | 132 | for _, tag := range page.Tags { 133 | tags = append(tags, structs.JSONTag{ 134 | Value: tag.Value, 135 | }) 136 | } 137 | 138 | var jsonTags []byte 139 | jsonTags, err = json.Marshal(tags) 140 | if err != nil { 141 | log.Println(err) 142 | } 143 | 144 | extra := map[string]string{ 145 | "SiteID": siteID, 146 | "PageID": pageID, 147 | "Slug": page.Slug, 148 | "Title": page.Title, 149 | "Content": page.Content, 150 | "Template": page.Template, 151 | "SiteDomain": site.Domain, 152 | "Tags": string(jsonTags), 153 | } 154 | 155 | pageData := structs.PageData{ 156 | Template: "admin.site.page.edit", 157 | Templates: br.Renderer.GetPageTemplates(), 158 | Title: "Admin - Edit Page", 159 | Extra: extra, 160 | } 161 | 162 | br.Renderer.RenderHTMLPage(w, r, pageData) 163 | } 164 | 165 | // AdminSitePageEditPost is the route for handling a post request to edit a page 166 | func (br *BeuboRouter) AdminSitePageEditPost(w http.ResponseWriter, r *http.Request) { 167 | if !middleware.CanAccess(br.DB, "manage_pages", r) { 168 | w.WriteHeader(http.StatusForbidden) 169 | return 170 | } 171 | 172 | params := mux.Vars(r) 173 | siteID := params["id"] 174 | pageID := params["pageId"] 175 | 176 | path := fmt.Sprintf("/admin/sites/a/%s", siteID) 177 | 178 | i, err := strconv.Atoi(siteID) 179 | utility.ErrorHandler(err, false) 180 | 181 | pageIDInt, err := strconv.Atoi(pageID) 182 | if err != nil { 183 | invalidError := "Invalid page id" 184 | utility.SetFlash(w, "error", []byte(invalidError)) 185 | http.Redirect(w, r, path, 302) 186 | return 187 | } 188 | 189 | successMessage := "Page updated" 190 | invalidError := "an error occurred and the page could not be updated." 191 | 192 | title := r.FormValue("titleField") 193 | slug := r.FormValue("slugField") 194 | content := r.FormValue("contentField") 195 | template := r.FormValue("templateField") 196 | tags := r.FormValue("tagField") 197 | var tagSlice []structs.Tag 198 | err2 := json.Unmarshal([]byte(tags), &tagSlice) 199 | utility.ErrorHandler(err2, false) 200 | 201 | for i, tag := range tagSlice { 202 | tempTag := structs.Tag{} 203 | br.DB.Where("value = ?", tag.Value).First(&tempTag) 204 | if tempTag.ID == 0 { 205 | br.DB.Create(&tag) 206 | tagSlice[i] = tag 207 | } else { 208 | tagSlice[i] = tempTag 209 | } 210 | } 211 | 212 | if len(title) < 1 { 213 | invalidError = "The title is too short" 214 | utility.SetFlash(w, "error", []byte(invalidError)) 215 | http.Redirect(w, r, fmt.Sprintf("%s/page/edit/%s", path, siteID), 302) 216 | return 217 | } 218 | 219 | if structs.UpdatePage(br.DB, i, title, slug, tagSlice, template, content, pageIDInt) { 220 | utility.SetFlash(w, "message", []byte(successMessage)) 221 | http.Redirect(w, r, path, 302) 222 | return 223 | } 224 | 225 | utility.SetFlash(w, "error", []byte(invalidError)) 226 | http.Redirect(w, r, path, 302) 227 | return 228 | } 229 | 230 | // AdminSitePageDelete is the route for handling the deletion of a pge 231 | func (br *BeuboRouter) AdminSitePageDelete(w http.ResponseWriter, r *http.Request) { 232 | if !middleware.CanAccess(br.DB, "manage_pages", r) { 233 | w.WriteHeader(http.StatusForbidden) 234 | return 235 | } 236 | 237 | params := mux.Vars(r) 238 | siteID := params["id"] 239 | pageID := params["pageId"] 240 | 241 | pageIDInt, err := strconv.Atoi(pageID) 242 | 243 | utility.ErrorHandler(err, false) 244 | 245 | structs.DeletePage(br.DB, pageIDInt) 246 | 247 | utility.SetFlash(w, "message", []byte("page deleted")) 248 | 249 | http.Redirect(w, r, fmt.Sprintf("/admin/sites/a/%s", siteID), 302) 250 | } 251 | -------------------------------------------------------------------------------- /pkg/routes/plugin.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gorilla/mux" 6 | "github.com/uberswe/beubo/pkg/middleware" 7 | "github.com/uberswe/beubo/pkg/plugin" 8 | "github.com/uberswe/beubo/pkg/structs" 9 | "github.com/uberswe/beubo/pkg/structs/page" 10 | "github.com/uberswe/beubo/pkg/structs/page/component" 11 | "github.com/uberswe/beubo/pkg/utility" 12 | "html/template" 13 | "net/http" 14 | ) 15 | 16 | // AdminPluginEdit is the route for editing a plugin 17 | func (br *BeuboRouter) AdminPluginEdit(w http.ResponseWriter, r *http.Request) { 18 | if !middleware.CanAccess(br.DB, "manage_plugins", r) { 19 | w.WriteHeader(http.StatusForbidden) 20 | return 21 | } 22 | 23 | params := mux.Vars(r) 24 | id := params["id"] 25 | 26 | plugins := plugin.FetchPluginSites(br.DB, id) 27 | 28 | if len(plugins) <= 0 { 29 | br.NotFoundHandler(w, r) 30 | return 31 | } 32 | 33 | var rows []component.Row 34 | for _, p := range plugins { 35 | comprow := component.Row{ 36 | Columns: []component.Column{ 37 | {Name: "Name", Value: p.Site.Title}, 38 | {Field: component.CheckBoxField{ 39 | Name: fmt.Sprintf("%s[]", p.PluginIdentifier), 40 | Identifier: fmt.Sprintf("%s_%d", p.PluginIdentifier, p.Site.ID), 41 | Value: fmt.Sprintf("%d", p.SiteID), 42 | Checked: p.Active, 43 | T: br.Renderer.T, 44 | }}, 45 | }, 46 | } 47 | rows = append(rows, comprow) 48 | } 49 | 50 | button := component.Button{ 51 | Section: "main", 52 | Link: template.URL("/admin/plugins"), 53 | Class: "btn btn-primary", 54 | Content: "Back", 55 | T: br.Renderer.T, 56 | } 57 | 58 | formButton := component.Button{ 59 | Section: "main", 60 | Class: "btn btn-primary", 61 | Content: "Save", 62 | T: br.Renderer.T, 63 | } 64 | 65 | table := component.Table{ 66 | Section: "main", 67 | Header: []component.Column{ 68 | {Name: "Site"}, 69 | {Name: "Active"}, 70 | }, 71 | Rows: rows, 72 | PageNumber: 1, 73 | PageDisplayCount: 10, 74 | T: br.Renderer.T, 75 | } 76 | 77 | form := component.Form{ 78 | Section: "main", 79 | Fields: []page.Component{ 80 | table, 81 | formButton, 82 | }, 83 | T: br.Renderer.T, 84 | Method: "POST", 85 | Action: fmt.Sprintf("/admin/plugins/edit/%s", id), 86 | } 87 | 88 | pageData := structs.PageData{ 89 | Template: "admin.page", 90 | Title: "Admin - Edit Plugin", 91 | Components: []page.Component{ 92 | button, 93 | form, 94 | }, 95 | Themes: br.Renderer.GetThemes(), 96 | } 97 | 98 | br.Renderer.RenderHTMLPage(w, r, pageData) 99 | } 100 | 101 | // AdminPluginEditPost handles editing of plugins 102 | func (br *BeuboRouter) AdminPluginEditPost(w http.ResponseWriter, r *http.Request) { 103 | if !middleware.CanAccess(br.DB, "manage_plugins", r) { 104 | w.WriteHeader(http.StatusForbidden) 105 | return 106 | } 107 | 108 | err := r.ParseForm() 109 | utility.ErrorHandler(err, false) 110 | 111 | params := mux.Vars(r) 112 | id := params["id"] 113 | 114 | plugins := plugin.FetchPluginSites(br.DB, id) 115 | 116 | path := fmt.Sprintf("/admin/plugins/edit/%s", id) 117 | 118 | successMessage := "Plugin updated" 119 | 120 | for key, values := range r.PostForm { 121 | if key == fmt.Sprintf("%s[]", id) { 122 | for _, p := range plugins { 123 | found := false 124 | for _, v := range values { 125 | if v == fmt.Sprintf("%d", p.SiteID) { 126 | found = true 127 | } 128 | } 129 | if found { 130 | // The plugin is active 131 | p.Active = true 132 | } else { 133 | // The plugin is inactive 134 | p.Active = false 135 | } 136 | br.DB.Save(&p) 137 | } 138 | } 139 | } 140 | 141 | // If all sites are set to inactive then we don't get any post data 142 | if len(r.PostForm) <= 0 { 143 | for _, p := range plugins { 144 | p.Active = false 145 | br.DB.Save(&p) 146 | } 147 | } 148 | 149 | utility.SetFlash(w, "message", []byte(successMessage)) 150 | http.Redirect(w, r, path, 302) 151 | } 152 | -------------------------------------------------------------------------------- /pkg/routes/router.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/uberswe/beubo/pkg/plugin" 5 | "github.com/uberswe/beubo/pkg/template" 6 | "gorm.io/gorm" 7 | ) 8 | 9 | // BeuboRouter holds parameters relevant to the router 10 | type BeuboRouter struct { 11 | DB *gorm.DB 12 | Renderer *template.BeuboTemplateRenderer 13 | PluginHandler *plugin.Handler 14 | } 15 | -------------------------------------------------------------------------------- /pkg/routes/setting.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gorilla/mux" 6 | "github.com/uberswe/beubo/pkg/middleware" 7 | "github.com/uberswe/beubo/pkg/structs" 8 | "github.com/uberswe/beubo/pkg/utility" 9 | "net/http" 10 | "strconv" 11 | ) 12 | 13 | // AdminSettingAdd is the route for adding a site 14 | func (br *BeuboRouter) AdminSettingAdd(w http.ResponseWriter, r *http.Request) { 15 | if !middleware.CanAccess(br.DB, "manage_settings", r) { 16 | w.WriteHeader(http.StatusForbidden) 17 | return 18 | } 19 | 20 | pageData := structs.PageData{ 21 | Template: "admin.setting.add", 22 | Title: "Admin - Add Setting", 23 | Themes: br.Renderer.GetThemes(), 24 | } 25 | 26 | br.Renderer.RenderHTMLPage(w, r, pageData) 27 | } 28 | 29 | // AdminSettingAddPost handles adding of a global setting 30 | func (br *BeuboRouter) AdminSettingAddPost(w http.ResponseWriter, r *http.Request) { 31 | if !middleware.CanAccess(br.DB, "manage_settings", r) { 32 | w.WriteHeader(http.StatusForbidden) 33 | return 34 | } 35 | 36 | path := "/admin/settings/add" 37 | 38 | successMessage := "Setting created" 39 | invalidError := "an error occurred and the setting could not be created." 40 | 41 | key := r.FormValue("keyField") 42 | value := r.FormValue("valueField") 43 | 44 | if len(key) < 1 { 45 | invalidError = "The key is too short" 46 | utility.SetFlash(w, "error", []byte(invalidError)) 47 | http.Redirect(w, r, path, 302) 48 | return 49 | } 50 | if len(value) < 1 { 51 | invalidError = "The value is too short" 52 | utility.SetFlash(w, "error", []byte(invalidError)) 53 | http.Redirect(w, r, path, 302) 54 | return 55 | } 56 | 57 | if structs.CreateSetting(br.DB, key, value) { 58 | utility.SetFlash(w, "message", []byte(successMessage)) 59 | http.Redirect(w, r, "/admin/settings", 302) 60 | return 61 | } 62 | 63 | utility.SetFlash(w, "error", []byte(invalidError)) 64 | http.Redirect(w, r, "/admin/settings/add", 302) 65 | } 66 | 67 | // AdminSettingDelete handles the deletion of a global setting 68 | func (br *BeuboRouter) AdminSettingDelete(w http.ResponseWriter, r *http.Request) { 69 | if !middleware.CanAccess(br.DB, "manage_settings", r) { 70 | w.WriteHeader(http.StatusForbidden) 71 | return 72 | } 73 | 74 | params := mux.Vars(r) 75 | id := params["id"] 76 | 77 | i, err := strconv.Atoi(id) 78 | 79 | utility.ErrorHandler(err, false) 80 | 81 | structs.DeleteSetting(br.DB, i) 82 | 83 | utility.SetFlash(w, "message", []byte("Setting deleted")) 84 | 85 | http.Redirect(w, r, "/admin/settings", 302) 86 | } 87 | 88 | // AdminSettingEdit is the route for adding a setting 89 | func (br *BeuboRouter) AdminSettingEdit(w http.ResponseWriter, r *http.Request) { 90 | if !middleware.CanAccess(br.DB, "manage_settings", r) { 91 | w.WriteHeader(http.StatusForbidden) 92 | return 93 | } 94 | 95 | params := mux.Vars(r) 96 | id := params["id"] 97 | 98 | i, err := strconv.Atoi(id) 99 | 100 | utility.ErrorHandler(err, false) 101 | 102 | setting := structs.FetchSetting(br.DB, i) 103 | 104 | if setting.ID == 0 { 105 | br.NotFoundHandler(w, r) 106 | return 107 | } 108 | 109 | pageData := structs.PageData{ 110 | Template: "admin.setting.edit", 111 | Title: "Admin - Edit Site", 112 | Extra: setting, 113 | Themes: br.Renderer.GetThemes(), 114 | } 115 | 116 | br.Renderer.RenderHTMLPage(w, r, pageData) 117 | } 118 | 119 | // AdminSettingEditPost handles editing of a global setting 120 | func (br *BeuboRouter) AdminSettingEditPost(w http.ResponseWriter, r *http.Request) { 121 | if !middleware.CanAccess(br.DB, "manage_settings", r) { 122 | w.WriteHeader(http.StatusForbidden) 123 | return 124 | } 125 | 126 | params := mux.Vars(r) 127 | id := params["id"] 128 | 129 | path := fmt.Sprintf("/admin/settings/edit/%s", id) 130 | 131 | i, err := strconv.Atoi(id) 132 | 133 | utility.ErrorHandler(err, false) 134 | 135 | successMessage := "Setting updated" 136 | invalidError := "an error occurred and the setting could not be updated." 137 | 138 | key := r.FormValue("keyField") 139 | value := r.FormValue("valueField") 140 | 141 | // TODO make rules for models 142 | if len(key) < 1 { 143 | invalidError = "The key is too short" 144 | utility.SetFlash(w, "error", []byte(invalidError)) 145 | http.Redirect(w, r, path, 302) 146 | return 147 | } 148 | if len(value) < 1 { 149 | invalidError = "The value is too short" 150 | utility.SetFlash(w, "error", []byte(invalidError)) 151 | http.Redirect(w, r, path, 302) 152 | return 153 | } 154 | 155 | if structs.UpdateSetting(br.DB, i, key, value) { 156 | utility.SetFlash(w, "message", []byte(successMessage)) 157 | http.Redirect(w, r, "/admin/settings", 302) 158 | return 159 | } 160 | 161 | utility.SetFlash(w, "error", []byte(invalidError)) 162 | http.Redirect(w, r, path, 302) 163 | } 164 | -------------------------------------------------------------------------------- /pkg/routes/site.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gorilla/mux" 6 | "github.com/uberswe/beubo/pkg/middleware" 7 | "github.com/uberswe/beubo/pkg/structs" 8 | "github.com/uberswe/beubo/pkg/utility" 9 | "html" 10 | "net/http" 11 | "strconv" 12 | "strings" 13 | ) 14 | 15 | // SiteAdmin is the main page for the admin area and shows a list of pages 16 | func (br *BeuboRouter) SiteAdmin(w http.ResponseWriter, r *http.Request) { 17 | if !middleware.CanAccess(br.DB, "manage_pages", r) { 18 | w.WriteHeader(http.StatusForbidden) 19 | return 20 | } 21 | 22 | params := mux.Vars(r) 23 | id := params["id"] 24 | 25 | i, err := strconv.Atoi(id) 26 | 27 | utility.ErrorHandler(err, false) 28 | 29 | site := structs.FetchSite(br.DB, i) 30 | 31 | self := r.Context().Value(middleware.UserContextKey) 32 | if self != nil && self.(structs.User).ID > 0 { 33 | if !self.(structs.User).CanAccessSite(br.DB, site) { 34 | w.WriteHeader(http.StatusForbidden) 35 | return 36 | } 37 | } 38 | 39 | var pages []structs.Page 40 | 41 | extra := make(map[string]interface{}) 42 | pagesRes := make(map[string]map[string]string) 43 | extra["SiteID"] = fmt.Sprintf("%d", site.ID) 44 | 45 | if err := br.DB.Where("site_id = ?", site.ID).Find(&pages).Error; err != nil { 46 | utility.ErrorHandler(err, false) 47 | } 48 | 49 | for _, page := range pages { 50 | pid := fmt.Sprintf("%d", page.ID) 51 | pagesRes[pid] = make(map[string]string) 52 | pagesRes[pid]["id"] = pid 53 | pagesRes[pid]["title"] = html.EscapeString(page.Title) 54 | pagesRes[pid]["slug"] = html.EscapeString(page.Slug) 55 | } 56 | extra["pagesRes"] = pagesRes 57 | 58 | pageData := structs.PageData{ 59 | Template: "admin.site.page.home", 60 | Title: "Admin", 61 | Extra: extra, 62 | } 63 | 64 | br.Renderer.RenderHTMLPage(w, r, pageData) 65 | } 66 | 67 | // AdminSiteAdd is the route for adding a site 68 | func (br *BeuboRouter) AdminSiteAdd(w http.ResponseWriter, r *http.Request) { 69 | if !middleware.CanAccess(br.DB, "manage_sites", r) { 70 | w.WriteHeader(http.StatusForbidden) 71 | return 72 | } 73 | 74 | pageData := structs.PageData{ 75 | Template: "admin.site.add", 76 | Title: "Admin - Add Site", 77 | Themes: br.Renderer.GetThemes(), 78 | } 79 | 80 | br.Renderer.RenderHTMLPage(w, r, pageData) 81 | } 82 | 83 | // AdminSiteAddPost handles the post request for adding a site 84 | func (br *BeuboRouter) AdminSiteAddPost(w http.ResponseWriter, r *http.Request) { 85 | if !middleware.CanAccess(br.DB, "manage_sites", r) { 86 | w.WriteHeader(http.StatusForbidden) 87 | return 88 | } 89 | 90 | path := "/admin/sites/add" 91 | 92 | successMessage := "Site created" 93 | invalidError := "an error occurred and the site could not be created." 94 | 95 | themeID := 0 96 | title := r.FormValue("titleField") 97 | domain := r.FormValue("domainField") 98 | destDomain := r.FormValue("destinationField") 99 | // typeField 100 | // 1 - Beubo hosted site 101 | // 2 - HTML files from directory 102 | // 3 - redirect to a different domain 103 | siteType := r.FormValue("typeField") 104 | 105 | typeID, err := strconv.Atoi(siteType) 106 | 107 | utility.ErrorHandler(err, false) 108 | // Theme is only relevant for Beubo hosted sites 109 | if siteType == "1" { 110 | theme := r.FormValue("themeField") 111 | themeStruct := structs.FetchThemeBySlug(br.DB, theme) 112 | if themeStruct.ID == 0 { 113 | invalidError = "The theme is invalid" 114 | utility.SetFlash(w, "error", []byte(invalidError)) 115 | http.Redirect(w, r, path, 302) 116 | return 117 | } 118 | themeID = int(themeStruct.ID) 119 | } 120 | 121 | domain = strings.ToLower(domain) 122 | domain = utility.TrimWhitespace(domain) 123 | 124 | destDomain = strings.ToLower(destDomain) 125 | destDomain = utility.TrimWhitespace(destDomain) 126 | 127 | if len(title) < 1 { 128 | invalidError = "The title is too short" 129 | utility.SetFlash(w, "error", []byte(invalidError)) 130 | http.Redirect(w, r, path, 302) 131 | return 132 | } 133 | if len(domain) < 1 { 134 | invalidError = "The domain is too short" 135 | utility.SetFlash(w, "error", []byte(invalidError)) 136 | http.Redirect(w, r, path, 302) 137 | return 138 | } 139 | 140 | if site := structs.CreateSite(br.DB, title, domain, typeID, themeID, destDomain); site.ID > 0 { 141 | self := r.Context().Value(middleware.UserContextKey) 142 | if self != nil && self.(structs.User).ID > 0 { 143 | selfUser := self.(structs.User) 144 | site.Users = []*structs.User{ 145 | &selfUser, 146 | } 147 | br.DB.Save(&site) 148 | } 149 | 150 | utility.SetFlash(w, "message", []byte(successMessage)) 151 | http.Redirect(w, r, "/admin/", 302) 152 | return 153 | } 154 | 155 | utility.SetFlash(w, "error", []byte(invalidError)) 156 | http.Redirect(w, r, "/admin/sites/add", 302) 157 | } 158 | 159 | // AdminSiteDelete is the route for deleting a site 160 | func (br *BeuboRouter) AdminSiteDelete(w http.ResponseWriter, r *http.Request) { 161 | if !middleware.CanAccess(br.DB, "manage_sites", r) { 162 | w.WriteHeader(http.StatusForbidden) 163 | return 164 | } 165 | 166 | params := mux.Vars(r) 167 | id := params["id"] 168 | i, err := strconv.Atoi(id) 169 | utility.ErrorHandler(err, false) 170 | site := structs.FetchSite(br.DB, i) 171 | self := r.Context().Value(middleware.UserContextKey) 172 | if self != nil && self.(structs.User).ID > 0 { 173 | if !self.(structs.User).CanAccessSite(br.DB, site) { 174 | w.WriteHeader(http.StatusForbidden) 175 | return 176 | } 177 | } 178 | 179 | structs.DeleteSite(br.DB, i) 180 | 181 | utility.SetFlash(w, "message", []byte("Site deleted")) 182 | 183 | http.Redirect(w, r, "/admin/", 302) 184 | } 185 | 186 | // AdminSiteEdit is the route for adding a site 187 | func (br *BeuboRouter) AdminSiteEdit(w http.ResponseWriter, r *http.Request) { 188 | if !middleware.CanAccess(br.DB, "manage_sites", r) { 189 | w.WriteHeader(http.StatusForbidden) 190 | return 191 | } 192 | 193 | params := mux.Vars(r) 194 | id := params["id"] 195 | 196 | i, err := strconv.Atoi(id) 197 | 198 | utility.ErrorHandler(err, false) 199 | 200 | site := structs.FetchSite(br.DB, i) 201 | 202 | if site.ID == 0 { 203 | br.NotFoundHandler(w, r) 204 | return 205 | } 206 | 207 | self := r.Context().Value(middleware.UserContextKey) 208 | if self != nil && self.(structs.User).ID > 0 { 209 | if !self.(structs.User).CanAccessSite(br.DB, site) { 210 | w.WriteHeader(http.StatusForbidden) 211 | return 212 | } 213 | } 214 | 215 | pageData := structs.PageData{ 216 | Template: "admin.site.edit", 217 | Title: "Admin - Edit Site", 218 | Extra: site, 219 | Themes: br.Renderer.GetThemes(), 220 | } 221 | 222 | br.Renderer.RenderHTMLPage(w, r, pageData) 223 | } 224 | 225 | // AdminSiteEditPost handles editing of a site 226 | func (br *BeuboRouter) AdminSiteEditPost(w http.ResponseWriter, r *http.Request) { 227 | if !middleware.CanAccess(br.DB, "manage_sites", r) { 228 | w.WriteHeader(http.StatusForbidden) 229 | return 230 | } 231 | 232 | params := mux.Vars(r) 233 | id := params["id"] 234 | 235 | path := fmt.Sprintf("/admin/sites/edit/%s", id) 236 | 237 | i, err := strconv.Atoi(id) 238 | 239 | utility.ErrorHandler(err, false) 240 | site := structs.FetchSite(br.DB, i) 241 | self := r.Context().Value(middleware.UserContextKey) 242 | if self != nil && self.(structs.User).ID > 0 { 243 | if !self.(structs.User).CanAccessSite(br.DB, site) { 244 | w.WriteHeader(http.StatusForbidden) 245 | return 246 | } 247 | } 248 | 249 | successMessage := "Site updated" 250 | invalidError := "an error occurred and the site could not be updated." 251 | 252 | themeID := 0 253 | title := r.FormValue("titleField") 254 | domain := r.FormValue("domainField") 255 | destDomain := r.FormValue("destinationField") 256 | // typeField 257 | // 1 - Beubo hosted site 258 | // 2 - HTML files from directory 259 | // 3 - redirect to a different domain 260 | siteType := r.FormValue("typeField") 261 | 262 | typeID, err := strconv.Atoi(siteType) 263 | 264 | utility.ErrorHandler(err, false) 265 | // Theme is only relevant for Beubo hosted sites 266 | if siteType == "1" { 267 | theme := r.FormValue("themeField") 268 | themeStruct := structs.FetchThemeBySlug(br.DB, theme) 269 | if themeStruct.ID == 0 { 270 | invalidError = "The theme is invalid" 271 | utility.SetFlash(w, "error", []byte(invalidError)) 272 | http.Redirect(w, r, path, 302) 273 | return 274 | } 275 | themeID = int(themeStruct.ID) 276 | } 277 | 278 | domain = strings.ToLower(domain) 279 | domain = utility.TrimWhitespace(domain) 280 | 281 | destDomain = strings.ToLower(destDomain) 282 | destDomain = utility.TrimWhitespace(destDomain) 283 | 284 | if len(title) < 1 { 285 | invalidError = "The title is too short" 286 | utility.SetFlash(w, "error", []byte(invalidError)) 287 | http.Redirect(w, r, path, 302) 288 | return 289 | } 290 | if len(domain) < 1 { 291 | invalidError = "The domain is too short" 292 | utility.SetFlash(w, "error", []byte(invalidError)) 293 | http.Redirect(w, r, path, 302) 294 | return 295 | } 296 | 297 | if structs.UpdateSite(br.DB, i, title, domain, typeID, themeID, destDomain) { 298 | utility.SetFlash(w, "message", []byte(successMessage)) 299 | http.Redirect(w, r, path, 302) 300 | return 301 | } 302 | 303 | utility.SetFlash(w, "error", []byte(invalidError)) 304 | http.Redirect(w, r, path, 302) 305 | } 306 | -------------------------------------------------------------------------------- /pkg/routes/user.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gorilla/mux" 6 | "github.com/uberswe/beubo/pkg/middleware" 7 | "github.com/uberswe/beubo/pkg/structs" 8 | "github.com/uberswe/beubo/pkg/structs/page" 9 | "github.com/uberswe/beubo/pkg/structs/page/component" 10 | "github.com/uberswe/beubo/pkg/utility" 11 | "html" 12 | "html/template" 13 | "net/http" 14 | "strconv" 15 | ) 16 | 17 | type userAddData struct { 18 | Roles []structs.Role 19 | Sites []structs.Site 20 | } 21 | 22 | // AdminUserAdd is the route for adding a user 23 | func (br *BeuboRouter) AdminUserAdd(w http.ResponseWriter, r *http.Request) { 24 | if !middleware.CanAccess(br.DB, "manage_users", r) { 25 | w.WriteHeader(http.StatusForbidden) 26 | return 27 | } 28 | 29 | roles := []structs.Role{} 30 | sites := []structs.Site{} 31 | br.DB.Find(&roles) 32 | br.DB.Find(&sites) 33 | pageData := structs.PageData{ 34 | Template: "admin.user.add", 35 | Title: "Admin - Add User", 36 | Themes: br.Renderer.GetThemes(), 37 | Extra: userAddData{ 38 | Roles: roles, 39 | Sites: sites, 40 | }, 41 | } 42 | 43 | br.Renderer.RenderHTMLPage(w, r, pageData) 44 | } 45 | 46 | // AdminUserAddPost handles adding of a user 47 | func (br *BeuboRouter) AdminUserAddPost(w http.ResponseWriter, r *http.Request) { 48 | if !middleware.CanAccess(br.DB, "manage_users", r) { 49 | w.WriteHeader(http.StatusForbidden) 50 | return 51 | } 52 | 53 | path := "/admin/users/add" 54 | 55 | successMessage := "User created" 56 | invalidError := "an error occurred and the user could not be created." 57 | 58 | email := r.FormValue("emailField") 59 | password := r.FormValue("passwordField") 60 | 61 | rs := []int{} 62 | ss := []int{} 63 | 64 | for key, values := range r.PostForm { 65 | if key == fmt.Sprintf("%s[]", "roleField") { 66 | for _, value := range values { 67 | valueInt, err := strconv.Atoi(value) 68 | utility.ErrorHandler(err, false) 69 | rs = append(rs, valueInt) 70 | } 71 | } else if key == fmt.Sprintf("%s[]", "siteField") { 72 | for _, value := range values { 73 | valueInt, err := strconv.Atoi(value) 74 | utility.ErrorHandler(err, false) 75 | ss = append(ss, valueInt) 76 | } 77 | } 78 | } 79 | 80 | roles := []*structs.Role{} 81 | if len(rs) > 0 { 82 | br.DB.Find(&roles, rs) 83 | } 84 | sites := []*structs.Site{} 85 | if len(ss) > 0 { 86 | br.DB.Find(&sites, ss) 87 | } 88 | 89 | if !utility.IsEmailValid(email) { 90 | invalidError = "The email is invalid" 91 | utility.SetFlash(w, "error", []byte(invalidError)) 92 | http.Redirect(w, r, path, 302) 93 | return 94 | } 95 | 96 | if len(password) < 8 { 97 | invalidError = "The password is too short" 98 | utility.SetFlash(w, "error", []byte(invalidError)) 99 | http.Redirect(w, r, path, 302) 100 | return 101 | } 102 | 103 | if structs.CreateUser(br.DB, email, password, roles, sites) { 104 | utility.SetFlash(w, "message", []byte(successMessage)) 105 | http.Redirect(w, r, "/admin/users", 302) 106 | return 107 | } 108 | 109 | utility.SetFlash(w, "error", []byte(invalidError)) 110 | http.Redirect(w, r, "/admin/users/add", 302) 111 | } 112 | 113 | // AdminUserDelete handles the deletion of a user 114 | func (br *BeuboRouter) AdminUserDelete(w http.ResponseWriter, r *http.Request) { 115 | if !middleware.CanAccess(br.DB, "manage_users", r) { 116 | w.WriteHeader(http.StatusForbidden) 117 | return 118 | } 119 | 120 | params := mux.Vars(r) 121 | id := params["id"] 122 | 123 | i, err := strconv.Atoi(id) 124 | 125 | utility.ErrorHandler(err, false) 126 | 127 | structs.DeleteUser(br.DB, i) 128 | 129 | utility.SetFlash(w, "message", []byte("User deleted")) 130 | 131 | http.Redirect(w, r, "/admin/users", 302) 132 | } 133 | 134 | type userEdit struct { 135 | User structs.User 136 | Role structs.Role 137 | Roles []userEditRole 138 | Features []userEditFeature 139 | Sites []userEditSite 140 | } 141 | 142 | type userEditRole struct { 143 | Role structs.Role 144 | Checked bool 145 | } 146 | 147 | type userEditSite struct { 148 | Site structs.Site 149 | Checked bool 150 | } 151 | 152 | // AdminUserEdit is the route for adding a user 153 | func (br *BeuboRouter) AdminUserEdit(w http.ResponseWriter, r *http.Request) { 154 | if !middleware.CanAccess(br.DB, "manage_users", r) { 155 | w.WriteHeader(http.StatusForbidden) 156 | return 157 | } 158 | 159 | params := mux.Vars(r) 160 | id := params["id"] 161 | 162 | i, err := strconv.Atoi(id) 163 | 164 | utility.ErrorHandler(err, false) 165 | 166 | user := structs.FetchUser(br.DB, i) 167 | 168 | if user.ID == 0 { 169 | br.NotFoundHandler(w, r) 170 | return 171 | } 172 | 173 | ueRoles := []userEditRole{} 174 | roles := []structs.Role{} 175 | br.DB.Find(&roles) 176 | for _, role := range roles { 177 | ueRoles = append(ueRoles, userEditRole{Role: role, Checked: user.HasRole(br.DB, role)}) 178 | } 179 | 180 | ueSites := []userEditSite{} 181 | sites := []structs.Site{} 182 | br.DB.Find(&sites) 183 | for _, site := range sites { 184 | ueSites = append(ueSites, userEditSite{Site: site, Checked: user.CanAccessSite(br.DB, site)}) 185 | } 186 | 187 | pageData := structs.PageData{ 188 | Template: "admin.user.edit", 189 | Title: "Admin - Edit Site", 190 | Extra: userEdit{ 191 | User: user, 192 | Roles: ueRoles, 193 | Sites: ueSites, 194 | }, 195 | Themes: br.Renderer.GetThemes(), 196 | } 197 | 198 | br.Renderer.RenderHTMLPage(w, r, pageData) 199 | } 200 | 201 | // AdminUserEditPost handles editing of a user 202 | func (br *BeuboRouter) AdminUserEditPost(w http.ResponseWriter, r *http.Request) { 203 | if !middleware.CanAccess(br.DB, "manage_users", r) { 204 | w.WriteHeader(http.StatusForbidden) 205 | return 206 | } 207 | 208 | params := mux.Vars(r) 209 | id := params["id"] 210 | 211 | path := fmt.Sprintf("/admin/users/edit/%s", id) 212 | 213 | i, err := strconv.Atoi(id) 214 | 215 | utility.ErrorHandler(err, false) 216 | 217 | successMessage := "User updated" 218 | invalidError := "an error occurred and the user could not be updated." 219 | 220 | // TODO make rules for models 221 | email := r.FormValue("emailField") 222 | password := r.FormValue("passwordField") 223 | 224 | rs := []int{} 225 | ss := []int{} 226 | 227 | for key, values := range r.PostForm { 228 | if key == fmt.Sprintf("%s[]", "roleField") { 229 | for _, value := range values { 230 | valueInt, err := strconv.Atoi(value) 231 | utility.ErrorHandler(err, false) 232 | rs = append(rs, valueInt) 233 | } 234 | } else if key == fmt.Sprintf("%s[]", "siteField") { 235 | for _, value := range values { 236 | valueInt, err := strconv.Atoi(value) 237 | utility.ErrorHandler(err, false) 238 | ss = append(ss, valueInt) 239 | } 240 | } 241 | } 242 | 243 | roles := []*structs.Role{} 244 | if len(rs) > 0 { 245 | br.DB.Find(&roles, rs) 246 | } 247 | sites := []*structs.Site{} 248 | if len(ss) > 0 { 249 | br.DB.Find(&sites, ss) 250 | } 251 | 252 | if !utility.IsEmailValid(email) { 253 | invalidError = "The email is invalid" 254 | utility.SetFlash(w, "error", []byte(invalidError)) 255 | http.Redirect(w, r, path, 302) 256 | return 257 | } 258 | if len(password) > 0 && len(password) < 8 { 259 | invalidError = "The password is too short" 260 | utility.SetFlash(w, "error", []byte(invalidError)) 261 | http.Redirect(w, r, path, 302) 262 | return 263 | } 264 | 265 | if structs.UpdateUser(br.DB, i, email, password, roles, sites) { 266 | utility.SetFlash(w, "message", []byte(successMessage)) 267 | http.Redirect(w, r, "/admin/users", 302) 268 | return 269 | } 270 | 271 | utility.SetFlash(w, "error", []byte(invalidError)) 272 | http.Redirect(w, r, path, 302) 273 | } 274 | 275 | // AdminUserRoles is the route for managing user roles 276 | func (br *BeuboRouter) AdminUserRoles(w http.ResponseWriter, r *http.Request) { 277 | if !middleware.CanAccess(br.DB, "manage_users", r) { 278 | w.WriteHeader(http.StatusForbidden) 279 | return 280 | } 281 | 282 | var roles []structs.Role 283 | 284 | if err := br.DB.Find(&roles).Error; err != nil { 285 | utility.ErrorHandler(err, false) 286 | } 287 | 288 | var rows []component.Row 289 | for _, role := range roles { 290 | sid := fmt.Sprintf("%d", role.ID) 291 | // TODO For now it's not possible to edit default roles. This is because they would be added again when launching Beubo and this needs to be handled in a better way. 292 | if role.Name == "Administrator" || role.Name == "Member" { 293 | rows = append(rows, component.Row{ 294 | Columns: []component.Column{ 295 | {Name: "ID", Value: sid}, 296 | {Name: "Name", Value: html.EscapeString(role.Name)}, 297 | {}, 298 | {}, 299 | }, 300 | }) 301 | } else { 302 | rows = append(rows, component.Row{ 303 | Columns: []component.Column{ 304 | {Name: "ID", Value: sid}, 305 | {Name: "Name", Value: html.EscapeString(role.Name)}, 306 | {Name: "", Field: component.Button{ 307 | Link: template.URL(fmt.Sprintf("/admin/users/roles/edit/%s", sid)), 308 | Class: "btn btn-primary", 309 | Content: "Edit", 310 | T: br.Renderer.T, 311 | }}, 312 | {Name: "", Field: component.Button{ 313 | Link: template.URL(fmt.Sprintf("/admin/users/roles/delete/%s", sid)), 314 | Class: "btn btn-primary", 315 | Content: "Delete", 316 | T: br.Renderer.T, 317 | }}, 318 | }, 319 | }) 320 | } 321 | } 322 | 323 | table := component.Table{ 324 | Section: "main", 325 | Header: []component.Column{ 326 | {Name: "ID"}, 327 | {Name: "Name"}, 328 | {Name: ""}, 329 | {Name: ""}, 330 | }, 331 | Rows: rows, 332 | PageNumber: 1, 333 | PageDisplayCount: 10, 334 | T: br.Renderer.T, 335 | } 336 | 337 | pageData := structs.PageData{ 338 | Template: "admin.page", 339 | Title: "Admin - User Roles", 340 | Components: []page.Component{ 341 | component.Button{ 342 | Section: "main", 343 | Link: template.URL("/admin/users/roles/add"), 344 | Class: "btn btn-primary", 345 | Content: "Add Role", 346 | T: br.Renderer.T, 347 | }, 348 | table, 349 | }, 350 | } 351 | 352 | br.Renderer.RenderHTMLPage(w, r, pageData) 353 | } 354 | 355 | // AdminUserRoleAdd is the route for adding a user role 356 | func (br *BeuboRouter) AdminUserRoleAdd(w http.ResponseWriter, r *http.Request) { 357 | if !middleware.CanAccess(br.DB, "manage_user_roles", r) { 358 | w.WriteHeader(http.StatusForbidden) 359 | return 360 | } 361 | 362 | features := []structs.Feature{} 363 | br.DB.Find(&features) 364 | 365 | pageData := structs.PageData{ 366 | Template: "admin.user.role.add", 367 | Title: "Admin - Add Role", 368 | Themes: br.Renderer.GetThemes(), 369 | Extra: features, 370 | } 371 | 372 | br.Renderer.RenderHTMLPage(w, r, pageData) 373 | } 374 | 375 | // AdminUserRoleAddPost handles adding of a user role 376 | func (br *BeuboRouter) AdminUserRoleAddPost(w http.ResponseWriter, r *http.Request) { 377 | if !middleware.CanAccess(br.DB, "manage_user_roles", r) { 378 | w.WriteHeader(http.StatusForbidden) 379 | return 380 | } 381 | 382 | path := "/admin/users/roles/add" 383 | 384 | successMessage := "Role created" 385 | invalidError := "an error occurred and the user could not be created." 386 | 387 | name := r.FormValue("nameField") 388 | 389 | if len(name) < 2 { 390 | invalidError = "The name is too short" 391 | utility.SetFlash(w, "error", []byte(invalidError)) 392 | http.Redirect(w, r, path, 302) 393 | return 394 | } 395 | 396 | if structs.CreateRole(br.DB, name) { 397 | utility.SetFlash(w, "message", []byte(successMessage)) 398 | http.Redirect(w, r, "/admin/users/roles", 302) 399 | return 400 | } 401 | 402 | utility.SetFlash(w, "error", []byte(invalidError)) 403 | http.Redirect(w, r, "/admin/users/roles/add", 302) 404 | } 405 | 406 | // AdminUserRoleDelete handles the deletion of a user role 407 | func (br *BeuboRouter) AdminUserRoleDelete(w http.ResponseWriter, r *http.Request) { 408 | if !middleware.CanAccess(br.DB, "manage_user_roles", r) { 409 | w.WriteHeader(http.StatusForbidden) 410 | return 411 | } 412 | 413 | params := mux.Vars(r) 414 | id := params["id"] 415 | 416 | i, err := strconv.Atoi(id) 417 | 418 | utility.ErrorHandler(err, false) 419 | 420 | role := structs.FetchRole(br.DB, i) 421 | if role.ID != 0 && !role.IsDefault() { 422 | structs.DeleteRole(br.DB, i) 423 | } else { 424 | utility.SetFlash(w, "error", []byte("Can not delete a default role")) 425 | http.Redirect(w, r, "/admin/users/roles", 302) 426 | return 427 | } 428 | 429 | utility.SetFlash(w, "message", []byte("Role deleted")) 430 | 431 | http.Redirect(w, r, "/admin/users/roles", 302) 432 | } 433 | 434 | type userEditFeature struct { 435 | Feature structs.Feature 436 | Checked bool 437 | } 438 | 439 | // AdminUserRoleEdit is the route for adding a user role 440 | func (br *BeuboRouter) AdminUserRoleEdit(w http.ResponseWriter, r *http.Request) { 441 | if !middleware.CanAccess(br.DB, "manage_user_roles", r) { 442 | w.WriteHeader(http.StatusForbidden) 443 | return 444 | } 445 | 446 | params := mux.Vars(r) 447 | id := params["id"] 448 | 449 | i, err := strconv.Atoi(id) 450 | 451 | utility.ErrorHandler(err, false) 452 | 453 | role := structs.FetchRole(br.DB, i) 454 | if role.ID == 0 || role.IsDefault() { 455 | br.NotFoundHandler(w, r) 456 | return 457 | } 458 | 459 | ueFeatures := []userEditFeature{} 460 | features := []structs.Feature{} 461 | br.DB.Find(&features) 462 | for _, feature := range features { 463 | ueFeatures = append(ueFeatures, userEditFeature{Feature: feature, Checked: role.HasFeature(br.DB, feature)}) 464 | } 465 | 466 | pageData := structs.PageData{ 467 | Template: "admin.user.role.edit", 468 | Title: "Admin - Edit Role", 469 | Extra: userEdit{Features: ueFeatures, Role: role}, 470 | Themes: br.Renderer.GetThemes(), 471 | } 472 | 473 | br.Renderer.RenderHTMLPage(w, r, pageData) 474 | } 475 | 476 | // AdminUserRoleEditPost handles editing of a user role 477 | func (br *BeuboRouter) AdminUserRoleEditPost(w http.ResponseWriter, r *http.Request) { 478 | if !middleware.CanAccess(br.DB, "manage_user_roles", r) { 479 | w.WriteHeader(http.StatusForbidden) 480 | return 481 | } 482 | 483 | err := r.ParseForm() 484 | utility.ErrorHandler(err, false) 485 | params := mux.Vars(r) 486 | id := params["id"] 487 | 488 | path := fmt.Sprintf("/admin/users/roles/edit/%s", id) 489 | 490 | i, err := strconv.Atoi(id) 491 | 492 | utility.ErrorHandler(err, false) 493 | 494 | successMessage := "Role updated" 495 | invalidError := "an error occurred and the role could not be updated." 496 | 497 | fs := []int{} 498 | 499 | name := r.FormValue("nameField") 500 | for key, values := range r.PostForm { 501 | if key == fmt.Sprintf("%s[]", "featureField") { 502 | for _, value := range values { 503 | valueInt, err := strconv.Atoi(value) 504 | utility.ErrorHandler(err, false) 505 | fs = append(fs, valueInt) 506 | } 507 | } 508 | } 509 | 510 | features := []*structs.Feature{} 511 | if len(fs) > 0 { 512 | br.DB.Find(&features, fs) 513 | } 514 | 515 | if len(name) < 2 { 516 | invalidError = "The name is too short" 517 | utility.SetFlash(w, "error", []byte(invalidError)) 518 | http.Redirect(w, r, path, 302) 519 | return 520 | } 521 | 522 | if structs.UpdateRole(br.DB, i, name, features) { 523 | utility.SetFlash(w, "message", []byte(successMessage)) 524 | http.Redirect(w, r, "/admin/users/roles", 302) 525 | return 526 | } 527 | 528 | utility.SetFlash(w, "error", []byte(invalidError)) 529 | http.Redirect(w, r, path, 302) 530 | } 531 | -------------------------------------------------------------------------------- /pkg/structs/README.md: -------------------------------------------------------------------------------- 1 | ## Models 2 | 3 | This folder should hold all Beubo structs -------------------------------------------------------------------------------- /pkg/structs/config.go: -------------------------------------------------------------------------------- 1 | package structs 2 | 3 | import "gorm.io/gorm" 4 | 5 | // Config is a key value store for various settings and configurations 6 | type Config struct { 7 | gorm.Model 8 | Key string `gorm:"size:255;unique_index"` 9 | Value string `sql:"type:text"` 10 | } 11 | -------------------------------------------------------------------------------- /pkg/structs/page.go: -------------------------------------------------------------------------------- 1 | package structs 2 | 3 | import ( 4 | "fmt" 5 | "github.com/uberswe/beubo/pkg/structs/page" 6 | "gorm.io/gorm" 7 | "html/template" 8 | ) 9 | 10 | // Page represents the content of a page, I wanted to go with the concept of having everything be a post even if it's a page, contact form or product 11 | type Page struct { 12 | gorm.Model 13 | Title string `gorm:"size:255"` 14 | Content string `sql:"type:text"` 15 | Description string `sql:"type:text"` 16 | Excerpt string `sql:"type:text"` 17 | Slug string `gorm:"size:255;unique_index:idx_slug_site_id"` 18 | Template string `gorm:"size:255"` 19 | Site Site 20 | SiteID int `gorm:"unique_index:idx_slug_site_id"` 21 | Tags []Tag `gorm:"many2many:page_tags;"` 22 | } 23 | 24 | // PageData is a general structure that holds all data that can be displayed on a page 25 | // using go html templates 26 | type PageData struct { 27 | Theme string 28 | Template string 29 | Templates map[string]string 30 | Themes map[string]string 31 | Title string 32 | WebsiteName string 33 | URL string 34 | Error string 35 | Warning string 36 | Message string 37 | Year string 38 | Stylesheets []string 39 | Scripts []string 40 | Favicon string 41 | Extra interface{} 42 | Components []page.Component 43 | Menus []page.Menu 44 | } 45 | 46 | // Tag of a post can be used for post categories or things like meta tag keywords for example 47 | type Tag struct { 48 | gorm.Model 49 | Value string `gorm:"unique;not null"` 50 | } 51 | 52 | // JSONTag is used for responses where tags are shown 53 | // TODO is this redundant if we can use Tag? 54 | type JSONTag struct { 55 | Value string `json:"value"` 56 | } 57 | 58 | // Comment can be related to a post created by a user 59 | type Comment struct { 60 | gorm.Model 61 | User User 62 | UserID int 63 | Email string 64 | Website string 65 | Text string 66 | Page Page 67 | PageID int 68 | } 69 | 70 | // CreatePage is a method which creates a page using gorm 71 | func CreatePage(db *gorm.DB, title string, slug string, tags []Tag, template string, content string, siteID int) bool { 72 | pageData := Page{ 73 | Title: title, 74 | Content: content, 75 | Slug: slug, 76 | Template: template, 77 | SiteID: siteID, 78 | Tags: tags, 79 | } 80 | 81 | if err := db.Create(&pageData).Error; err != nil { 82 | fmt.Println("Could not create pageData") 83 | return false 84 | } 85 | return true 86 | } 87 | 88 | // FetchPage gets a page based on the provided id from the database 89 | func FetchPage(db *gorm.DB, id int) Page { 90 | pageData := Page{} 91 | 92 | db.Preload("Tags").First(&pageData, id) 93 | 94 | return pageData 95 | } 96 | 97 | // FetchPageBySiteIDAndSlug gets a page based on the site id and slug from the database 98 | func FetchPageBySiteIDAndSlug(db *gorm.DB, SiteID int, slug string) Page { 99 | pageData := Page{} 100 | db.Where("slug = ? AND site_id = ?", slug, SiteID).First(&pageData) 101 | return pageData 102 | } 103 | 104 | // UpdatePage is a method which updates a page in the database with relevant data 105 | func UpdatePage(db *gorm.DB, id int, title string, slug string, tags []Tag, template string, content string, siteID int) bool { 106 | pageData := FetchPage(db, id) 107 | 108 | db.Model(&pageData).Association("Tags").Clear() 109 | 110 | pageData.Title = title 111 | pageData.Slug = slug 112 | pageData.Content = content 113 | pageData.Template = template 114 | pageData.SiteID = siteID 115 | pageData.Tags = tags 116 | 117 | if err := db.Save(&pageData).Error; err != nil { 118 | fmt.Println("Could not create site") 119 | return false 120 | } 121 | return true 122 | } 123 | 124 | // DeletePage deletes a page with the provided id from the database 125 | func DeletePage(db *gorm.DB, id int) Page { 126 | pageData := FetchPage(db, id) 127 | db.Delete(&pageData) 128 | return pageData 129 | } 130 | 131 | // Content renders components for a page for the specified section 132 | func (pd PageData) Content(section string) template.HTML { 133 | result := "" 134 | for _, component := range pd.Components { 135 | if component.GetSection() == section { 136 | result += component.Render() 137 | } 138 | } 139 | return template.HTML(result) 140 | } 141 | 142 | // Menu renders a menu for the provided section 143 | func (pd PageData) Menu(section string) template.HTML { 144 | result := "" 145 | for _, menu := range pd.Menus { 146 | if menu.GetIdentifier() == section { 147 | return template.HTML(menu.Render()) 148 | } 149 | } 150 | return template.HTML(result) 151 | } 152 | -------------------------------------------------------------------------------- /pkg/structs/page/component.go: -------------------------------------------------------------------------------- 1 | package page 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | "log" 8 | ) 9 | 10 | // Component is anything that can be rendered on a page. A text field is a component but so is the form the text field is a part of. 11 | type Component interface { 12 | GetSection() string 13 | // render returns a html template string with the content of the field 14 | Render() string 15 | GetTemplateName() string 16 | GetTheme() string 17 | GetTemplate() *template.Template 18 | } 19 | 20 | // RenderComponent takes the provided component and finds the relevant template and renders this into a string 21 | func RenderComponent(c Component) string { 22 | path := fmt.Sprintf("%s.%s", c.GetTheme(), c.GetTemplateName()) 23 | var foundTemplate *template.Template 24 | if c.GetTemplate() == nil { 25 | return "" 26 | } 27 | if foundTemplate = c.GetTemplate().Lookup(path); foundTemplate == nil { 28 | log.Printf("Component file not found %s\n", path) 29 | return "" 30 | } 31 | buf := &bytes.Buffer{} 32 | err := foundTemplate.Execute(buf, c) 33 | if err != nil { 34 | log.Printf("Component file error executing template %s\n", path) 35 | return "" 36 | } 37 | return buf.String() 38 | } 39 | -------------------------------------------------------------------------------- /pkg/structs/page/component/button.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "github.com/uberswe/beubo/pkg/structs/page" 5 | "html/template" 6 | ) 7 | 8 | // Button is a beubo component that can be rendered using HTML templates 9 | type Button struct { 10 | Section string 11 | Theme string 12 | Template string 13 | Class string 14 | Content string 15 | Link template.URL 16 | T *template.Template 17 | } 18 | 19 | // GetSection is a getter for the Section property 20 | func (b Button) GetSection() string { 21 | return b.Section 22 | } 23 | 24 | // GetTemplateName is a getter for the Template Property 25 | func (b Button) GetTemplateName() string { 26 | return returnTIfNotEmpty(b.Template, "component.button") 27 | } 28 | 29 | // GetTheme is a getter for the Theme Property 30 | func (b Button) GetTheme() string { 31 | return returnTIfNotEmpty(b.Theme, "default") 32 | } 33 | 34 | // GetTemplate is a getter for the T Property 35 | func (b Button) GetTemplate() *template.Template { 36 | return b.T 37 | } 38 | 39 | // Render calls RenderComponent to turn a Component into a html string for browser output 40 | func (b Button) Render() string { 41 | return page.RenderComponent(b) 42 | } 43 | -------------------------------------------------------------------------------- /pkg/structs/page/component/checkboxfield.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "github.com/uberswe/beubo/pkg/structs/page" 5 | "html/template" 6 | ) 7 | 8 | // CheckBoxField is a beubo component that can be rendered using HTML templates 9 | type CheckBoxField struct { 10 | Theme string 11 | Template string 12 | Content string 13 | Class string 14 | Name string 15 | Identifier string 16 | Value string 17 | Checked bool 18 | T *template.Template 19 | } 20 | 21 | // GetSection is a getter for the Section property 22 | func (cb CheckBoxField) GetSection() string { 23 | return "" 24 | } 25 | 26 | // GetTemplateName is a getter for the Template Property 27 | func (cb CheckBoxField) GetTemplateName() string { 28 | return returnTIfNotEmpty(cb.Template, "component.checkboxfield") 29 | } 30 | 31 | // GetTheme is a getter for the Theme Property 32 | func (cb CheckBoxField) GetTheme() string { 33 | return returnTIfNotEmpty(cb.Theme, "default") 34 | } 35 | 36 | // GetTemplate is a getter for the T Property 37 | func (cb CheckBoxField) GetTemplate() *template.Template { 38 | return cb.T 39 | } 40 | 41 | // Render calls RenderComponent to turn a Component into a html string for browser output 42 | func (cb CheckBoxField) Render() string { 43 | return page.RenderComponent(cb) 44 | } 45 | -------------------------------------------------------------------------------- /pkg/structs/page/component/form.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "github.com/uberswe/beubo/pkg/structs/page" 5 | "html/template" 6 | ) 7 | 8 | // Form is a beubo component that can be rendered using HTML templates 9 | type Form struct { 10 | Section string 11 | Fields []page.Component 12 | Theme string 13 | Template string 14 | T *template.Template 15 | Method string 16 | Action string 17 | } 18 | 19 | // GetSection is a getter for the Section property 20 | func (f Form) GetSection() string { 21 | return f.Section 22 | } 23 | 24 | // GetTemplateName is a getter for the Template Property 25 | func (f Form) GetTemplateName() string { 26 | return returnTIfNotEmpty(f.Template, "component.form") 27 | } 28 | 29 | // GetTheme is a getter for the Theme Property 30 | func (f Form) GetTheme() string { 31 | return returnTIfNotEmpty(f.Theme, "default") 32 | } 33 | 34 | // GetTemplate is a getter for the T Property 35 | func (f Form) GetTemplate() *template.Template { 36 | return f.T 37 | } 38 | 39 | // Render calls RenderComponent to turn a Component into a html string for browser output 40 | func (f Form) Render() string { 41 | return page.RenderComponent(f) 42 | } 43 | 44 | // RenderField calls Render to turn a Column into a string which is added to the Form Render 45 | func (f Form) RenderField(value string, field page.Component) template.HTML { 46 | if field != nil && field.Render() != "" { 47 | return template.HTML(field.Render()) 48 | } 49 | return template.HTML(value) 50 | } 51 | -------------------------------------------------------------------------------- /pkg/structs/page/component/functions.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | func returnTIfNotEmpty(t string, d string) string { 4 | if t != "" { 5 | return t 6 | } 7 | return d 8 | } 9 | -------------------------------------------------------------------------------- /pkg/structs/page/component/hiddenfield.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "github.com/uberswe/beubo/pkg/structs/page" 5 | "html/template" 6 | ) 7 | 8 | // HiddenField is a beubo component that can be rendered using HTML templates 9 | type HiddenField struct { 10 | Theme string 11 | Template string 12 | Identifier string 13 | Name string 14 | Value string 15 | T *template.Template 16 | } 17 | 18 | // GetSection is a getter for the Section property 19 | func (hf HiddenField) GetSection() string { 20 | return "" 21 | } 22 | 23 | // GetTemplateName is a getter for the Template Property 24 | func (hf HiddenField) GetTemplateName() string { 25 | return returnTIfNotEmpty(hf.Template, "component.hiddenfield") 26 | } 27 | 28 | // GetTheme is a getter for the Theme Property 29 | func (hf HiddenField) GetTheme() string { 30 | return returnTIfNotEmpty(hf.Theme, "default") 31 | } 32 | 33 | // GetTemplate is a getter for the T Property 34 | func (hf HiddenField) GetTemplate() *template.Template { 35 | return hf.T 36 | } 37 | 38 | // Render calls RenderComponent to turn a Component into a html string for browser output 39 | func (hf HiddenField) Render() string { 40 | return page.RenderComponent(hf) 41 | } 42 | -------------------------------------------------------------------------------- /pkg/structs/page/component/radiofield.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "github.com/uberswe/beubo/pkg/structs/page" 5 | "html/template" 6 | ) 7 | 8 | // RadioField is a beubo component that can be rendered using HTML templates 9 | type RadioField struct { 10 | Theme string 11 | Template string 12 | Class string 13 | Identifier string 14 | Name string 15 | Value string 16 | Content string 17 | Checked bool 18 | T *template.Template 19 | } 20 | 21 | // GetSection is a getter for the Section property 22 | func (rf RadioField) GetSection() string { 23 | return "" 24 | } 25 | 26 | // GetTemplateName is a getter for the Template Property 27 | func (rf RadioField) GetTemplateName() string { 28 | return returnTIfNotEmpty(rf.Template, "component.radiofield") 29 | } 30 | 31 | // GetTheme is a getter for the Theme Property 32 | func (rf RadioField) GetTheme() string { 33 | return returnTIfNotEmpty(rf.Theme, "default") 34 | } 35 | 36 | // GetTemplate is a getter for the T Property 37 | func (rf RadioField) GetTemplate() *template.Template { 38 | return rf.T 39 | } 40 | 41 | // Render calls RenderComponent to turn a Component into a html string for browser output 42 | func (rf RadioField) Render() string { 43 | return page.RenderComponent(rf) 44 | } 45 | -------------------------------------------------------------------------------- /pkg/structs/page/component/selectfield.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "github.com/uberswe/beubo/pkg/structs/page" 5 | "html/template" 6 | ) 7 | 8 | // SelectField is a beubo component that can be rendered using HTML templates 9 | type SelectField struct { 10 | Theme string 11 | Template string 12 | Identifier string 13 | Class string 14 | Name string 15 | Options []SelectFieldOption 16 | T *template.Template 17 | } 18 | 19 | // SelectFieldOption is part of the SelectField values, there should be one or more of these 20 | type SelectFieldOption struct { 21 | Value string 22 | Content string 23 | } 24 | 25 | // GetSection is a getter for the Section property 26 | func (sf SelectField) GetSection() string { 27 | return "" 28 | } 29 | 30 | // GetTemplateName is a getter for the Template Property 31 | func (sf SelectField) GetTemplateName() string { 32 | return returnTIfNotEmpty(sf.Template, "component.selectfield") 33 | } 34 | 35 | // GetTheme is a getter for the Theme Property 36 | func (sf SelectField) GetTheme() string { 37 | return returnTIfNotEmpty(sf.Theme, "default") 38 | } 39 | 40 | // GetTemplate is a getter for the T Property 41 | func (sf SelectField) GetTemplate() *template.Template { 42 | return sf.T 43 | } 44 | 45 | // Render calls RenderComponent to turn a Component into a html string for browser output 46 | func (sf SelectField) Render() string { 47 | return page.RenderComponent(sf) 48 | } 49 | -------------------------------------------------------------------------------- /pkg/structs/page/component/table.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "github.com/uberswe/beubo/pkg/structs/page" 5 | "html/template" 6 | ) 7 | 8 | // Table is a beubo component that can be rendered using HTML templates 9 | type Table struct { 10 | Section string 11 | Header []Column 12 | Rows []Row 13 | Theme string 14 | Template string 15 | PageNumber int // Current page 16 | PageDisplayCount int // How many rows per page 17 | T *template.Template 18 | } 19 | 20 | // GetSection is a getter for the Section property 21 | func (t Table) GetSection() string { 22 | return t.Section 23 | } 24 | 25 | // Row represents a html table row which can have columns 26 | type Row struct { 27 | Columns []Column 28 | } 29 | 30 | // Column represents a html column in a table which is part of a row 31 | type Column struct { 32 | Name string 33 | Field page.Component 34 | Value string 35 | } 36 | 37 | // GetTemplateName is a getter for the Template Property 38 | func (t Table) GetTemplateName() string { 39 | return returnTIfNotEmpty(t.Template, "component.table") 40 | } 41 | 42 | // GetTheme is a getter for the Theme Property 43 | func (t Table) GetTheme() string { 44 | return returnTIfNotEmpty(t.Theme, "default") 45 | } 46 | 47 | // GetTemplate is a getter for the T Property 48 | func (t Table) GetTemplate() *template.Template { 49 | return t.T 50 | } 51 | 52 | // Render calls RenderComponent to turn a Component into a html string for browser output 53 | func (t Table) Render() string { 54 | return page.RenderComponent(t) 55 | } 56 | 57 | // RenderColumn calls RenderComponent to turn a Column into a html string which is added to the Table Render 58 | func (t Table) RenderColumn(c Column) string { 59 | return page.RenderComponent(c.Field) 60 | } 61 | 62 | // RenderField calls Render to turn a Column into a string which is added to the Table Render 63 | func (c Column) RenderField(value string, field page.Component) template.HTML { 64 | if field != nil && field.Render() != "" { 65 | return template.HTML(field.Render()) 66 | } 67 | return template.HTML(value) 68 | } 69 | -------------------------------------------------------------------------------- /pkg/structs/page/component/text.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "github.com/uberswe/beubo/pkg/structs/page" 5 | "html/template" 6 | ) 7 | 8 | // Text is a beubo component that can be rendered using HTML templates 9 | type Text struct { 10 | Section string 11 | Content template.HTML 12 | Theme string 13 | Template string 14 | Class string 15 | T *template.Template 16 | } 17 | 18 | // GetSection is a getter for the Section property 19 | func (t Text) GetSection() string { 20 | return t.Section 21 | } 22 | 23 | // GetTemplateName is a getter for the Template property 24 | func (t Text) GetTemplateName() string { 25 | return returnTIfNotEmpty(t.Template, "component.text") 26 | } 27 | 28 | // GetTheme is a getter for the Theme property 29 | func (t Text) GetTheme() string { 30 | return returnTIfNotEmpty(t.Theme, "default") 31 | } 32 | 33 | // GetTemplate is a getter for the T Property 34 | func (t Text) GetTemplate() *template.Template { 35 | return t.T 36 | } 37 | 38 | // Render calls RenderComponent to turn a Component into a html string for browser output 39 | func (t Text) Render() string { 40 | return page.RenderComponent(t) 41 | } 42 | -------------------------------------------------------------------------------- /pkg/structs/page/component/textareafield.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "github.com/uberswe/beubo/pkg/structs/page" 5 | "html/template" 6 | ) 7 | 8 | // TextAreaField is a beubo component that can be rendered using HTML templates 9 | type TextAreaField struct { 10 | Content string 11 | Theme string 12 | Template string 13 | Class string 14 | Identifier string 15 | Label string 16 | Name string 17 | Rows int 18 | Cols int 19 | T *template.Template 20 | } 21 | 22 | // GetSection is a getter for the Section property 23 | func (t TextAreaField) GetSection() string { 24 | return "" 25 | } 26 | 27 | // GetTemplateName is a getter for the Template property 28 | func (t TextAreaField) GetTemplateName() string { 29 | return returnTIfNotEmpty(t.Template, "component.textareafield") 30 | } 31 | 32 | // GetTheme is a getter for the Theme property 33 | func (t TextAreaField) GetTheme() string { 34 | return returnTIfNotEmpty(t.Theme, "default") 35 | } 36 | 37 | // GetTemplate is a getter for the T Property 38 | func (t TextAreaField) GetTemplate() *template.Template { 39 | return t.T 40 | } 41 | 42 | // Render calls RenderComponent to turn a Component into a html string for browser output 43 | func (t TextAreaField) Render() string { 44 | return page.RenderComponent(t) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/structs/page/component/textfield.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "github.com/uberswe/beubo/pkg/structs/page" 5 | "html/template" 6 | ) 7 | 8 | // TextField is a beubo component that can be rendered using HTML templates 9 | type TextField struct { 10 | Theme string 11 | Template string 12 | Class string 13 | Identifier string 14 | Label string 15 | Name string 16 | Value string 17 | Placeholder string 18 | T *template.Template 19 | } 20 | 21 | // GetSection is a getter for the Section property 22 | func (t TextField) GetSection() string { 23 | return "" 24 | } 25 | 26 | // GetTemplateName is a getter for the Template property 27 | func (t TextField) GetTemplateName() string { 28 | return returnTIfNotEmpty(t.Template, "component.textfield") 29 | } 30 | 31 | // GetTheme is a getter for the Theme property 32 | func (t TextField) GetTheme() string { 33 | return returnTIfNotEmpty(t.Theme, "default") 34 | } 35 | 36 | // GetTemplate is a getter for the T Property 37 | func (t TextField) GetTemplate() *template.Template { 38 | return t.T 39 | } 40 | 41 | // Render calls RenderComponent to turn a Component into a html string for browser output 42 | func (t TextField) Render() string { 43 | return page.RenderComponent(t) 44 | } 45 | -------------------------------------------------------------------------------- /pkg/structs/page/menu.go: -------------------------------------------------------------------------------- 1 | package page 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | "log" 8 | ) 9 | 10 | // Menu is a component but requires a slice of menu items 11 | type Menu interface { 12 | GetIdentifier() string 13 | GetItems() []MenuItem 14 | Render() string 15 | } 16 | 17 | // MenuItem is part of a Menu and usually represents a clickable link 18 | type MenuItem struct { 19 | Text string 20 | URI string 21 | Template string 22 | Theme string 23 | Items []MenuItem // A menu item can contain submenus 24 | T *template.Template 25 | } 26 | 27 | // SubMenu renders submenu items recursively in templates 28 | func (m MenuItem) SubMenu() template.HTML { 29 | if len(m.Items) > 0 { 30 | tmpl := "menu.sub" 31 | if m.Template != "" { 32 | tmpl = m.Template 33 | } 34 | theme := "default" 35 | if m.Theme != "" { 36 | theme = m.Theme 37 | } 38 | path := fmt.Sprintf("%s.%s", theme, tmpl) 39 | var foundTemplate *template.Template 40 | if foundTemplate = m.T.Lookup(path); foundTemplate == nil { 41 | log.Printf("Menu file not found %s\n", path) 42 | return "" 43 | } 44 | buf := &bytes.Buffer{} 45 | err := foundTemplate.Execute(buf, m) 46 | if err != nil { 47 | log.Printf("Component file error executing template %s\n", path) 48 | return "" 49 | } 50 | return template.HTML(buf.String()) 51 | } 52 | return "" 53 | } 54 | -------------------------------------------------------------------------------- /pkg/structs/page/menu/default.go: -------------------------------------------------------------------------------- 1 | package menu 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/uberswe/beubo/pkg/structs/page" 7 | "html/template" 8 | "log" 9 | ) 10 | 11 | // DefaultMenu is the base menu struct for Beubo 12 | type DefaultMenu struct { 13 | Items []page.MenuItem 14 | Identifier string 15 | Template string 16 | Theme string 17 | T *template.Template 18 | } 19 | 20 | // GetIdentifier is a getter for the Identifier property 21 | func (m DefaultMenu) GetIdentifier() string { 22 | return m.Identifier 23 | } 24 | 25 | // GetItems is a getter for the Items property 26 | func (m DefaultMenu) GetItems() []page.MenuItem { 27 | return m.Items 28 | } 29 | 30 | // Render calls the relevant templates and functions to turn a menu into a html string for browser output 31 | func (m DefaultMenu) Render() string { 32 | tmpl := "menu.default" 33 | if m.Template != "" { 34 | tmpl = m.Template 35 | } 36 | theme := "default" 37 | if m.Theme != "" { 38 | theme = m.Theme 39 | } 40 | path := fmt.Sprintf("%s.%s", theme, tmpl) 41 | var foundTemplate *template.Template 42 | if foundTemplate = m.T.Lookup(path); foundTemplate == nil { 43 | log.Printf("Menu file not found %s\n", path) 44 | return "" 45 | } 46 | buf := &bytes.Buffer{} 47 | err := foundTemplate.Execute(buf, m) 48 | if err != nil { 49 | log.Printf("Component file error executing template %s\n", path) 50 | return "" 51 | } 52 | return buf.String() 53 | } 54 | -------------------------------------------------------------------------------- /pkg/structs/role.go: -------------------------------------------------------------------------------- 1 | package structs 2 | 3 | import ( 4 | "fmt" 5 | "github.com/uberswe/beubo/pkg/utility" 6 | "gorm.io/gorm" 7 | ) 8 | 9 | // Role has one or more users and one or more features. A user belonging to a role which also has a feature will allow that user to use the feature 10 | type Role struct { 11 | gorm.Model 12 | Name string `gorm:"size:255"` 13 | Users []*User `gorm:"many2many:user_roles;"` 14 | Features []*Feature `gorm:"many2many:role_features;"` 15 | } 16 | 17 | // Feature contains a key referencing features in the application 18 | type Feature struct { 19 | gorm.Model 20 | Key string `gorm:"size:255"` 21 | Roles []*Role `gorm:"many2many:role_features;"` 22 | } 23 | 24 | // IsDefault checks if the role is a default role 25 | // TODO find a better way to handle default roles 26 | func (r Role) IsDefault() bool { 27 | if r.Name == "Administrator" || r.Name == "Member" { 28 | return true 29 | } 30 | return false 31 | } 32 | 33 | // HasFeature checks if a role has the specified feature 34 | func (r Role) HasFeature(db *gorm.DB, f Feature) bool { 35 | features := []Feature{} 36 | _ = db.Model(&r).Where("key = ?", f.Key).Association("Features").Find(&features) 37 | if len(features) >= 1 { 38 | return true 39 | } 40 | return false 41 | } 42 | 43 | // CreateRole is a method which creates a role using gorm 44 | func CreateRole(db *gorm.DB, name string) bool { 45 | checkRole := FetchRoleByName(db, name) 46 | if checkRole.ID != 0 { 47 | return false 48 | } 49 | role := Role{Name: name} 50 | 51 | if err := db.Create(&role).Error; err != nil { 52 | fmt.Println("Could not create role") 53 | return false 54 | } 55 | return true 56 | } 57 | 58 | // FetchRoleByName retrieves a role from the database using the provided name 59 | func FetchRoleByName(db *gorm.DB, name string) Role { 60 | role := Role{} 61 | db.Where("name = ?", name).First(&role) 62 | return role 63 | } 64 | 65 | // FetchRole retrieves a role from the database using the provided id 66 | func FetchRole(db *gorm.DB, id int) Role { 67 | role := Role{} 68 | db.First(&role, id) 69 | return role 70 | } 71 | 72 | // DeleteRole deletes a user by id 73 | func DeleteRole(db *gorm.DB, id int) (role Role) { 74 | db.First(&role, id) 75 | db.Delete(&role) 76 | return role 77 | } 78 | 79 | // UpdateRole updates the role struct with the provided details 80 | func UpdateRole(db *gorm.DB, id int, name string, features []*Feature) bool { 81 | role := FetchRole(db, id) 82 | checkRole := FetchRoleByName(db, name) 83 | if checkRole.ID != 0 && checkRole.ID != role.ID { 84 | return false 85 | } 86 | err := db.Model(&role).Association("Features").Clear() 87 | utility.ErrorHandler(err, false) 88 | role.Name = name 89 | role.Features = features 90 | if err := db.Save(&role).Error; err != nil { 91 | fmt.Println("Could not create role") 92 | return false 93 | } 94 | return true 95 | } 96 | -------------------------------------------------------------------------------- /pkg/structs/session.go: -------------------------------------------------------------------------------- 1 | package structs 2 | 3 | import ( 4 | "fmt" 5 | "github.com/uberswe/beubo/pkg/utility" 6 | "gorm.io/gorm" 7 | ) 8 | 9 | // Session represents an authenticated user session, there can be multiple sessions for one user 10 | type Session struct { 11 | gorm.Model 12 | Token string `gorm:"size:255;unique_index"` 13 | UserID int 14 | User User 15 | } 16 | 17 | // CreateSession is a method which creates a session using gorm 18 | func CreateSession(db *gorm.DB, userID int) Session { 19 | token, err := utility.GenerateToken(255) 20 | utility.ErrorHandler(err, false) 21 | 22 | session := Session{ 23 | Token: token, 24 | UserID: userID, 25 | } 26 | 27 | if err := db.Create(&session).Error; err != nil { 28 | fmt.Println("Could not create session") 29 | } 30 | 31 | return session 32 | } 33 | 34 | // FetchUserFromSession takes a provided token string and fetches the user for the session matching the provided token 35 | func FetchUserFromSession(db *gorm.DB, token string) User { 36 | user := User{} 37 | session := Session{} 38 | db.Where("token = ?", token).First(&session) 39 | if session.ID != 0 { 40 | user = FetchUser(db, session.UserID) 41 | } 42 | return user 43 | } 44 | -------------------------------------------------------------------------------- /pkg/structs/setting.go: -------------------------------------------------------------------------------- 1 | package structs 2 | 3 | import ( 4 | "fmt" 5 | "gorm.io/gorm" 6 | ) 7 | 8 | // Setting represents a key value setting for Beubo usually used for global config values 9 | type Setting struct { 10 | gorm.Model 11 | Key string `gorm:"size:255"` 12 | Value string `gorm:"size:255"` 13 | } 14 | 15 | // CreateSetting is a method which creates a setting using gorm 16 | func CreateSetting(db *gorm.DB, key string, value string) bool { 17 | setting := Setting{ 18 | Key: key, 19 | Value: value, 20 | } 21 | 22 | if err := db.Create(&setting).Error; err != nil { 23 | fmt.Println("Could not create setting") 24 | return false 25 | } 26 | return true 27 | } 28 | 29 | // FetchSetting gets a setting from the database via the provided id 30 | func FetchSetting(db *gorm.DB, id int) Setting { 31 | setting := Setting{} 32 | db.First(&setting, id) 33 | return setting 34 | } 35 | 36 | // FetchSettingByKey gets a setting from the database via the provided key 37 | func FetchSettingByKey(db *gorm.DB, key string) Setting { 38 | setting := Setting{} 39 | db.Where("key = ?", key).First(&setting) 40 | return setting 41 | } 42 | 43 | // FetchSettings gets all settings from the database 44 | func FetchSettings(db *gorm.DB) (settings []Setting) { 45 | db.Find(&settings) 46 | return settings 47 | } 48 | 49 | // UpdateSetting updates a setting key value pair using gorm 50 | func UpdateSetting(db *gorm.DB, id int, key string, value string) bool { 51 | setting := FetchSetting(db, id) 52 | 53 | setting.Key = key 54 | setting.Value = value 55 | 56 | if err := db.Save(&setting).Error; err != nil { 57 | fmt.Println("Could not create setting") 58 | return false 59 | } 60 | return true 61 | } 62 | 63 | // DeleteSetting removes a setting with the matching id from the database 64 | func DeleteSetting(db *gorm.DB, id int) Setting { 65 | setting := FetchSetting(db, id) 66 | db.Delete(&setting) 67 | return setting 68 | } 69 | -------------------------------------------------------------------------------- /pkg/structs/site.go: -------------------------------------------------------------------------------- 1 | package structs 2 | 3 | import ( 4 | "fmt" 5 | "gorm.io/gorm" 6 | ) 7 | 8 | // Site represents one website, the idea is that Beubo handles many websites at the same time, you could then have 100s of sites all on the same platform 9 | type Site struct { 10 | gorm.Model 11 | Title string `gorm:"size:255"` 12 | Domain string `gorm:"size:255;unique_index"` 13 | DestinationDomain string 14 | Type int 15 | Theme Theme 16 | ThemeID int 17 | Users []*User `gorm:"many2many:user_sites;"` 18 | } 19 | 20 | // CreateSite is a method which creates a site using gorm 21 | func CreateSite(db *gorm.DB, title string, domain string, siteType int, themeID int, destinationDomain string) Site { 22 | site := Site{ 23 | Title: title, 24 | Domain: domain, 25 | Type: siteType, 26 | ThemeID: themeID, 27 | DestinationDomain: destinationDomain, 28 | } 29 | 30 | if err := db.Create(&site).Error; err != nil { 31 | fmt.Println("Could not create site") 32 | return site 33 | } 34 | return site 35 | } 36 | 37 | // FetchSite gets a site from the database using the provided id 38 | func FetchSite(db *gorm.DB, id int) (site Site) { 39 | site = Site{} 40 | db.Preload("Theme").First(&site, id) 41 | return site 42 | } 43 | 44 | // FetchSites gets a site from the database using the provided id 45 | func FetchSites(db *gorm.DB) (sites []Site) { 46 | sites = []Site{} 47 | db.Find(&sites) 48 | return sites 49 | } 50 | 51 | // FetchSiteByHost retrieves a site from the database based on the provided host string 52 | // TODO what if one site can have many hosts? For now a redirect can be added for other hosts 53 | func FetchSiteByHost(db *gorm.DB, host string) Site { 54 | site := Site{} 55 | db.Preload("Theme").Where("domain = ?", host).First(&site) 56 | return site 57 | } 58 | 59 | // UpdateSite is a method which updates a site using gorm 60 | func UpdateSite(db *gorm.DB, id int, title string, domain string, siteType int, themeID int, destinationDomain string) bool { 61 | site := FetchSite(db, id) 62 | 63 | site.Title = title 64 | site.Domain = domain 65 | site.Type = siteType 66 | site.ThemeID = themeID 67 | site.DestinationDomain = destinationDomain 68 | 69 | if err := db.Save(&site).Error; err != nil { 70 | fmt.Println("Could not create site") 71 | return false 72 | } 73 | return true 74 | } 75 | 76 | // DeleteSite removes a site from the database based on the provided id 77 | func DeleteSite(db *gorm.DB, id int) Site { 78 | site := FetchSite(db, id) 79 | db.Delete(&site) 80 | return site 81 | } 82 | -------------------------------------------------------------------------------- /pkg/structs/theme.go: -------------------------------------------------------------------------------- 1 | package structs 2 | 3 | import "gorm.io/gorm" 4 | 5 | // Theme is the template or html files that a site uses, theme files are found under the web directory 6 | type Theme struct { 7 | gorm.Model 8 | Title string `gorm:"size:255;unique_index"` 9 | Slug string `gorm:"size:255;unique_index"` 10 | } 11 | 12 | // FetchTheme gets a theme from the database using the provided id 13 | func FetchTheme(db *gorm.DB, id int) Theme { 14 | theme := Theme{} 15 | 16 | db.First(&theme, id) 17 | 18 | return theme 19 | } 20 | 21 | // FetchThemeBySlug gets a theme from the database by the slug string 22 | func FetchThemeBySlug(db *gorm.DB, slug string) Theme { 23 | theme := Theme{} 24 | 25 | db.Where("slug = ?", slug).First(&theme) 26 | 27 | return theme 28 | } 29 | -------------------------------------------------------------------------------- /pkg/structs/user.go: -------------------------------------------------------------------------------- 1 | package structs 2 | 3 | import ( 4 | "fmt" 5 | "github.com/uberswe/beubo/pkg/utility" 6 | "golang.org/x/crypto/bcrypt" 7 | "gorm.io/gorm" 8 | "log" 9 | ) 10 | 11 | // User is a user who can authenticate with Beubo 12 | type User struct { 13 | gorm.Model 14 | Email string `gorm:"size:255"` 15 | Password string `gorm:"size:255"` 16 | Activations []UserActivation 17 | Roles []*Role `gorm:"many2many:user_roles;"` 18 | Sites []*Site `gorm:"many2many:user_sites;"` 19 | } 20 | 21 | // UserActivation is used to verify a user when signing up 22 | type UserActivation struct { 23 | gorm.Model 24 | UserID uint 25 | Type string // Email, SMS, PushNotification 26 | Active bool 27 | Code string 28 | } 29 | 30 | // CanAccess checks if a user has access to the specified feature 31 | func (u User) CanAccess(db *gorm.DB, featureKey string) bool { 32 | var count int64 33 | db.Model(&Feature{}). 34 | Select("features.id"). 35 | Joins("left join role_features on role_features.feature_id = features.id"). 36 | Joins("left join user_roles on user_roles.role_id = role_features.role_id"). 37 | Where("features.key = ?", featureKey). 38 | Where("user_roles.user_id = ?", u.ID). 39 | Count(&count) 40 | return count > 0 41 | } 42 | 43 | // CanAccessSite checks if a user is allowed to access the specified site 44 | func (u User) CanAccessSite(db *gorm.DB, site Site) bool { 45 | var count int64 46 | db.Model(&Site{}). 47 | Select("sites.id"). 48 | Joins("left join user_sites on user_sites.site_id = sites.id"). 49 | Where("user_sites.user_id = ?", u.ID). 50 | Where("sites.id = ?", site.ID). 51 | Count(&count) 52 | return count > 0 53 | } 54 | 55 | // HasRole checks if a user has the specified role 56 | func (u User) HasRole(db *gorm.DB, r Role) bool { 57 | roles := []Role{} 58 | _ = db.Model(&u).Where("roles.name = ?", r.Name).Association("Roles").Find(&roles) 59 | if len(roles) >= 1 { 60 | return true 61 | } 62 | return false 63 | } 64 | 65 | // CreateUser is a method which creates a user using gorm 66 | func CreateUser(db *gorm.DB, email string, password string, roles []*Role, sites []*Site) bool { 67 | checkUser := FetchUserByEmail(db, email) 68 | if checkUser.ID != 0 { 69 | return false 70 | } 71 | user := User{ 72 | Email: email, 73 | Roles: roles, 74 | Sites: sites, 75 | } 76 | if err := db.Create(&user).Error; err != nil { 77 | fmt.Println("Could not create user") 78 | return false 79 | } 80 | // User password is hashed after the response is returned to improve performance 81 | go hashUserPassword(db, user, password) 82 | return true 83 | } 84 | 85 | // FetchUser retrieves a user from the database using the provided id 86 | func FetchUser(db *gorm.DB, id int) User { 87 | user := User{} 88 | db.First(&user, id) 89 | return user 90 | } 91 | 92 | // FetchUserByEmail retrieves a user from the database using the provided email 93 | func FetchUserByEmail(db *gorm.DB, email string) User { 94 | user := User{} 95 | db.Where("email = ?", email).First(&user) 96 | return user 97 | } 98 | 99 | // hashUserPassword hashes the user password using bcrypt 100 | func hashUserPassword(db *gorm.DB, user User, password string) { 101 | 102 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 14) 103 | 104 | if err != nil { 105 | fmt.Println("Password hashing failed for user") 106 | return 107 | } 108 | 109 | user.Password = string(hashedPassword) 110 | 111 | if err := db.Save(&user).Error; err != nil { 112 | fmt.Println("Could not update hashed password for user") 113 | } 114 | } 115 | 116 | // AuthUser authenticates the user by verifying a username and password 117 | func AuthUser(db *gorm.DB, email string, password string) *User { 118 | user := User{} 119 | 120 | if err := db.Where("email = ?", email).First(&user).Error; err != nil { 121 | log.Println(err) 122 | return nil 123 | } 124 | 125 | err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) 126 | if err == bcrypt.ErrMismatchedHashAndPassword { 127 | return nil 128 | } else if err != nil { 129 | return nil 130 | } 131 | 132 | return &user 133 | } 134 | 135 | // UpdateUser updates the user struct with the provided details 136 | func UpdateUser(db *gorm.DB, id int, email string, password string, roles []*Role, sites []*Site) bool { 137 | user := FetchUser(db, id) 138 | checkUser := FetchUserByEmail(db, email) 139 | if checkUser.ID != 0 && checkUser.ID != user.ID { 140 | return false 141 | } 142 | err := db.Model(&user).Association("Roles").Clear() 143 | utility.ErrorHandler(err, false) 144 | err = db.Model(&user).Association("Sites").Clear() 145 | utility.ErrorHandler(err, false) 146 | user.Email = email 147 | user.Roles = roles 148 | user.Sites = sites 149 | if err := db.Save(&user).Error; err != nil { 150 | fmt.Println("Could not create user") 151 | return false 152 | } 153 | // User password is hashed after the response is returned to improve performance 154 | // An empty password updates the user without changing the password 155 | if len(password) > 8 { 156 | go hashUserPassword(db, user, password) 157 | } 158 | return true 159 | } 160 | 161 | // DeleteUser deletes a user by id 162 | func DeleteUser(db *gorm.DB, id int) (user User) { 163 | db.First(&user, id) 164 | db.Delete(&user) 165 | return user 166 | } 167 | -------------------------------------------------------------------------------- /pkg/template/page.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // GetPageTemplates recursively parses the themes directory to find all theme files 8 | func (btr *BeuboTemplateRenderer) GetPageTemplates() map[string]string { 9 | pageTemplates := map[string]string{ 10 | "page": "Default", 11 | } 12 | for _, t := range btr.T.Templates() { 13 | // Ignore paths and only look at names containing .page. and does not contain .admin. 14 | if !strings.Contains(t.Name(), "themes/") && !strings.Contains(t.Name(), ".admin.") && strings.Contains(t.Name(), ".page.") { 15 | s := strings.Split(t.Name(), ".") 16 | pageName := "" 17 | pageTemplate := "page" 18 | pageFound := false 19 | for _, part := range s { 20 | if pageFound { 21 | if len(pageName) > 0 { 22 | pageName += " " 23 | } 24 | pageTemplate += "." + part 25 | pageName += strings.Title(part) 26 | } 27 | // Anything after page is our page template name 28 | if part == "page" { 29 | pageFound = true 30 | } 31 | } 32 | if len(pageName) > 0 { 33 | pageTemplates[pageTemplate] = pageName 34 | } 35 | } 36 | } 37 | return pageTemplates 38 | } 39 | -------------------------------------------------------------------------------- /pkg/template/render.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "fmt" 5 | "github.com/uberswe/beubo/pkg/middleware" 6 | "github.com/uberswe/beubo/pkg/plugin" 7 | "github.com/uberswe/beubo/pkg/structs" 8 | "github.com/uberswe/beubo/pkg/structs/page" 9 | "github.com/uberswe/beubo/pkg/structs/page/menu" 10 | "github.com/uberswe/beubo/pkg/utility" 11 | "gorm.io/gorm" 12 | "html/template" 13 | "io/ioutil" 14 | "log" 15 | "net/http" 16 | "os" 17 | "path/filepath" 18 | "strconv" 19 | "strings" 20 | "time" 21 | ) 22 | 23 | var ( 24 | currentTheme = "default" 25 | rootDir = "./" 26 | ) 27 | 28 | // BeuboTemplateRenderer holds all the configuration variables for rendering templates in Beubo 29 | type BeuboTemplateRenderer struct { 30 | T *template.Template 31 | PluginHandler *plugin.Handler 32 | ReloadTemplates bool 33 | CurrentTheme string 34 | ThemeDir string 35 | DB *gorm.DB 36 | } 37 | 38 | // Init prepares the BeuboTemplateRenderer to render pages with html templates 39 | func (btr *BeuboTemplateRenderer) Init() { 40 | log.Println("Parsing and loading templates...") 41 | var err error 42 | btr.T, err = findAndParseTemplates(rootDir) 43 | utility.ErrorHandler(err, false) 44 | } 45 | 46 | // RenderHTMLPage handles rendering of the html template and should be the last function called before returning the response 47 | func (btr *BeuboTemplateRenderer) RenderHTMLPage(w http.ResponseWriter, r *http.Request, pageData structs.PageData) { 48 | 49 | if os.Getenv("ASSETS_DIR") != "" { 50 | rootDir = os.Getenv("ASSETS_DIR") 51 | } 52 | 53 | // Session flash messages to prompt failed logins etc.. 54 | errorMessage, err := utility.GetFlash(w, r, "error") 55 | utility.ErrorHandler(err, false) 56 | warningMessage, err := utility.GetFlash(w, r, "warning") 57 | utility.ErrorHandler(err, false) 58 | stringMessage, err := utility.GetFlash(w, r, "message") 59 | utility.ErrorHandler(err, false) 60 | 61 | siteName := "Beubo" 62 | 63 | // Get the site from context 64 | site := r.Context().Value(middleware.SiteContextKey) 65 | user := r.Context().Value(middleware.UserContextKey) 66 | 67 | if site != nil { 68 | btr.CurrentTheme = site.(structs.Site).Theme.Slug 69 | siteName = site.(structs.Site).Title 70 | } else if os.Getenv("THEME") != "" { 71 | btr.CurrentTheme = os.Getenv("THEME") 72 | } else { 73 | // Default theme 74 | if btr.CurrentTheme == "" { 75 | btr.CurrentTheme = "default" 76 | } 77 | } 78 | 79 | // TODO in the future we should make some way for the theme to define the stylesheets 80 | scripts := []string{ 81 | "/default/js/main.js", 82 | } 83 | 84 | stylesheets := []string{ 85 | "/default/css/main.css", 86 | } 87 | 88 | if strings.HasPrefix(r.URL.Path, "/admin") { 89 | scripts = append(scripts, "/default/js/admin.js") 90 | stylesheets = append(stylesheets, "/default/css/admin.css") 91 | } 92 | var userFromContext structs.User 93 | if user != nil && user.(structs.User).ID > 0 { 94 | userFromContext = user.(structs.User) 95 | } 96 | 97 | data := structs.PageData{ 98 | Stylesheets: stylesheets, 99 | // TODO make the favicon dynamic 100 | Favicon: "/default/images/favicon.ico", 101 | Scripts: scripts, 102 | WebsiteName: siteName, 103 | URL: "http://localhost:3000", 104 | Error: string(errorMessage), 105 | Warning: string(warningMessage), 106 | Message: string(stringMessage), 107 | Year: strconv.Itoa(time.Now().Year()), 108 | Menus: btr.buildMenus(userFromContext), 109 | } 110 | 111 | data = mergePageData(data, pageData) 112 | // PluginHandler is not available during installation 113 | if btr.PluginHandler != nil { 114 | data = btr.PluginHandler.PageData(r, data) 115 | } 116 | 117 | if btr.ReloadTemplates { 118 | log.Println("Parsing and loading templates...") 119 | var err error 120 | btr.T, err = findAndParseTemplates(rootDir) 121 | utility.ErrorHandler(err, false) 122 | } 123 | 124 | var foundTemplate *template.Template 125 | 126 | path := fmt.Sprintf("%s.%s", btr.CurrentTheme, pageData.Template) 127 | if foundTemplate = btr.T.Lookup(path); foundTemplate == nil { 128 | // Fallback to default 129 | path := fmt.Sprintf("%s.%s", "default", pageData.Template) 130 | if foundTemplate = btr.T.Lookup(path); foundTemplate == nil { 131 | log.Printf("Theme file not found %s\n", path) 132 | return 133 | } 134 | } 135 | 136 | err = foundTemplate.Execute(w, data) 137 | utility.ErrorHandler(err, false) 138 | } 139 | 140 | func (btr *BeuboTemplateRenderer) buildMenus(user structs.User) []page.Menu { 141 | menuItems := []page.MenuItem{ 142 | {Text: "Home", URI: "/"}, 143 | {Text: "Login", URI: "/login"}, 144 | } 145 | 146 | // DB is not available during installation 147 | if btr.DB != nil { 148 | setting := structs.FetchSettingByKey(btr.DB, "enable_user_registration") 149 | if !(setting.ID == 0 || setting.Value == "false") { 150 | menuItems = append(menuItems, page.MenuItem{Text: "Register", URI: "/register"}) 151 | } 152 | } 153 | 154 | menus := []page.Menu{menu.DefaultMenu{ 155 | Items: menuItems, 156 | Identifier: "header", 157 | T: btr.T, 158 | }} 159 | 160 | if user.ID > 0 { 161 | adminHeaderMenu := []page.MenuItem{ 162 | {Text: "Home", URI: "/"}, 163 | {Text: "Logout", URI: "/logout"}, 164 | } 165 | 166 | if user.CanAccess(btr.DB, "manage_sites") || 167 | user.CanAccess(btr.DB, "manage_pages") || 168 | user.CanAccess(btr.DB, "manage_users") || 169 | user.CanAccess(btr.DB, "manage_user_roles") || 170 | user.CanAccess(btr.DB, "manage_plugins") || 171 | user.CanAccess(btr.DB, "manage_settings") { 172 | adminHeaderMenu = []page.MenuItem{ 173 | {Text: "Home", URI: "/"}, 174 | {Text: "Admin", URI: "/admin"}, 175 | {Text: "Logout", URI: "/logout"}, 176 | } 177 | } 178 | 179 | adminSidebarMenu := []page.MenuItem{} 180 | 181 | if user.CanAccess(btr.DB, "manage_sites") { 182 | adminSidebarMenu = append(adminSidebarMenu, page.MenuItem{Text: "Sites", URI: "/admin/"}) 183 | } 184 | 185 | if user.CanAccess(btr.DB, "manage_settings") { 186 | adminSidebarMenu = append(adminSidebarMenu, page.MenuItem{Text: "Settings", URI: "/admin/settings"}) 187 | } 188 | 189 | if user.CanAccess(btr.DB, "manage_users") { 190 | adminSidebarMenu = append(adminSidebarMenu, page.MenuItem{ 191 | Text: "Users", 192 | URI: "/admin/users", 193 | Items: []page.MenuItem{ 194 | { 195 | Text: "Roles", 196 | URI: "/admin/users/roles", 197 | }, 198 | }, 199 | // Submenus need template to be defined 200 | T: btr.T, 201 | }) 202 | } 203 | 204 | if user.CanAccess(btr.DB, "manage_plugins") { 205 | adminSidebarMenu = append(adminSidebarMenu, page.MenuItem{Text: "Plugins", URI: "/admin/plugins"}) 206 | } 207 | 208 | menus = []page.Menu{menu.DefaultMenu{ 209 | Items: adminHeaderMenu, 210 | Identifier: "header", 211 | T: btr.T, 212 | }, menu.DefaultMenu{ 213 | Items: adminSidebarMenu, 214 | Identifier: "sidebar", 215 | Template: "menu.sidebar", 216 | T: btr.T, 217 | }} 218 | } 219 | return menus 220 | } 221 | 222 | // findAndParseTemplates finds all the templates in the rootDir and makes a template map 223 | // This method was found here https://stackoverflow.com/a/50581032/1260548 224 | func findAndParseTemplates(rootDir string) (*template.Template, error) { 225 | cleanRoot, err := filepath.Abs(rootDir) 226 | if err != nil { 227 | return nil, err 228 | } 229 | pfx := len(cleanRoot) + 1 230 | root := template.New("") 231 | 232 | err = filepath.Walk(cleanRoot, func(path string, info os.FileInfo, e1 error) error { 233 | if !info.IsDir() && strings.HasSuffix(path, ".html") { 234 | if e1 != nil { 235 | return e1 236 | } 237 | 238 | b, e2 := ioutil.ReadFile(path) 239 | if e2 != nil { 240 | return e2 241 | } 242 | 243 | name := path[pfx:] 244 | t := root.New(name) 245 | t, e2 = t.Parse(string(b)) 246 | if e2 != nil { 247 | return e2 248 | } 249 | } 250 | 251 | return nil 252 | }) 253 | 254 | return root, err 255 | } 256 | 257 | func mergePageData(a structs.PageData, b structs.PageData) structs.PageData { 258 | // TODO this could be simplified by making a function that compares an interface and picks a value but I decided that this is more readable for now 259 | if b.Template != "" { 260 | a.Template = b.Template 261 | } 262 | 263 | if b.Title != "" { 264 | a.Title = b.Title 265 | } 266 | 267 | if b.WebsiteName != "" { 268 | a.WebsiteName = b.WebsiteName 269 | } 270 | 271 | if b.Error != "" { 272 | a.Error = b.Error 273 | } 274 | 275 | if b.Warning != "" { 276 | a.Warning = b.Warning 277 | } 278 | 279 | if b.Message != "" { 280 | a.Message = b.Message 281 | } 282 | 283 | if len(b.Scripts) > 0 { 284 | a.Scripts = b.Scripts 285 | } 286 | 287 | if len(b.Stylesheets) > 0 { 288 | a.Stylesheets = b.Stylesheets 289 | } 290 | 291 | if b.Favicon != "" { 292 | a.Favicon = b.Favicon 293 | } 294 | 295 | if len(b.Menus) > 0 { 296 | a.Menus = b.Menus 297 | } 298 | 299 | a.Components = b.Components 300 | a.Themes = b.Themes 301 | a.Templates = b.Templates 302 | a.Extra = b.Extra 303 | 304 | return a 305 | } 306 | -------------------------------------------------------------------------------- /pkg/template/site.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | ) 7 | 8 | // GetThemes fetches all the themes in the theme directory as strings 9 | func (btr *BeuboTemplateRenderer) GetThemes() map[string]string { 10 | pageThemes := map[string]string{} 11 | 12 | files, err := ioutil.ReadDir(btr.ThemeDir) 13 | 14 | if err != nil { 15 | log.Println(err) 16 | return pageThemes 17 | } 18 | 19 | for _, file := range files { 20 | // TODO in the future we could define a way to define names for themes 21 | // Ignore the install directory, only used for installation 22 | if file.IsDir() && file.Name() != "install" { 23 | pageThemes[file.Name()] = file.Name() 24 | } 25 | } 26 | 27 | return pageThemes 28 | } 29 | -------------------------------------------------------------------------------- /pkg/utility/email.go: -------------------------------------------------------------------------------- 1 | package utility 2 | 3 | import "regexp" 4 | 5 | // from https://golangcode.com/validate-an-email-address/ 6 | 7 | var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") 8 | 9 | // IsEmailValid checks if the email provided passes the required structure and length. 10 | func IsEmailValid(e string) bool { 11 | if len(e) < 3 && len(e) > 254 { 12 | return false 13 | } 14 | return emailRegex.MatchString(e) 15 | } 16 | -------------------------------------------------------------------------------- /pkg/utility/errors.go: -------------------------------------------------------------------------------- 1 | package utility 2 | 3 | import "log" 4 | 5 | // ErrorHandler is a utility function to handle errors so we don't have to write 6 | // if err != nil { 7 | // // Do something here 8 | // } 9 | func ErrorHandler(e error, fatal bool) bool { 10 | if e != nil { 11 | if fatal { 12 | log.Fatal(e) 13 | } else { 14 | log.Print(e) 15 | } 16 | return true 17 | } 18 | return false 19 | } 20 | -------------------------------------------------------------------------------- /pkg/utility/requests.go: -------------------------------------------------------------------------------- 1 | package utility 2 | 3 | import ( 4 | "encoding/base64" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | // GetFlash gets the cookie value set by SetFlash and removes the cookie 10 | func GetFlash(w http.ResponseWriter, r *http.Request, name string) ([]byte, error) { 11 | c, err := r.Cookie(name) 12 | if err != nil { 13 | switch err { 14 | case http.ErrNoCookie: 15 | return nil, nil 16 | default: 17 | return nil, err 18 | } 19 | } 20 | value, err := Decode(c.Value) 21 | if err != nil { 22 | return nil, err 23 | } 24 | // Deletes the cookie 25 | dc := &http.Cookie{Path: "/", Name: name, MaxAge: -1, Expires: time.Now().Add(-100 * time.Hour)} 26 | http.SetCookie(w, dc) 27 | return value, nil 28 | } 29 | 30 | // SetFlash sets a cookie which expires after the next page load 31 | func SetFlash(w http.ResponseWriter, name string, value []byte) { 32 | c := &http.Cookie{Path: "/", Name: name, Value: Encode(value)} 33 | http.SetCookie(w, c) 34 | } 35 | 36 | // Encode encodes a byte array into an urlencoded string 37 | func Encode(src []byte) string { 38 | return base64.URLEncoding.EncodeToString(src) 39 | } 40 | 41 | // Decode decodes an url encoded string into a byte array 42 | func Decode(src string) ([]byte, error) { 43 | return base64.URLEncoding.DecodeString(src) 44 | } 45 | -------------------------------------------------------------------------------- /pkg/utility/strings.go: -------------------------------------------------------------------------------- 1 | package utility 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "strings" 7 | "unicode" 8 | ) 9 | 10 | // GenerateToken generates a random string of len length 11 | func GenerateToken(len int) (string, error) { 12 | b := make([]byte, len) 13 | _, err := rand.Read(b) 14 | if err != nil { 15 | return "", err 16 | } 17 | return fmt.Sprintf("%x", b), nil 18 | } 19 | 20 | // TrimWhitespace takes a string and removes any spaces 21 | func TrimWhitespace(str string) string { 22 | var b strings.Builder 23 | b.Grow(len(str)) 24 | for _, ch := range str { 25 | if !unicode.IsSpace(ch) { 26 | b.WriteRune(ch) 27 | } 28 | } 29 | return b.String() 30 | } 31 | -------------------------------------------------------------------------------- /plugin.go: -------------------------------------------------------------------------------- 1 | package beubo 2 | 3 | import ( 4 | beuboplugin "github.com/uberswe/beubo/pkg/plugin" 5 | ) 6 | 7 | // This is the plugin handler for Beubo 8 | // The initial function will always be Register 9 | // We need a fairly dynamic way to hook any Beubo function into the plugins 10 | // Most functions can be async but some will cause Beubo to wait for them to finish. 11 | // For example, an analytics plugin does not need Beubo to wait as it doesn't affect the flow 12 | // But an ecommerce plugin would need Beubo to wait since it affects what is displayed on the page 13 | 14 | var pluginHandler beuboplugin.Handler 15 | 16 | func loadPlugins() { 17 | pluginHandler = beuboplugin.Handler{ 18 | DB: DB, 19 | } 20 | pluginHandler = beuboplugin.Load(pluginHandler) 21 | } 22 | -------------------------------------------------------------------------------- /plugins/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !README.md -------------------------------------------------------------------------------- /plugins/README.md: -------------------------------------------------------------------------------- 1 | ## Plugins 2 | 3 | Beubo supports plugins and it should use the Go plugins package https://golang.org/pkg/plugin/ 4 | 5 | More documentation, sample code and a proper interface will be added. 6 | 7 | ## Build a plugin 8 | 9 | To run a plugin it needs to be compiled, to build the plugin run: 10 | 11 | ``` 12 | go build -buildmode=plugin 13 | ``` 14 | 15 | ## Available functions 16 | 17 | Currently the only function available is the `Register` function which is called when Beubo starts. 18 | This can be used to tell Beubo about the plugin. 19 | 20 | ## Planned functions 21 | 22 | - Page request 23 | - Template 24 | - Save page 25 | - New page 26 | - New site 27 | - Save site 28 | - Login 29 | - Logout 30 | - Install started 31 | - Install finished 32 | - Beubo is running, a run function that starts running if Beubo is active 33 | - Shutdown -------------------------------------------------------------------------------- /routes.go: -------------------------------------------------------------------------------- 1 | package beubo 2 | 3 | import ( 4 | "fmt" 5 | "github.com/goincremental/negroni-sessions" 6 | "github.com/goincremental/negroni-sessions/cookiestore" 7 | "github.com/gorilla/mux" 8 | "github.com/uberswe/beubo/pkg/middleware" 9 | "github.com/uberswe/beubo/pkg/routes" 10 | "github.com/uberswe/beubo/pkg/template" 11 | "github.com/uberswe/beubo/pkg/utility" 12 | "github.com/urfave/negroni" 13 | "golang.org/x/time/rate" 14 | "io/ioutil" 15 | "log" 16 | "net/http" 17 | "sync" 18 | "time" 19 | ) 20 | 21 | var themes []string 22 | var fileServers = map[string]http.Handler{} 23 | 24 | // routesInit initializes the routes and starts a web server that listens on the specified port 25 | func routesInit() { 26 | var err error 27 | 28 | beuboTemplateRenderer := template.BeuboTemplateRenderer{ 29 | ReloadTemplates: reloadTemplates, 30 | CurrentTheme: currentTheme, 31 | ThemeDir: rootDir, 32 | PluginHandler: &pluginHandler, 33 | DB: DB, 34 | } 35 | beuboTemplateRenderer.Init() 36 | 37 | beuboRouter := &routes.BeuboRouter{ 38 | DB: DB, 39 | Renderer: &beuboTemplateRenderer, 40 | PluginHandler: &pluginHandler, 41 | } 42 | 43 | beuboMiddleware := &middleware.BeuboMiddleware{ 44 | DB: DB, 45 | PluginHandler: &pluginHandler, 46 | } 47 | 48 | // TODO make the burst and time configurable 49 | throttleMiddleware := middleware.Throttle{ 50 | IPs: make(map[string]middleware.ThrottleClient), 51 | Rate: rate.Every(time.Minute), 52 | Burst: 300, 53 | Mu: &sync.RWMutex{}, 54 | Cleanup: time.Duration(24) * time.Hour, 55 | } 56 | 57 | utility.ErrorHandler(err, true) 58 | 59 | r := mux.NewRouter() 60 | r.StrictSlash(true) 61 | n := negroni.Classic() 62 | 63 | store := cookiestore.New(sessionKey) 64 | n.Use(sessions.Sessions("beubo", store)) 65 | 66 | r.NotFoundHandler = http.HandlerFunc(beuboRouter.PageHandler) 67 | 68 | log.Println("Registering themes...") 69 | 70 | r = registerStaticFiles(r) 71 | 72 | log.Println("Registering routes...") 73 | 74 | r.HandleFunc("/login", beuboRouter.Login).Methods("GET") 75 | r.HandleFunc("/login", beuboRouter.LoginPost).Methods("POST") 76 | 77 | r.HandleFunc("/register", beuboRouter.Register).Methods("GET") 78 | r.HandleFunc("/register", beuboRouter.RegisterPost).Methods("POST") 79 | 80 | admin := r.PathPrefix("/admin").Subrouter() 81 | admin.HandleFunc("/", beuboRouter.Admin) 82 | 83 | admin.HandleFunc("/settings", beuboRouter.Settings) 84 | admin.HandleFunc("/users", beuboRouter.Users) 85 | admin.HandleFunc("/users/roles", beuboRouter.AdminUserRoles) 86 | admin.HandleFunc("/plugins", beuboRouter.Plugins) 87 | 88 | admin.HandleFunc("/sites/add", beuboRouter.AdminSiteAdd).Methods("GET") 89 | admin.HandleFunc("/sites/add", beuboRouter.AdminSiteAddPost).Methods("POST") 90 | 91 | admin.HandleFunc("/settings/add", beuboRouter.AdminSettingAdd).Methods("GET") 92 | admin.HandleFunc("/settings/add", beuboRouter.AdminSettingAddPost).Methods("POST") 93 | 94 | admin.HandleFunc("/users/add", beuboRouter.AdminUserAdd).Methods("GET") 95 | admin.HandleFunc("/users/add", beuboRouter.AdminUserAddPost).Methods("POST") 96 | 97 | admin.HandleFunc("/users/roles/add", beuboRouter.AdminUserRoleAdd).Methods("GET") 98 | admin.HandleFunc("/users/roles/add", beuboRouter.AdminUserRoleAddPost).Methods("POST") 99 | 100 | admin.HandleFunc("/sites/delete/{id:[0-9]+}", beuboRouter.AdminSiteDelete) 101 | admin.HandleFunc("/settings/delete/{id:[0-9]+}", beuboRouter.AdminSettingDelete) 102 | admin.HandleFunc("/users/delete/{id:[0-9]+}", beuboRouter.AdminUserDelete) 103 | admin.HandleFunc("/users/roles/delete/{id:[0-9]+}", beuboRouter.AdminUserRoleDelete) 104 | 105 | admin.HandleFunc("/sites/edit/{id:[0-9]+}", beuboRouter.AdminSiteEdit).Methods("GET") 106 | admin.HandleFunc("/sites/edit/{id:[0-9]+}", beuboRouter.AdminSiteEditPost).Methods("POST") 107 | 108 | admin.HandleFunc("/settings/edit/{id:[0-9]+}", beuboRouter.AdminSettingEdit).Methods("GET") 109 | admin.HandleFunc("/settings/edit/{id:[0-9]+}", beuboRouter.AdminSettingEditPost).Methods("POST") 110 | 111 | admin.HandleFunc("/users/edit/{id:[0-9]+}", beuboRouter.AdminUserEdit).Methods("GET") 112 | admin.HandleFunc("/users/edit/{id:[0-9]+}", beuboRouter.AdminUserEditPost).Methods("POST") 113 | 114 | admin.HandleFunc("/users/roles/edit/{id:[0-9]+}", beuboRouter.AdminUserRoleEdit).Methods("GET") 115 | admin.HandleFunc("/users/roles/edit/{id:[0-9]+}", beuboRouter.AdminUserRoleEditPost).Methods("POST") 116 | 117 | admin.HandleFunc("/plugins/edit/{id:[a-zA-Z_]+}", beuboRouter.AdminPluginEdit).Methods("GET") 118 | admin.HandleFunc("/plugins/edit/{id:[a-zA-Z_]+}", beuboRouter.AdminPluginEditPost).Methods("POST") 119 | 120 | // TODO I don't like this /sites/a/ structure of the routes, consider changing it 121 | siteAdmin := admin.PathPrefix("/sites/a/{id:[0-9]+}").Subrouter() 122 | 123 | siteAdmin.HandleFunc("/", beuboRouter.SiteAdmin) 124 | 125 | siteAdmin.HandleFunc("/page/new", beuboRouter.SiteAdminPageNew).Methods("GET") 126 | siteAdmin.HandleFunc("/page/new", beuboRouter.SiteAdminPageNewPost).Methods("POST") 127 | 128 | siteAdmin.HandleFunc("/page/edit/{pageId:[0-9]+}", beuboRouter.AdminSitePageEdit).Methods("GET") 129 | siteAdmin.HandleFunc("/page/edit/{pageId:[0-9]+}", beuboRouter.AdminSitePageEditPost).Methods("POST") 130 | 131 | siteAdmin.HandleFunc("/page/delete/{pageId:[0-9]+}", beuboRouter.AdminSitePageDelete) 132 | 133 | r.HandleFunc("/logout", beuboRouter.Logout) 134 | 135 | muxer := http.NewServeMux() 136 | muxer.Handle("/", negroni.New( 137 | negroni.HandlerFunc(beuboMiddleware.Site), 138 | negroni.HandlerFunc(beuboMiddleware.Whitelist), 139 | negroni.HandlerFunc(beuboMiddleware.Auth), 140 | negroni.HandlerFunc(throttleMiddleware.Throttle), 141 | negroni.HandlerFunc(beuboMiddleware.Plugin), 142 | negroni.Wrap(r), 143 | )) 144 | 145 | n.UseHandler(muxer) 146 | 147 | log.Println("HTTP Server listening on:", port) 148 | err = http.ListenAndServe(fmt.Sprintf(":%d", port), n) 149 | if err != nil { 150 | log.Println(err) 151 | } 152 | } 153 | 154 | // registerStaticFiles handles the loading of all static files for all templates 155 | func registerStaticFiles(r *mux.Router) *mux.Router { 156 | var err error 157 | 158 | themedir := "themes/" 159 | files, err := ioutil.ReadDir(themedir) 160 | utility.ErrorHandler(err, false) 161 | for _, f := range files { 162 | if !f.IsDir() { 163 | continue 164 | } 165 | themes = append(themes, f.Name()) 166 | // Register file paths for themes 167 | fileServers[f.Name()+"_css"] = http.FileServer(http.Dir(themedir + f.Name() + "/css/")) 168 | fileServers[f.Name()+"_js"] = http.FileServer(http.Dir(themedir + f.Name() + "/js/")) 169 | fileServers[f.Name()+"_images"] = http.FileServer(http.Dir(themedir + f.Name() + "/images/")) 170 | fileServers[f.Name()+"_fonts"] = http.FileServer(http.Dir(themedir + f.Name() + "/fonts/")) 171 | 172 | r.PathPrefix("/" + f.Name() + "/css/").Handler(http.StripPrefix("/"+f.Name()+"/css/", fileServers[f.Name()+"_css"])) 173 | r.PathPrefix("/" + f.Name() + "/js/").Handler(http.StripPrefix("/"+f.Name()+"/js/", fileServers[f.Name()+"_js"])) 174 | r.PathPrefix("/" + f.Name() + "/images/").Handler(http.StripPrefix("/"+f.Name()+"/images/", fileServers[f.Name()+"_images"])) 175 | r.PathPrefix("/" + f.Name() + "/favicon.ico").Handler(fileServers["/"+f.Name()+"_images"]) 176 | r.PathPrefix("/" + f.Name() + "/fonts/").Handler(http.StripPrefix("/"+f.Name()+"/fonts/", fileServers[f.Name()+"_fonts"])) 177 | } 178 | return r 179 | } 180 | -------------------------------------------------------------------------------- /settings.go: -------------------------------------------------------------------------------- 1 | package beubo 2 | 3 | import ( 4 | "context" 5 | "encoding/hex" 6 | "errors" 7 | "fmt" 8 | "github.com/gorilla/mux" 9 | "github.com/gorilla/securecookie" 10 | "github.com/joho/godotenv" 11 | "github.com/uberswe/beubo/pkg/routes" 12 | "github.com/uberswe/beubo/pkg/structs" 13 | "github.com/uberswe/beubo/pkg/structs/page" 14 | "github.com/uberswe/beubo/pkg/structs/page/menu" 15 | "github.com/uberswe/beubo/pkg/template" 16 | "github.com/uberswe/beubo/pkg/utility" 17 | "github.com/urfave/negroni" 18 | "gorm.io/gorm" 19 | "gorm.io/gorm/logger" 20 | "io/ioutil" 21 | "log" 22 | "net/http" 23 | "os" 24 | "time" 25 | ) 26 | 27 | var ( 28 | databaseHost = "localhost" 29 | databaseName = "" 30 | databaseUser = "" 31 | databasePassword = "" 32 | databasePort = "3306" 33 | databaseDriver = "mysql" 34 | environment = "production" 35 | testuser = "" 36 | testpass = "" 37 | 38 | rootDir = "./themes/" 39 | currentTheme = "default" 40 | installed = false // TODO handle this in a middleware or something 41 | reloadTemplates = false 42 | 43 | sessionKey = securecookie.GenerateRandomKey(64) 44 | 45 | failures map[string]map[string]string 46 | ) 47 | 48 | type logWriter struct { 49 | } 50 | 51 | func (writer logWriter) Write(bytes []byte) (int, error) { 52 | return fmt.Printf("[beubo] %s | %s", time.Now().Format("2006-01-02T15:04:05-07:00"), string(bytes)) 53 | } //2006-01-02T15:04:05.999Z 54 | 55 | // Initializes the settings and checks if Beubo is installed by checking the env file 56 | // If no env file is present then this function will start it's own http listener to go through the installation process 57 | func settingsInit() { 58 | 59 | log.SetFlags(0) 60 | log.SetOutput(new(logWriter)) 61 | 62 | err := godotenv.Load() 63 | 64 | if err != nil { 65 | // No .env file 66 | utility.ErrorHandler(err, false) 67 | log.Println("Attempting to create .env file") 68 | writeEnv("", "", "", "", "", "", "") 69 | } 70 | 71 | rootDir = setSetting(os.Getenv("ASSETS_DIR"), rootDir) 72 | currentTheme = setSetting(os.Getenv("THEME"), currentTheme) 73 | 74 | databaseHost = setSetting(os.Getenv("DB_HOST"), databaseHost) 75 | databaseName = setSetting(os.Getenv("DB_NAME"), databaseName) 76 | databaseUser = setSetting(os.Getenv("DB_USER"), databaseUser) 77 | databaseDriver = setSetting(os.Getenv("DB_DRIVER"), databaseDriver) 78 | databasePassword = setSetting(os.Getenv("DB_PASSWORD"), databasePassword) 79 | shouldRefreshDatabase = setBoolSetting(os.Getenv("REFRESH_DATABASE"), shouldRefreshDatabase) 80 | shouldSeed = setBoolSetting(os.Getenv("SEED_DATABASE"), shouldSeed) 81 | 82 | environment = setSetting(os.Getenv("ENVIRONMENT"), environment) 83 | 84 | testpass = setSetting(os.Getenv("TEST_PASS"), testpass) 85 | testuser = setSetting(os.Getenv("TEST_USER"), testuser) 86 | 87 | sessionKeyBytes, err := hex.DecodeString(os.Getenv("SESSION_KEY")) 88 | utility.ErrorHandler(err, false) 89 | if len(sessionKeyBytes) > 1 { 90 | sessionKey = sessionKeyBytes 91 | } 92 | 93 | if databaseName != "" { 94 | installed = true 95 | } else { 96 | log.Println("No installation detected, starting install server") 97 | srv := startInstallServer() 98 | 99 | // TODO there might be a bug here where we might have multiple instances waiting for installed to be true which causes an infinite loop 100 | // TODO make this use a channel instead of a loop to wait for install to finish 101 | for !installed { 102 | // Pause for 100 ms, this was causing high cpu load without this here 103 | time.Sleep(time.Millisecond * 100) 104 | // Keep running install server until installed is finished 105 | } 106 | 107 | if err := srv.Shutdown(context.TODO()); err != nil { 108 | panic(err) // failure/timeout shutting down the server gracefully 109 | } 110 | log.Println("Install complete, restarting server") 111 | // settingsInit() calls itself after install to reload settings 112 | settingsInit() 113 | } 114 | 115 | } 116 | 117 | // startInstallServer starts a http listener and presents the installation page to anyone visiting the port on the host 118 | func startInstallServer() *http.Server { 119 | r := mux.NewRouter() 120 | n := negroni.Classic() 121 | 122 | beuboTemplateRenderer := template.BeuboTemplateRenderer{ 123 | ReloadTemplates: true, 124 | CurrentTheme: "install", 125 | } 126 | 127 | beuboTemplateRenderer.Init() 128 | 129 | beuboRouter := &routes.BeuboRouter{ 130 | Renderer: &beuboTemplateRenderer, 131 | } 132 | 133 | r.NotFoundHandler = http.HandlerFunc(beuboRouter.NotFoundHandler) 134 | 135 | log.Println("Registering themes...") 136 | 137 | r = registerStaticFiles(r) 138 | 139 | log.Println("Registering routes...") 140 | 141 | r.HandleFunc("/", Install) 142 | 143 | n.UseHandler(r) 144 | 145 | srv := &http.Server{Addr: fmt.Sprintf(":%d", port), Handler: n} 146 | 147 | log.Println("HTTP Server listening on:", port) 148 | go func() { 149 | // returns ErrServerClosed on graceful close 150 | if err := srv.ListenAndServe(); err != http.ErrServerClosed { 151 | // NOTE: there is a chance that next line won't have time to run, 152 | // as main() doesn't wait for this goroutine to stop. don't use 153 | // code with race conditions like these for production. see post 154 | // comments below on more discussion on how to handle this. 155 | log.Fatalf("ListenAndServe(): %s", err) 156 | } 157 | log.Println("Server stopped") 158 | }() 159 | 160 | // returning reference so caller can call Shutdown() 161 | return srv 162 | } 163 | 164 | // Install handles installation requests and presents the install page 165 | func Install(w http.ResponseWriter, r *http.Request) { 166 | 167 | beuboTemplateRenderer := template.BeuboTemplateRenderer{ 168 | ReloadTemplates: true, 169 | CurrentTheme: "install", 170 | } 171 | 172 | beuboTemplateRenderer.Init() 173 | 174 | pageData := structs.PageData{ 175 | Template: "finished", 176 | Title: "Install", 177 | } 178 | 179 | formKey := "form" 180 | dbhostKey := "dbhost" 181 | dbnameKey := "dbname" 182 | dbuserKey := "dbuser" 183 | dbpasswordKey := "dbpassword" 184 | dbdriverKey := "dbdriver" 185 | usernameKey := "username" 186 | passwordKey := "password" 187 | 188 | if failures == nil { 189 | failures = make(map[string]map[string]string) 190 | } 191 | 192 | extra := make(map[string]map[string]string) 193 | 194 | if r.Method == http.MethodPost { 195 | extra[formKey] = make(map[string]string) 196 | err := r.ParseForm() 197 | if err != nil { 198 | utility.ErrorHandler(err, false) 199 | } 200 | 201 | extra[formKey][dbhostKey] = r.PostFormValue(dbhostKey) 202 | extra[formKey][dbnameKey] = r.PostFormValue(dbnameKey) 203 | extra[formKey][dbuserKey] = r.PostFormValue(dbuserKey) 204 | extra[formKey][dbpasswordKey] = r.PostFormValue(dbpasswordKey) 205 | extra[formKey][dbdriverKey] = r.PostFormValue(dbdriverKey) 206 | extra[formKey][usernameKey] = r.PostFormValue(usernameKey) 207 | extra[formKey][passwordKey] = r.PostFormValue(passwordKey) 208 | 209 | token, err := utility.GenerateToken(30) 210 | if err != nil { 211 | panic(err) 212 | } 213 | 214 | failures[token] = extra[formKey] 215 | 216 | utility.SetFlash(w, "token", []byte(token)) 217 | 218 | if len(extra[formKey][usernameKey]) < 8 || len(extra[formKey][passwordKey]) < 8 { 219 | err = errors.New("username and password must be filled with a minimum of 8 characters") 220 | utility.SetFlash(w, "error", []byte(err.Error())) 221 | // Redirect back with error 222 | w.Header().Add("Location", "/") 223 | w.WriteHeader(302) 224 | return 225 | } 226 | 227 | newLogger := logger.New( 228 | log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer 229 | logger.Config{ 230 | SlowThreshold: time.Second, // Slow SQL threshold 231 | LogLevel: logger.Info, // Log level 232 | Colorful: true, 233 | }, 234 | ) 235 | config := gorm.Config{ 236 | Logger: newLogger, 237 | } 238 | if extra[formKey][dbdriverKey] == "sqlite3" { 239 | config = gorm.Config{ 240 | DisableForeignKeyConstraintWhenMigrating: true, 241 | Logger: newLogger, 242 | } 243 | } 244 | 245 | dialector := getDialector(extra[formKey][dbuserKey], extra[formKey][dbpasswordKey], extra[formKey][dbhostKey], databasePort, extra[formKey][dbnameKey], extra[formKey][dbdriverKey]) 246 | _, err = gorm.Open(dialector, &config) 247 | if err != nil { 248 | utility.SetFlash(w, "error", []byte(err.Error())) 249 | // Redirect back with error 250 | w.Header().Add("Location", "/") 251 | w.WriteHeader(302) 252 | return 253 | } 254 | 255 | log.Println("no error, install done") 256 | writeEnv("", "", extra[formKey][dbhostKey], extra[formKey][dbnameKey], extra[formKey][dbuserKey], extra[formKey][dbpasswordKey], extra[formKey][dbdriverKey]) 257 | beuboTemplateRenderer.RenderHTMLPage(w, r, pageData) 258 | currentTheme = "default" 259 | prepareSeed(extra[formKey][usernameKey], extra[formKey][passwordKey]) 260 | databaseName = extra[formKey][dbnameKey] 261 | installed = true 262 | return 263 | } 264 | 265 | menuItems := []page.MenuItem{ 266 | {Text: "Home", URI: "/"}, 267 | } 268 | 269 | menus := []page.Menu{menu.DefaultMenu{ 270 | Items: menuItems, 271 | Identifier: "header", 272 | T: beuboTemplateRenderer.T, 273 | }} 274 | 275 | extra = make(map[string]map[string]string) 276 | token, err := utility.GetFlash(w, r, "token") 277 | if err == nil { 278 | extra[formKey] = make(map[string]string) 279 | extra[formKey] = failures[string(token)] 280 | failures[string(token)] = nil 281 | } 282 | pageData = structs.PageData{ 283 | Theme: "install", 284 | Template: "page", 285 | Title: "Install", 286 | Extra: extra, 287 | Menus: menus, 288 | } 289 | beuboTemplateRenderer.RenderHTMLPage(w, r, pageData) 290 | return 291 | 292 | } 293 | 294 | // setSetting returns the key value if it is set and otherwise falls back to return the variable value 295 | func setSetting(key string, variable string) string { 296 | if key != "" { 297 | variable = key 298 | } 299 | return variable 300 | } 301 | 302 | func setBoolSetting(key string, variable bool) bool { 303 | if key == "true" { 304 | return true 305 | } else if key == "false" { 306 | return false 307 | } 308 | return variable 309 | } 310 | 311 | // writeEnv writes environmental variables to an .env file 312 | func writeEnv(assetDir string, theme string, dbHost string, dbName string, dbUser string, dbPassword string, dbDriver string) { 313 | envContent := []byte("ASSETS_DIR=" + assetDir + "\nTHEME=" + theme + "\n\nDB_DRIVER=" + dbDriver + "\nDB_HOST=" + dbHost + "\nDB_NAME=" + dbName + "\nDB_USER=" + dbUser + "\nDB_PASSWORD=" + dbPassword + "\nSESSION_KEY=" + hex.EncodeToString(sessionKey)) 314 | // TODO allow users to specify folder or even config filename, maybe beuboConfig 315 | err := ioutil.WriteFile(".env", envContent, 0600) // TODO allow user to change permissions here? 316 | 317 | // We panic if we can not write env 318 | utility.ErrorHandler(err, false) 319 | } 320 | -------------------------------------------------------------------------------- /themes/README.md: -------------------------------------------------------------------------------- 1 | # Themes 2 | 3 | This is the themes directory. Beubo will look here for any static files that are needed when loading 4 | pages. I have included third party files directly in the repository and have opted not to use a package 5 | manager like npm because I feel that it's simpler and easier to understand. 6 | 7 | The default theme for Beubo can be found here: [https://github.com/uberswe/beubo-default](https://github.com/uberswe/beubo-default) 8 | 9 | Any third party libraries and files will have a license file or show a license at the top of the file. 10 | 11 | ## Best practice 12 | 13 | This is a general rule that I like to stick to. Themes should aim to be less than 50kb in size 14 | per page including css and any js but not counting images. Images should be as small as possible 15 | (50kb or less) without losing too much quality. --------------------------------------------------------------------------------