diff --git a/.air.toml b/.air.toml index e58a42a..d883ef7 100644 --- a/.air.toml +++ b/.air.toml @@ -4,16 +4,16 @@ tmp_dir = "tmp" [build] args_bin = [] -bin = "./tmp/main" -cmd = "go build -o ./tmp/main ." +bin = "tmp/server" +cmd = "make" delay = 1000 exclude_dir = [ "assets", "tmp", "vendor", "testdata", + "web/frontend/dist", "web/frontend/node_modules", - "web/static", ] exclude_file = [] exclude_regex = ["_test.go"] diff --git a/.env b/.env new file mode 100644 index 0000000..5c7b238 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +SESSION_KEY="67d829bf61dc5f87a73fd814e2c9f629" diff --git a/.gitignore b/.gitignore index d9dbc43..25e7016 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,28 @@ go.work tmp web/static/css/.DS_Store +# +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/Makefile b/Makefile index e5f387b..a41ffcb 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ SVELTE_COMPONENTS = status SVELTE_TARGETS = $(addprefix $(FRONTEND)/public/build/,$(addsuffix .ts, $(SVELTE_COMPONENTS))) -.PHONY: $(TARGET) +.PHONY: $(TARGET) clean .NOTPARALLEL: $(TARGET): $(SVELTE_TARGETS) @@ -15,3 +15,6 @@ $(TARGET): $(SVELTE_TARGETS) $(SVELTE_TARGETS): $(SVELTE_SRC) $(info ===> BUILD frontend) cd $(FRONTEND) && npm install && npm run build + +clean: + @rm -rf tmp diff --git a/internal/api/news.go b/internal/api/news.go new file mode 100644 index 0000000..09edf9b --- /dev/null +++ b/internal/api/news.go @@ -0,0 +1,100 @@ +package api + +import ( + "bufio" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strconv" + + "git.clustercockpit.org/moebiusband/go-http-skeleton/internal/repository" +) + +func createNewsItem(rw http.ResponseWriter, r *http.Request) { + req := repository.CreateNewsEntryParams{} + if err := decode(r.Body, &req); err != nil { + handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw) + return + } + + repo := repository.GetRepository() + err := repo.CreateNewsEntry(r.Context(), req) + if err != nil { + handleError(err, http.StatusBadRequest, rw) + return + } +} + +func readNewsItems(rw http.ResponseWriter, r *http.Request) { + repo := repository.GetRepository() + items, err := repo.ListNews(r.Context()) + if err != nil { + handleError(err, http.StatusBadRequest, rw) + return + } + + slog.Debug("/api/news returned", "newscount", len(items)) + rw.Header().Add("Content-Type", "application/json") + bw := bufio.NewWriter(rw) + defer bw.Flush() + + if err := json.NewEncoder(bw).Encode(items); err != nil { + handleError(err, http.StatusInternalServerError, rw) + return + } +} + +func readNewsItem(rw http.ResponseWriter, r *http.Request) { + repo := repository.GetRepository() + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + handleError(err, http.StatusBadRequest, rw) + return + } + + item, err := repo.GetNewsEntry(r.Context(), id) + if err != nil { + handleError(err, http.StatusBadRequest, rw) + return + } + + rw.Header().Add("Content-Type", "application/json") + bw := bufio.NewWriter(rw) + defer bw.Flush() + + if err := json.NewEncoder(bw).Encode(item); err != nil { + handleError(err, http.StatusInternalServerError, rw) + return + } +} + +func updateNewsItem(rw http.ResponseWriter, r *http.Request) { + req := repository.UpdateNewsEntryParams{} + if err := decode(r.Body, &req); err != nil { + handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw) + return + } + + repo := repository.GetRepository() + err := repo.UpdateNewsEntry(r.Context(), req) + if err != nil { + handleError(err, http.StatusBadRequest, rw) + return + } +} + +func deleteNewsItem(rw http.ResponseWriter, r *http.Request) { + repo := repository.GetRepository() + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + handleError(err, http.StatusBadRequest, rw) + return + } + + err = repo.DeleteNewsEntry(r.Context(), id) + if err != nil { + handleError(err, http.StatusBadRequest, rw) + return + } +} diff --git a/internal/api/rest.go b/internal/api/rest.go index aa71d3d..a5b22b7 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -1,15 +1,10 @@ package api import ( - "bufio" "encoding/json" - "fmt" "io" "log/slog" "net/http" - "strconv" - - "git.clustercockpit.org/moebiusband/go-http-skeleton/internal/repository" ) type ErrorResponse struct { @@ -24,6 +19,12 @@ func MountApiEndpoints(r *http.ServeMux) { r.HandleFunc("GET /api/news/{id}", readNewsItem) r.HandleFunc("PATCH /api/news/", updateNewsItem) r.HandleFunc("DELETE /api/news/{id}", deleteNewsItem) + + r.HandleFunc("POST /api/retailers/", createRetailerItem) + r.HandleFunc("GET /api/retailers/", readRetailerItems) + r.HandleFunc("GET /api/retailers/{id}", readRetailerItem) + r.HandleFunc("PATCH /api/retailers/", updateRetailerItem) + r.HandleFunc("DELETE /api/retailers/{id}", deleteRetailerItem) } func handleError(err error, statusCode int, rw http.ResponseWriter) { @@ -41,91 +42,3 @@ func decode(r io.Reader, val any) error { dec.DisallowUnknownFields() return dec.Decode(val) } - -func createNewsItem(rw http.ResponseWriter, r *http.Request) { - req := repository.CreateNewsEntryParams{} - if err := decode(r.Body, &req); err != nil { - handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw) - return - } - - repo := repository.GetRepository() - err := repo.CreateNewsEntry(r.Context(), req) - if err != nil { - handleError(err, http.StatusBadRequest, rw) - return - } -} - -func readNewsItems(rw http.ResponseWriter, r *http.Request) { - repo := repository.GetRepository() - items, err := repo.ListNews(r.Context()) - if err != nil { - handleError(err, http.StatusBadRequest, rw) - return - } - - slog.Debug("/api/news returned", "newscount", len(items)) - rw.Header().Add("Content-Type", "application/json") - bw := bufio.NewWriter(rw) - defer bw.Flush() - - if err := json.NewEncoder(bw).Encode(items); err != nil { - handleError(err, http.StatusInternalServerError, rw) - return - } -} - -func readNewsItem(rw http.ResponseWriter, r *http.Request) { - repo := repository.GetRepository() - id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) - if err != nil { - handleError(err, http.StatusBadRequest, rw) - return - } - - item, err := repo.GetNewsEntry(r.Context(), id) - if err != nil { - handleError(err, http.StatusBadRequest, rw) - return - } - - rw.Header().Add("Content-Type", "application/json") - bw := bufio.NewWriter(rw) - defer bw.Flush() - - if err := json.NewEncoder(bw).Encode(item); err != nil { - handleError(err, http.StatusInternalServerError, rw) - return - } -} - -func updateNewsItem(rw http.ResponseWriter, r *http.Request) { - req := repository.UpdateNewsEntryParams{} - if err := decode(r.Body, &req); err != nil { - handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw) - return - } - - repo := repository.GetRepository() - err := repo.UpdateNewsEntry(r.Context(), req) - if err != nil { - handleError(err, http.StatusBadRequest, rw) - return - } -} - -func deleteNewsItem(rw http.ResponseWriter, r *http.Request) { - repo := repository.GetRepository() - id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) - if err != nil { - handleError(err, http.StatusBadRequest, rw) - return - } - - err = repo.DeleteNewsEntry(r.Context(), id) - if err != nil { - handleError(err, http.StatusBadRequest, rw) - return - } -} diff --git a/internal/api/retailer.go b/internal/api/retailer.go new file mode 100644 index 0000000..5acd264 --- /dev/null +++ b/internal/api/retailer.go @@ -0,0 +1,100 @@ +package api + +import ( + "bufio" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strconv" + + "git.clustercockpit.org/moebiusband/go-http-skeleton/internal/repository" +) + +func createRetailerItem(rw http.ResponseWriter, r *http.Request) { + req := repository.CreateNewsEntryParams{} + if err := decode(r.Body, &req); err != nil { + handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw) + return + } + + repo := repository.GetRepository() + err := repo.CreateNewsEntry(r.Context(), req) + if err != nil { + handleError(err, http.StatusBadRequest, rw) + return + } +} + +func readRetailerItems(rw http.ResponseWriter, r *http.Request) { + repo := repository.GetRepository() + items, err := repo.ListRetailers(r.Context()) + if err != nil { + handleError(err, http.StatusBadRequest, rw) + return + } + + slog.Debug("/api/retailers returned", "count", len(items)) + rw.Header().Add("Content-Type", "application/json") + bw := bufio.NewWriter(rw) + defer bw.Flush() + + if err := json.NewEncoder(bw).Encode(items); err != nil { + handleError(err, http.StatusInternalServerError, rw) + return + } +} + +func readRetailerItem(rw http.ResponseWriter, r *http.Request) { + repo := repository.GetRepository() + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + handleError(err, http.StatusBadRequest, rw) + return + } + + item, err := repo.GetNewsEntry(r.Context(), id) + if err != nil { + handleError(err, http.StatusBadRequest, rw) + return + } + + rw.Header().Add("Content-Type", "application/json") + bw := bufio.NewWriter(rw) + defer bw.Flush() + + if err := json.NewEncoder(bw).Encode(item); err != nil { + handleError(err, http.StatusInternalServerError, rw) + return + } +} + +func updateRetailerItem(rw http.ResponseWriter, r *http.Request) { + req := repository.UpdateNewsEntryParams{} + if err := decode(r.Body, &req); err != nil { + handleError(fmt.Errorf("parsing request body failed: %w", err), http.StatusBadRequest, rw) + return + } + + repo := repository.GetRepository() + err := repo.UpdateNewsEntry(r.Context(), req) + if err != nil { + handleError(err, http.StatusBadRequest, rw) + return + } +} + +func deleteRetailerItem(rw http.ResponseWriter, r *http.Request) { + repo := repository.GetRepository() + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + handleError(err, http.StatusBadRequest, rw) + return + } + + err = repo.DeleteNewsEntry(r.Context(), id) + if err != nil { + handleError(err, http.StatusBadRequest, rw) + return + } +} diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go index c2b9d12..57cd45a 100644 --- a/internal/handlers/admin.go +++ b/internal/handlers/admin.go @@ -1,6 +1,7 @@ package handlers import ( + "fmt" "html/template" "log/slog" "net/http" @@ -13,11 +14,12 @@ func AdminHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tpl := template.Must(template.ParseFS(web.Templates, "templates/admin.html", "templates/base.html")) viteFragment, err := vite.HTMLFragment(vite.Config{ - FS: web.StaticAssets, + FS: web.DistFS(), IsDev: false, }) if err != nil { - http.Error(w, "Error instantiating vite fragment", http.StatusInternalServerError) + msg := fmt.Errorf("error instantiating vite fragment: %v", err) + http.Error(w, msg.Error(), http.StatusInternalServerError) return } diff --git a/main.go b/main.go index 7262715..ff66ea2 100644 --- a/main.go +++ b/main.go @@ -11,12 +11,14 @@ import ( "strings" "time" + "git.clustercockpit.org/moebiusband/go-http-skeleton/internal/api" "git.clustercockpit.org/moebiusband/go-http-skeleton/internal/auth" "git.clustercockpit.org/moebiusband/go-http-skeleton/internal/handlers" "git.clustercockpit.org/moebiusband/go-http-skeleton/internal/middleware" "git.clustercockpit.org/moebiusband/go-http-skeleton/internal/repository" "git.clustercockpit.org/moebiusband/go-http-skeleton/web" "github.com/joho/godotenv" + "golang.org/x/crypto/bcrypt" _ "modernc.org/sqlite" ) @@ -116,8 +118,14 @@ func main() { ctx := context.Background() q := repository.GetRepository() + hash, err := bcrypt.GenerateFromPassword([]byte(parts[1]), bcrypt.DefaultCost) + password := string(hash) + if err != nil { + slog.Error("Error while encrypting new user password") + os.Exit(1) + } if err := q.CreateUser(ctx, repository.CreateUserParams{ - UserName: parts[0], UserPass: &parts[1], + UserName: parts[0], UserPass: &password, }); err != nil { slog.Error("Add User: Could not add new user authentication", "username", parts[0], "error", err.Error()) os.Exit(1) @@ -215,6 +223,7 @@ func main() { }) }))) + api.MountApiEndpoints(mux) mux.HandleFunc("GET /", handlers.RootHandler()) securedMux := http.NewServeMux() diff --git a/web/frontend/.gitignore b/web/frontend/.gitignore deleted file mode 100644 index a547bf3..0000000 --- a/web/frontend/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/web/frontend/src/App.svelte b/web/frontend/src/App.svelte index 9e85973..dd23da9 100644 --- a/web/frontend/src/App.svelte +++ b/web/frontend/src/App.svelte @@ -1,45 +1,139 @@ + + +
-
- - - + + +
+ {#if activeTab === "invoice"} +

Invoice list

+ {/if} + {#if activeTab === "retailers"} +
+
+ +
+
+
+
+ + + + {/if} + {#if activeTab === "news"} +

news

+ {/if} -

Vite + Svelte

- -
- -
- -

- Check out SvelteKit, the official Svelte app framework powered by Vite! -

- -

Click on the Vite and Svelte logos to learn more

- - diff --git a/web/frontend/src/lib/GenericForm.svelte b/web/frontend/src/lib/GenericForm.svelte new file mode 100644 index 0000000..3d08f0e --- /dev/null +++ b/web/frontend/src/lib/GenericForm.svelte @@ -0,0 +1,14 @@ + + + + + + diff --git a/web/frontend/src/lib/Table.svelte b/web/frontend/src/lib/Table.svelte new file mode 100644 index 0000000..f89c564 --- /dev/null +++ b/web/frontend/src/lib/Table.svelte @@ -0,0 +1,48 @@ + + +
+ + + {#each columns as col} + + {/each} + + + + + + {#each records as row} + + {#each columns as col} + + {/each} + + + + {/each} + +
{col}editremove
{row[col]} + + + +
diff --git a/web/frontend/vite.config.ts b/web/frontend/vite.config.ts index d32eba1..f7cd9fd 100644 --- a/web/frontend/vite.config.ts +++ b/web/frontend/vite.config.ts @@ -4,4 +4,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte' // https://vite.dev/config/ export default defineConfig({ plugins: [svelte()], + build: { + manifest: true + } }) diff --git a/web/templates.go b/web/templates.go index 11dfec9..b0bf0a9 100644 --- a/web/templates.go +++ b/web/templates.go @@ -2,6 +2,8 @@ package web import ( "embed" + "fmt" + "io/fs" "net/http" "text/template" @@ -19,9 +21,17 @@ type PageData struct { //go:embed templates var Templates embed.FS -//go:embed frontend/dist +//go:embed all:frontend/dist var StaticAssets embed.FS +func DistFS() fs.FS { + efs, err := fs.Sub(StaticAssets, "frontend/dist") + if err != nil { + panic(fmt.Sprintf("unable to serve frontend: %v", err)) + } + return efs +} + func RenderTemplate(w http.ResponseWriter, name string, data PageData) error { tpl := template.Must(template.ParseFS(Templates, "templates/"+name+".html", "templates/base.html")) return tpl.ExecuteTemplate(w, "base", data) diff --git a/web/templates/admin.html b/web/templates/admin.html index 3f9a328..0146df6 100644 --- a/web/templates/admin.html +++ b/web/templates/admin.html @@ -1,3 +1,3 @@ {{define "vite"}} {{ .Vite.Tags }} {{end}} {{define "content"}} -
+
{{end}}