diff options
-rw-r--r-- | Makefile | 3 | ||||
-rw-r--r-- | README.md | 54 | ||||
-rw-r--r-- | config.go | 8 | ||||
-rw-r--r-- | config/debug.json | 5 | ||||
-rw-r--r-- | config/default.json | 5 | ||||
-rw-r--r-- | data/storage/recipes/0/text | 3 | ||||
-rw-r--r-- | data/templates/recipe.html | 2 | ||||
-rw-r--r-- | data/templates/recipe_edit.html | 2 | ||||
-rw-r--r-- | database.go | 126 | ||||
-rw-r--r-- | go.mod | 5 | ||||
-rw-r--r-- | go.sum | 2 | ||||
-rw-r--r-- | handler.go | 233 | ||||
-rw-r--r-- | main.go | 19 | ||||
-rw-r--r-- | sql/0001_migration.sql | 18 | ||||
-rw-r--r-- | sql/0002_migration.sql | 25 |
15 files changed, 107 insertions, 403 deletions
@@ -21,9 +21,6 @@ install: all install -Dm 644 data/templates/index.html $(DESTDIR)$(PREFIX)/share/ceres/templates/index.html install -Dm 644 data/templates/recipe.html $(DESTDIR)$(PREFIX)/share/ceres/templates/recipe.html - - install -Dm 644 sql/0001_migration.sql $(DESTDIR)$(PREFIX)/share/ceres/migrations/0001_migration.sql - install -Dm 644 sql/0002_migration.sql $(DESTDIR)$(PREFIX)/share/ceres/migrations/0002_migration.sql debug: go run *.go -c config/debug.json @@ -19,54 +19,6 @@ Please install the following dependencies to build and run Ceres: - [Git][5] - [Make][6] - [Go][7] -- [MariaDB][3] - - -## Setup Ceres database - -Ceres supports only the [MariaDB][3] SQL implementation which is available on a -lot of Linux distributions. - -Database creation should be easy so please install MariaDB on your system and -then just run the first migration script with root user rights like this: - -``` - mariadb -u root < sql/0001_migration.sql -``` - -After database creation you have to also grant access to the database for a -Linux user. See the next section for details. - - -## Grant access to Ceres database for a Linux user - -If you want to use Ceres for production it is recommended to add a Linux user -for this purpose like this: - -``` - useradd ceres -``` - -For development you can just go on with your default Linux username. First -start an interactive MariaDB shell like this: - -``` - mariadb -u root -``` - -And then add a corresponding MariaDB user for the selected Linux user and set -corresponding access rights (substitute <user> with the selected username): - -``` - CREATE USER IF NOT EXISTS '<user>'@'localhost' IDENTIFIED VIA unix_socket; - GRANT ALL PRIVILEGES on ceres.* to '<user>'@'localhost'; - FLUSH PRIVILEGES; -``` - -The `unix_socket` authentication method ensures that the corresponding user -does not need to provide a password. - -Finally you can quit the MariaDB shell with CTRL + d. ## Build and run Ceres @@ -74,9 +26,8 @@ Finally you can quit the MariaDB shell with CTRL + d. If you just cloned the repository it is important to initialize Git submodules to add dependencies via `git submodule update --init`. -Then it is time to build and run Ceres for the first time! If your database is -correctly configured and your current user has access rights, it is very simple -to run the server in debug mode: +Then it is time to build and run Ceres for the first time in debug mode like +this: ``` make debug @@ -118,7 +69,6 @@ executable at boot. [1]: https://xengineering.eu/git/ceres [2]: ./CHANGELOG.md -[3]: https://mariadb.com/ [4]: https://www.gnu.org/software/coreutils/ [5]: https://git-scm.com/ [6]: https://www.gnu.org/software/make/ @@ -12,7 +12,6 @@ import ( type RuntimeConfig struct { Path string Http HttpConfig `json:"http"` - Database DatabaseConfig `json:"database"` } type HttpConfig struct { @@ -22,13 +21,6 @@ type HttpConfig struct { Templates string `json:"templates"` } -type DatabaseConfig struct { - Socket string `json:"socket"` - User string `json:"user"` - Database string `json:"database"` - Migrations string `json:"migrations"` -} - func GetRuntimeConfig() RuntimeConfig { config := RuntimeConfig{} diff --git a/config/debug.json b/config/debug.json index b3f5cbd..8333e1e 100644 --- a/config/debug.json +++ b/config/debug.json @@ -4,10 +4,5 @@ "bind_port":"8080", "static":"./data/static", "templates":"./data/templates" - }, - "database":{ - "socket":"/run/mysqld/mysqld.sock", - "database":"ceres", - "migrations":"./sql" } } diff --git a/config/default.json b/config/default.json index 33ff4a4..0fbcf09 100644 --- a/config/default.json +++ b/config/default.json @@ -4,10 +4,5 @@ "bind_port":"8080", "static":"/usr/share/ceres/static", "templates":"/usr/share/ceres/templates" - }, - "database":{ - "socket":"/run/mysqld/mysqld.sock", - "database":"ceres", - "migrations":"/usr/share/ceres/migrations" } } diff --git a/data/storage/recipes/0/text b/data/storage/recipes/0/text index 208828a..3eb81ce 100644 --- a/data/storage/recipes/0/text +++ b/data/storage/recipes/0/text @@ -2,5 +2,6 @@ Important steps: -- cook it +- cook it smoothly +- serve it - eat it diff --git a/data/templates/recipe.html b/data/templates/recipe.html index b1fc550..972a8bf 100644 --- a/data/templates/recipe.html +++ b/data/templates/recipe.html @@ -15,7 +15,7 @@ </header> <main> - {{.RenderedDescriptionMarkdown}} + {{.Html}} <a href="./recipe/edit?id={{.Id}}"><button>edit</button></a> {{ template "footer.html" }} diff --git a/data/templates/recipe_edit.html b/data/templates/recipe_edit.html index 4860c6a..9069d4a 100644 --- a/data/templates/recipe_edit.html +++ b/data/templates/recipe_edit.html @@ -15,7 +15,7 @@ <main> <p>Recipe ID: {{.Id}}</p> - <pre contenteditable="true" id="editor"><code>{{.DescriptionMarkdown}}</code></pre> + <pre contenteditable="true" id="editor"><code>{{.Text}}</code></pre> <button onclick="save()">save</button> <a href="/recipe?id={{.Id}}"><button>back</button></a> <a href="/recipe/confirm-deletion?id={{.Id}}"><button style="background-color:red">delete</button></a> diff --git a/database.go b/database.go deleted file mode 100644 index ffc44e3..0000000 --- a/database.go +++ /dev/null @@ -1,126 +0,0 @@ -package main - -import ( - "database/sql" - "fmt" - "io" - "log" - "os" - "os/exec" - "os/user" - "path/filepath" - "strconv" - - _ "github.com/go-sql-driver/mysql" -) - -const neededSchemaVersion int = 2 - -func setupDatabase() *sql.DB { - - u, err := user.Current() - if err != nil { - log.Fatal(err) - } - target := fmt.Sprintf("%s@unix(%s)/%s", u.Username, config.Database.Socket, - config.Database.Database) - - db, err := sql.Open("mysql", target) - if err != nil { - log.Fatal(err) - } - - err = db.Ping() - if err != nil { - log.Fatal(err) - } - - migrate(db) - - log.Printf("Connected to database: %s\n", target) - - return db -} - -func migrate(db *sql.DB) { - - for { - v := schemaVersion(db) - - if v > neededSchemaVersion { - log.Fatalf( - "Current database schema version is %d but newest is %d!", v, - neededSchemaVersion) - } - - if v == neededSchemaVersion { - break - } - - log.Printf("Starting database schema migration to version %d.\n", v+1) - path := filepath.Join(config.Database.Migrations, - fmt.Sprintf("%04d_migration.sql", v+1)) - RunSqlScript(path) - log.Printf("Finished database schema migration to version %d.\n", v+1) - } -} - -func RunSqlScript(path string) { - - script, err := os.Open(path) - if err != nil { - log.Fatalf("Could not open SQL script '%s'!\n", path) - } - - cmd := exec.Command("mariadb") - stdin, err := cmd.StdinPipe() - if err != nil { - log.Fatalf("Could not open stdin of mariadb process!\n%v", err) - } - - err = cmd.Start() - if err != nil { - log.Fatalf("Could not start mariadb process!\n%v", err) - } - io.Copy(stdin, script) - stdin.Close() - - err = cmd.Wait() - if err != nil { - log.Fatalf("Failed to wait for SQL script to finish!\n%v", err) - } -} - -func schemaVersion(db *sql.DB) int { - - cmd := "SELECT value FROM meta WHERE (identifier='version');" - rows, err := db.Query(cmd) - if err != nil { - log.Fatal(err) - } - defer rows.Close() - - rows.Next() - var version string - err = rows.Scan(&version) - if err != nil { - log.Fatal(err) - } - - v, err := strconv.Atoi(version) - if err != nil { - log.Fatalf("Could not convert database schema version '%s' to int.\n", - version) - } - - return v -} - -func dbCleanup(db *sql.DB) { - err := db.Close() - if err != nil { - log.Println("Could not close database connection") - } else { - log.Println("Closed database connection") - } -} @@ -2,7 +2,4 @@ module xengineering.eu/ceres go 1.16 -require ( - github.com/go-sql-driver/mysql v1.6.0 - github.com/yuin/goldmark v1.4.13 -) +require github.com/yuin/goldmark v1.4.13 @@ -1,4 +1,2 @@ -github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= -github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -2,6 +2,7 @@ package main import ( "bufio" + "os" "bytes" "fmt" "io/ioutil" @@ -9,6 +10,7 @@ import ( "path/filepath" "regexp" "strings" + "strconv" "github.com/yuin/goldmark" ) @@ -17,6 +19,13 @@ const ( VALID_ID_REGEX = `^[0-9]+$` ) +type Recipe struct { + Id string + Title string + Text string + Html string +} + func titleFromMd(md string) string { scanner := bufio.NewScanner(strings.NewReader(md)) for scanner.Scan() { @@ -31,34 +40,36 @@ func titleFromMd(md string) string { func indexGet(w http.ResponseWriter, r *http.Request) { - cmd := "SELECT id,description_markdown FROM recipes ORDER BY id;" - rows, err := db.Query(cmd) + entries, err := os.ReadDir("data/storage/recipes") if err != nil { - http.Error(w, "Failed to load recipes from database.", 500) + http.Error(w, "Could not list recipes!", 500) return } - defer rows.Close() - type Element struct { - Id string - Title string - DescriptionMarkdown string - } - elements := make([]Element, 0) + recipes := make([]Recipe, 0) + + for _,v := range entries { + if v.IsDir() == false { + continue + } - for rows.Next() { - var element Element - err := rows.Scan(&element.Id, &element.DescriptionMarkdown) + _, err = strconv.Atoi(v.Name()) if err != nil { - http.Error(w, "Could not parse recipe from database request.", 500) - return - } else { - element.Title = titleFromMd(element.DescriptionMarkdown) - elements = append(elements, element) + continue } + + textpath := fmt.Sprintf("data/storage/recipes/%s/text", v.Name()) + data, _ := ioutil.ReadFile(textpath) + + recipes = append(recipes, Recipe{ + v.Name(), + titleFromMd(string(data)), + string(data), + "", + }) } - ServeTemplate(w, "index.html", elements) + ServeTemplate(w, "index.html", recipes) } func recipeGet(w http.ResponseWriter, r *http.Request) { @@ -71,104 +82,47 @@ func recipeGet(w http.ResponseWriter, r *http.Request) { } idStr := ids[0] - idRegex := regexp.MustCompile(VALID_ID_REGEX) - if !(idRegex.MatchString(idStr)) { - http.Error(w, "Bad 'id' URL parameter.", 400) - return - } - - cmd := fmt.Sprintf("SELECT description_markdown FROM recipes WHERE (id='%s');", idStr) - rows, err := db.Query(cmd) - if err != nil { - http.Error(w, "Database returned error: "+err.Error(), 500) - return - } - defer rows.Close() - - type Element struct { - Id string - Title string - DescriptionMarkdown string - RenderedDescriptionMarkdown string - } - elements := make([]Element, 0) + textpath := fmt.Sprintf("data/storage/recipes/%s/text", idStr) + data, _ := ioutil.ReadFile(textpath) - for rows.Next() { - var element Element - element.Id = idStr - err := rows.Scan(&element.DescriptionMarkdown) - if err != nil { - http.Error(w, "Could not parse recipe from database request.", 500) - return - } else { - element.Title = titleFromMd(element.DescriptionMarkdown) - elements = append(elements, element) - } - } - - if len(elements) != 1 { - http.Error(w, "Expected exactly 1 recipe from database.", 500) - return + recipe := Recipe{ + idStr, + titleFromMd(string(data)), + string(data), + "", } titleRegex := regexp.MustCompile(`\# .*`) - elements[0].DescriptionMarkdown = titleRegex.ReplaceAllString(elements[0].DescriptionMarkdown, "") + recipe.Text = titleRegex.ReplaceAllString(recipe.Text, "") - // render markdown var buf bytes.Buffer - goldmark.Convert([]byte(elements[0].DescriptionMarkdown), &buf) - elements[0].RenderedDescriptionMarkdown = buf.String() + goldmark.Convert([]byte(recipe.Text), &buf) + recipe.Html = buf.String() - ServeTemplate(w, "recipe.html", elements[0]) + ServeTemplate(w, "recipe.html", recipe) } func recipeEditGet(w http.ResponseWriter, r *http.Request) { ids := r.URL.Query()["id"] if len(ids) != 1 { - http.Error(w, "Exactly 1 'id' URL parameter expected.", 400) + msg := fmt.Sprintf("Exactly 1 'id' URL parameter expected but %d provided.", len(ids)) + http.Error(w, msg, 400) return } idStr := ids[0] - idRegex := regexp.MustCompile(VALID_ID_REGEX) - if !(idRegex.MatchString(idStr)) { - http.Error(w, "Bad 'id' URL parameter.", 400) - return - } - - cmd := fmt.Sprintf("SELECT description_markdown FROM recipes WHERE (id='%s');", idStr) - rows, err := db.Query(cmd) - if err != nil { - http.Error(w, "Got error from database: "+err.Error(), 500) - return - } - defer rows.Close() - - type Element struct { - Id string - DescriptionMarkdown string - } - elements := make([]Element, 0) + textpath := fmt.Sprintf("data/storage/recipes/%s/text", idStr) + data, _ := ioutil.ReadFile(textpath) - for rows.Next() { - var element Element - element.Id = idStr - err := rows.Scan(&element.DescriptionMarkdown) - if err != nil { - http.Error(w, "Could not parse recipe from database request.", 500) - return - } else { - elements = append(elements, element) - } + recipe := Recipe{ + idStr, + "", + string(data), + "", } - if len(elements) != 1 { - http.Error(w, "Did not get exactly one recipe from database.", 500) - return - } - - ServeTemplate(w, "recipe_edit.html", elements[0]) + ServeTemplate(w, "recipe_edit.html", recipe) } func recipeEditPost(w http.ResponseWriter, r *http.Request) { @@ -186,9 +140,17 @@ func recipeEditPost(w http.ResponseWriter, r *http.Request) { return } - buffer, _ := ioutil.ReadAll(r.Body) // FIXME error handling - body := string(buffer) - updateRecipe(body, idStr) + buffer, err := ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Could not read request body.", 400) + return + } + + textpath := fmt.Sprintf("data/storage/recipes/%s/text", idStr) + err = ioutil.WriteFile(textpath, buffer, 0644) + if err != nil { + http.Error(w, "Could not save new text for recipe.", 500) + } } func recipeConfirmDeletionGet(w http.ResponseWriter, r *http.Request) { @@ -198,15 +160,10 @@ func recipeConfirmDeletionGet(w http.ResponseWriter, r *http.Request) { http.Error(w, "Exactly 1 'id' URL parameter expected.", 400) return } - idStr := ids[0] - type Element struct { - Id string - } - var element Element - element.Id = idStr + recipe := Recipe{ids[0], "", "", ""} - ServeTemplate(w, "recipe_confirm_deletion.html", element) + ServeTemplate(w, "recipe_confirm_deletion.html", recipe) } func recipeConfirmDeletionPost(w http.ResponseWriter, r *http.Request) { @@ -216,12 +173,10 @@ func recipeConfirmDeletionPost(w http.ResponseWriter, r *http.Request) { http.Error(w, "Exactly 1 'id' URL parameter expected.", 400) return } - idStr := ids[0] - cmd := fmt.Sprintf("DELETE FROM recipes where (id='%s');", idStr) - _, err := db.Query(cmd) + recipedir := fmt.Sprintf("data/storage/recipes/%s", ids[0]) + err := os.RemoveAll(recipedir) if err != nil { - fmt.Print(err) http.Error(w, "Could not delete recipe.", 500) return; } @@ -229,37 +184,49 @@ func recipeConfirmDeletionPost(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/index.html", 303) } -func updateRecipe(body string, idStr string) { +func addRecipesGet(w http.ResponseWriter, r *http.Request) { - _, _ = db.Exec(` - UPDATE - recipes - SET - description_markdown=? - WHERE - (id=?); - `, - body, idStr, - ) // FIXME error handling + entries, err := os.ReadDir("data/storage/recipes") + if err != nil { + http.Error(w, "Could not get list of existing recipes!", 500) + return + } - return -} + var biggest int = -1 -func addRecipesGet(w http.ResponseWriter, r *http.Request) { + for _,v := range entries { + if v.IsDir() == false { + continue + } - cmd := fmt.Sprintf("INSERT INTO recipes (title) VALUES ('%s')", "New recipe") - res, err := db.Exec(cmd) + number, err := strconv.Atoi(v.Name()) + if err != nil { + continue + } + + if number > biggest { + biggest = number + } + } + + newId := biggest + 1 + + recipedir := fmt.Sprintf("data/storage/recipes/%d", newId) + err = os.Mkdir(recipedir, 0755) if err != nil { - http.Error(w, "Could not create recipe.", 500) + http.Error(w, "Could not create new recipe!", 500) return } - id, err := res.LastInsertId() + + textpath := fmt.Sprintf("data/storage/recipes/%d/text", newId) + err = os.WriteFile(textpath, make([]byte, 0), 0644) if err != nil { - http.Error(w, "Could not get new recipe ID from database", 500) - } else { - redirect := fmt.Sprintf("/recipe/edit?id=%d", id) - http.Redirect(w, r, redirect, 303) + http.Error(w, "Could not create new recipe!", 500) + return } + + redirect := fmt.Sprintf("/recipe/edit?id=%d", newId) + http.Redirect(w, r, redirect, 303) } func staticGet(w http.ResponseWriter, r *http.Request, filename string) { @@ -1,35 +1,16 @@ package main import ( - "database/sql" "log" - "os" - "os/signal" - "syscall" "text/template" ) var config RuntimeConfig -var db *sql.DB var templates *template.Template func main() { log.Printf("Started Ceres recipe server.\n") config = GetRuntimeConfig() templates = setupTemplates() - db = setupDatabase() - provideShutdown() runServer() } - -func provideShutdown() { - var listener = make(chan os.Signal) - signal.Notify(listener, syscall.SIGTERM) - signal.Notify(listener, syscall.SIGINT) - go func() { - signal := <-listener - log.Printf("Got signal '%+v'. Shutdown is started.\n", signal) - dbCleanup(db) - os.Exit(0) - }() -} diff --git a/sql/0001_migration.sql b/sql/0001_migration.sql deleted file mode 100644 index 9502e6c..0000000 --- a/sql/0001_migration.sql +++ /dev/null @@ -1,18 +0,0 @@ - --------------------------------------------------------------------------------- - --- This migration script adds the initial version of the Ceres database. --- Run this migration via `sudo mariadb -u root < 0001_migration.sql`. - --- create database for ceres, add ceres user and set privileges -CREATE DATABASE IF NOT EXISTS ceres; - --- select correct database for the rest of this script -USE ceres; - --- create meta table and set database schema version -CREATE TABLE IF NOT EXISTS meta ( - identifier varchar(80) PRIMARY KEY NOT NULL UNIQUE, - value varchar(80) NOT NULL -); -INSERT INTO meta (identifier,value) VALUES ('version', '1'); diff --git a/sql/0002_migration.sql b/sql/0002_migration.sql deleted file mode 100644 index 7c99616..0000000 --- a/sql/0002_migration.sql +++ /dev/null @@ -1,25 +0,0 @@ - --------------------------------------------------------------------------------- - --- This migration creates the initial version of the database layout. - --- Run this command as Linux user `ceres` with this command: --- `mariadb < 0002_migration.sql`. - --- set database for this script -USE ceres; - --- create recipe table -CREATE TABLE IF NOT EXISTS recipes ( - id INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, - title varchar(80) NOT NULL DEFAULT '(no title)', - upstream_url varchar(200) NOT NULL DEFAULT '', - description_markdown text NOT NULL DEFAULT '' -); - --- update database schema version -UPDATE meta -SET - value='2' -WHERE - identifier='version'; |