From 5cbb20dcc44f635c98dab94ce4f4e1c3058e3434 Mon Sep 17 00:00:00 2001 From: Andrew-71 Date: Wed, 23 Oct 2024 14:11:02 +0300 Subject: [PATCH] Refactor server routes --- CHANGELOG.md | 1 + internal/config/main.go | 6 +- internal/logging/logger.go | 8 +- internal/server/api.go | 112 --------------------- internal/server/api/main.go | 34 +++++++ internal/server/api/routes.go | 124 ++++++++++++++++++++++++ internal/server/auth.go | 108 --------------------- internal/server/auth/auth.go | 39 ++++++++ internal/server/auth/telegram.go | 77 +++++++++++++++ internal/server/config.go | 31 ------ internal/server/info.go | 25 ----- internal/server/main.go | 38 ++++++++ internal/server/{ => routes}/days.go | 17 ++-- internal/server/{ => routes}/entries.go | 14 +-- internal/server/{ => routes}/errors.go | 4 +- internal/server/routes/main.go | 38 ++++++++ internal/server/routes/misc.go | 28 ++++++ internal/server/{ => routes}/notes.go | 25 ++--- internal/server/serve.go | 78 --------------- internal/server/util/handle.go | 11 +++ internal/templates/main.go | 1 + 21 files changed, 429 insertions(+), 390 deletions(-) delete mode 100644 internal/server/api.go create mode 100644 internal/server/api/main.go create mode 100644 internal/server/api/routes.go delete mode 100644 internal/server/auth.go create mode 100644 internal/server/auth/auth.go create mode 100644 internal/server/auth/telegram.go delete mode 100644 internal/server/config.go delete mode 100644 internal/server/info.go create mode 100644 internal/server/main.go rename internal/server/{ => routes}/days.go (79%) rename internal/server/{ => routes}/entries.go (82%) rename internal/server/{ => routes}/errors.go (87%) create mode 100644 internal/server/routes/main.go create mode 100644 internal/server/routes/misc.go rename internal/server/{ => routes}/notes.go (64%) delete mode 100644 internal/server/serve.go create mode 100644 internal/server/util/handle.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5087a06..b06b768 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ These changes are not yet released Files in that directory are generated as `hibiscus_YYYY-MM-DD_HH:MM:SS.log` * Adjusted default theme * Error pages are now translated +* API: `/api/today` POST now behaves like other file uploads ## v1.1.4 diff --git a/internal/config/main.go b/internal/config/main.go index 1700092..d0241b2 100644 --- a/internal/config/main.go +++ b/internal/config/main.go @@ -37,7 +37,7 @@ type Config struct { TelegramTopic string `config:"tg_topic" type:"string"` } -var DefaultConfig = Config{ +var defaultConfig = Config{ Username: "admin", Password: "admin", Port: 7101, @@ -59,7 +59,7 @@ var DefaultConfig = Config{ func (c *Config) String() string { output := "" v := reflect.ValueOf(*c) - vDefault := reflect.ValueOf(DefaultConfig) + vDefault := reflect.ValueOf(defaultConfig) typeOfS := v.Type() for i := 0; i < v.NumField(); i++ { key := typeOfS.Field(i).Tag.Get("config") @@ -75,7 +75,7 @@ func (c *Config) String() string { // Reload resets, then loads config from the ConfigFile. // It creates the file with mandatory options if it is missing. func (c *Config) Reload() error { - *c = DefaultConfig // Reset config + *c = defaultConfig // Reset config if _, err := os.Stat(ConfigFile); errors.Is(err, os.ErrNotExist) { err := c.Save() diff --git a/internal/logging/logger.go b/internal/logging/logger.go index 6b8c18a..bec934c 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -14,15 +14,15 @@ import ( var DebugMode = false -// File returns the appropriate filename for log +// file returns the appropriate filename for log // (log_dir/hibiscus_YYYY-MM-DD_HH:MM:SS.log) -func File() string { +func file() string { return config.Cfg.LogDir + "/hibiscus_" + time.Now().In(config.Cfg.Timezone).Format("2006-01-02_15:04:05") + ".log" } // LogInit makes slog output to both os.Stdout and a file if needed, and sets slog.LevelDebug if enabled. func LogInit() { - logFile := File() + logFile := file() var w io.Writer = os.Stdout if config.Cfg.LogToFile { // Create dir in case it doesn't exist yet to avoid errors @@ -47,5 +47,5 @@ func LogInit() { } slog.SetDefault(slog.New(slog.NewTextHandler(w, opts))) middleware.DefaultLogger = middleware.RequestLogger(&middleware.DefaultLogFormatter{Logger: log.Default(), NoColor: true}) - slog.Debug("Debug mode enabled") // This string is only shown if debugging + slog.Debug("Debug mode enabled") // This string is only shown if debugging } diff --git a/internal/server/api.go b/internal/server/api.go deleted file mode 100644 index 80cff1f..0000000 --- a/internal/server/api.go +++ /dev/null @@ -1,112 +0,0 @@ -package server - -import ( - "encoding/json" - "errors" - "io" - "log/slog" - "net/http" - "os" - - "git.a71.su/Andrew71/hibiscus-txt/internal/config" - "git.a71.su/Andrew71/hibiscus-txt/internal/files" - "github.com/go-chi/chi/v5" -) - -// HandleWrite handles error in output of ResponseWriter.Write. -func HandleWrite(_ int, err error) { - if err != nil { - slog.Error("error writing response", "error", err) - } -} - -// GetFileApi returns raw contents of a file. -func GetFileApi(filename string, w http.ResponseWriter) { - fileContents, err := files.Read(filename) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - http.Error(w, "file not found", http.StatusNotFound) - } else { - http.Error(w, "error reading found", http.StatusNotFound) - } - return - } - HandleWrite(w.Write(fileContents)) -} - -// PostFileApi writes contents of Request.Body to a file. -func PostFileApi(filename string, w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - HandleWrite(w.Write([]byte("error reading body"))) - return - } - err = files.Save(filename, body) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - HandleWrite(w.Write([]byte("error saving file"))) - return - } - HandleWrite(w.Write([]byte("wrote to file"))) - w.WriteHeader(http.StatusOK) -} - -// GetFileList returns JSON list of filenames in a directory without extensions or path. -func GetFileList(directory string, w http.ResponseWriter) { - filenames, err := files.List(directory) - if err != nil { - http.Error(w, "error searching for files", http.StatusInternalServerError) - return - } - filenamesJson, err := json.Marshal(filenames) - if err != nil { - http.Error(w, "error marshaling json", http.StatusInternalServerError) - return - } - HandleWrite(w.Write(filenamesJson)) -} - -// GetDayApi returns raw contents of a daily file specified in URL. -func GetDayApi(w http.ResponseWriter, r *http.Request) { - dayString := chi.URLParam(r, "day") - if dayString == "" { - w.WriteHeader(http.StatusBadRequest) - HandleWrite(w.Write([]byte("day not specified"))) - return - } - GetFileApi(files.DataFile("day/"+dayString), w) -} - -// GetNoteApi returns contents of a note specified in URL. -func GetNoteApi(w http.ResponseWriter, r *http.Request) { - noteString := chi.URLParam(r, "note") - if noteString == "" { - w.WriteHeader(http.StatusBadRequest) - HandleWrite(w.Write([]byte("note not specified"))) - return - } - GetFileApi(files.DataFile("notes/"+noteString), w) -} - -// PostNoteApi writes contents of Request.Body to a note specified in URL. -func PostNoteApi(w http.ResponseWriter, r *http.Request) { - noteString := chi.URLParam(r, "note") - if noteString == "" { - w.WriteHeader(http.StatusBadRequest) - HandleWrite(w.Write([]byte("note not specified"))) - return - } - PostFileApi(files.DataFile("notes/"+noteString), w, r) -} - -// GraceActiveApi returns "true" if grace period is active, and "false" otherwise. -func GraceActiveApi(w http.ResponseWriter, r *http.Request) { - value := "false" - if config.Cfg.Grace() { - value = "true" - } - HandleWrite(w.Write([]byte(value))) - w.WriteHeader(http.StatusOK) -} - diff --git a/internal/server/api/main.go b/internal/server/api/main.go new file mode 100644 index 0000000..1cdb23e --- /dev/null +++ b/internal/server/api/main.go @@ -0,0 +1,34 @@ +package api + +import ( + "net/http" + + "git.a71.su/Andrew71/hibiscus-txt/internal/config" + "git.a71.su/Andrew71/hibiscus-txt/internal/files" + "git.a71.su/Andrew71/hibiscus-txt/internal/server/auth" + "github.com/go-chi/chi/v5" +) + +var ApiRouter *chi.Mux + +func init() { + ApiRouter = chi.NewRouter() + ApiRouter.Use(auth.BasicAuth) + ApiRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) { getFile("readme", w) }) + ApiRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { postFile("readme", w, r) }) + ApiRouter.Get("/day", func(w http.ResponseWriter, r *http.Request) { fileList("day", w) }) + ApiRouter.Get("/day/{day}", getDay) + ApiRouter.Get("/notes", func(w http.ResponseWriter, r *http.Request) { fileList("notes", w) }) + ApiRouter.Get("/notes/{note}", getNote) + ApiRouter.Post("/notes/{note}", postNote) + ApiRouter.Get("/today", func(w http.ResponseWriter, r *http.Request) { + getFile(files.DataFile("day/"+config.Cfg.TodayDate()), w) + }) + ApiRouter.Post("/today", func(w http.ResponseWriter, r *http.Request) { + postFile(files.DataFile("day/"+config.Cfg.TodayDate()), w, r) + }) + ApiRouter.Get("/export", files.GetExport) + ApiRouter.Get("/grace", graceStatus) + ApiRouter.Get("/version", getVersion) + ApiRouter.Get("/reload", configReload) +} diff --git a/internal/server/api/routes.go b/internal/server/api/routes.go new file mode 100644 index 0000000..5c486f1 --- /dev/null +++ b/internal/server/api/routes.go @@ -0,0 +1,124 @@ +package api + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "os" + + "git.a71.su/Andrew71/hibiscus-txt/internal/config" + "git.a71.su/Andrew71/hibiscus-txt/internal/files" + "git.a71.su/Andrew71/hibiscus-txt/internal/server/util" + "github.com/go-chi/chi/v5" +) + +// getFile returns raw contents of a file. +func getFile(filename string, w http.ResponseWriter) { + fileContents, err := files.Read(filename) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + http.Error(w, "file not found", http.StatusNotFound) + } else { + http.Error(w, "error reading found", http.StatusNotFound) + } + return + } + util.HandleWrite(w.Write(fileContents)) +} + +// postFile writes contents of Request.Body to a file. +func postFile(filename string, w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + util.HandleWrite(w.Write([]byte("error reading body"))) + return + } + err = files.Save(filename, body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + util.HandleWrite(w.Write([]byte("error saving file"))) + return + } + util.HandleWrite(w.Write([]byte("wrote to file"))) + w.WriteHeader(http.StatusOK) +} + +// fileList returns JSON list of filenames in a directory without extensions or path. +func fileList(directory string, w http.ResponseWriter) { + filenames, err := files.List(directory) + if err != nil { + http.Error(w, "error searching for files", http.StatusInternalServerError) + return + } + filenamesJson, err := json.Marshal(filenames) + if err != nil { + http.Error(w, "error marshaling json", http.StatusInternalServerError) + return + } + util.HandleWrite(w.Write(filenamesJson)) +} + +// getDay returns raw contents of a daily file specified in URL. +func getDay(w http.ResponseWriter, r *http.Request) { + dayString := chi.URLParam(r, "day") + if dayString == "" { + w.WriteHeader(http.StatusBadRequest) + util.HandleWrite(w.Write([]byte("day not specified"))) + return + } + getFile(files.DataFile("day/"+dayString), w) +} + +// getNote returns contents of a note specified in URL. +func getNote(w http.ResponseWriter, r *http.Request) { + noteString := chi.URLParam(r, "note") + if noteString == "" { + w.WriteHeader(http.StatusBadRequest) + util.HandleWrite(w.Write([]byte("note not specified"))) + return + } + getFile(files.DataFile("notes/"+noteString), w) +} + +// postNote writes contents of Request.Body to a note specified in URL. +func postNote(w http.ResponseWriter, r *http.Request) { + noteString := chi.URLParam(r, "note") + if noteString == "" { + w.WriteHeader(http.StatusBadRequest) + util.HandleWrite(w.Write([]byte("note not specified"))) + return + } + postFile(files.DataFile("notes/"+noteString), w, r) +} + +// graceStatus returns "true" if grace period is active, and "false" otherwise. +func graceStatus(w http.ResponseWriter, r *http.Request) { + value := "false" + if config.Cfg.Grace() { + value = "true" + } + util.HandleWrite(w.Write([]byte(value))) + w.WriteHeader(http.StatusOK) +} + +// getVersion returns current app version. +func getVersion(w http.ResponseWriter, r *http.Request) { + util.HandleWrite(w.Write([]byte(config.Info.Version()))) + w.WriteHeader(http.StatusOK) +} + +// configReload reloads the config. It then redirects back if Referer field is present. +func configReload(w http.ResponseWriter, r *http.Request) { + err := config.Cfg.Reload() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + util.HandleWrite(w.Write([]byte(err.Error()))) + } + if r.Referer() != "" { + http.Redirect(w, r, r.Header.Get("Referer"), http.StatusFound) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/internal/server/auth.go b/internal/server/auth.go deleted file mode 100644 index 8028124..0000000 --- a/internal/server/auth.go +++ /dev/null @@ -1,108 +0,0 @@ -package server - -import ( - "crypto/sha256" - "crypto/subtle" - "fmt" - "log/slog" - "net/http" - "os" - "strings" - "time" - - "git.a71.su/Andrew71/hibiscus-txt/internal/config" - "git.a71.su/Andrew71/hibiscus-txt/internal/lang" -) - -type failedLogin struct { - Username string - Password string - Timestamp time.Time -} - -var failedLogins []failedLogin - -// NoteLoginFail attempts to log and counteract bruteforce attacks. -func NoteLoginFail(username string, password string, r *http.Request) { - slog.Warn("failed auth", "username", username, "password", password, "address", r.RemoteAddr) - NotifyTelegram(fmt.Sprintf(lang.Translate("info.telegram.auth_fail")+":\nusername=%s\npassword=%s\nremote=%s", username, password, r.RemoteAddr)) - - attempt := failedLogin{username, password, time.Now()} - updatedLogins := []failedLogin{attempt} - for _, attempt := range failedLogins { - if 100 > time.Since(attempt.Timestamp).Seconds() { - updatedLogins = append(updatedLogins, attempt) - } - } - failedLogins = updatedLogins - - // At least 3 failed attempts in last 100 seconds -> likely bruteforce - if len(failedLogins) >= 3 && config.Cfg.Scram { - Scram() - } -} - -// BasicAuth is a middleware that handles authentication & authorization for the app. -// It uses BasicAuth because I doubt there is a need for something sophisticated in a small hobby project. -// Originally taken from Alex Edwards's https://www.alexedwards.net/blog/basic-authentication-in-go, MIT Licensed (13.03.2024). -func BasicAuth(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - username, password, ok := r.BasicAuth() - if ok { - // Calculate SHA-256 hashes for equal length in ConstantTimeCompare - usernameHash := sha256.Sum256([]byte(username)) - passwordHash := sha256.Sum256([]byte(password)) - expectedUsernameHash := sha256.Sum256([]byte(config.Cfg.Username)) - expectedPasswordHash := sha256.Sum256([]byte(config.Cfg.Password)) - - usernameMatch := subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1 - passwordMatch := subtle.ConstantTimeCompare(passwordHash[:], expectedPasswordHash[:]) == 1 - - if usernameMatch && passwordMatch { - next.ServeHTTP(w, r) - return - } else { - NoteLoginFail(username, password, r) - } - } - - // Unauthorized, inform client that we have auth and return 401 - w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - }) -} - -// Scram shuts down the service, useful in case of suspected attack. -func Scram() { - slog.Warn("SCRAM triggered, shutting down") - NotifyTelegram(lang.Translate("info.telegram.scram")) - os.Exit(0) -} - -// NotifyTelegram attempts to send a message to admin through Telegram. -func NotifyTelegram(msg string) { - if config.Cfg.TelegramChat == "" || config.Cfg.TelegramToken == "" { - slog.Debug("ignoring telegram request due to lack of credentials") - return - } - client := &http.Client{} - data := "chat_id=" + config.Cfg.TelegramChat + "&text=" + msg - if config.Cfg.TelegramTopic != "" { - data += "&message_thread_id=" + config.Cfg.TelegramTopic - } - req, err := http.NewRequest("POST", "https://api.telegram.org/bot"+config.Cfg.TelegramToken+"/sendMessage", strings.NewReader(data)) - if err != nil { - slog.Error("failed telegram request", "error", err) - return - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := client.Do(req) - if err != nil { - slog.Error("failed telegram request", "error", err) - return - } - - if resp.StatusCode != 200 { - slog.Error("failed telegram request", "status", resp.Status) - } -} diff --git a/internal/server/auth/auth.go b/internal/server/auth/auth.go new file mode 100644 index 0000000..7ad807a --- /dev/null +++ b/internal/server/auth/auth.go @@ -0,0 +1,39 @@ +package auth + +import ( + "crypto/sha256" + "crypto/subtle" + "net/http" + + "git.a71.su/Andrew71/hibiscus-txt/internal/config" +) + +// BasicAuth middleware handles authentication & authorization for the app. +// It uses BasicAuth because I doubt there is a need for something sophisticated in a small hobby project. +// Originally taken from Alex Edwards's https://www.alexedwards.net/blog/basic-authentication-in-go, MIT Licensed (13.03.2024). +func BasicAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if ok { + // Calculate SHA-256 hashes for equal length in ConstantTimeCompare + usernameHash := sha256.Sum256([]byte(username)) + passwordHash := sha256.Sum256([]byte(password)) + expectedUsernameHash := sha256.Sum256([]byte(config.Cfg.Username)) + expectedPasswordHash := sha256.Sum256([]byte(config.Cfg.Password)) + + usernameMatch := subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1 + passwordMatch := subtle.ConstantTimeCompare(passwordHash[:], expectedPasswordHash[:]) == 1 + + if usernameMatch && passwordMatch { + next.ServeHTTP(w, r) + return + } else { + noteLoginFail(username, password, r) + } + } + + // Unauthorized, inform client that we have auth and return 401 + w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + }) +} diff --git a/internal/server/auth/telegram.go b/internal/server/auth/telegram.go new file mode 100644 index 0000000..e9b29bd --- /dev/null +++ b/internal/server/auth/telegram.go @@ -0,0 +1,77 @@ +package auth + +import ( + "fmt" + "log/slog" + "net/http" + "os" + "strings" + "time" + + "git.a71.su/Andrew71/hibiscus-txt/internal/config" + "git.a71.su/Andrew71/hibiscus-txt/internal/lang" +) + + +type failedLogin struct { + Username string + Password string + Timestamp time.Time +} + +var failedLogins []failedLogin + +// noteLoginFail attempts to log and counteract brute-force attacks. +func noteLoginFail(username string, password string, r *http.Request) { + slog.Warn("failed auth", "username", username, "password", password, "address", r.RemoteAddr) + notifyTelegram(fmt.Sprintf(lang.Translate("info.telegram.auth_fail")+":\nusername=%s\npassword=%s\nremote=%s", username, password, r.RemoteAddr)) + + attempt := failedLogin{username, password, time.Now()} + updatedLogins := []failedLogin{attempt} + for _, attempt := range failedLogins { + if 100 > time.Since(attempt.Timestamp).Seconds() { + updatedLogins = append(updatedLogins, attempt) + } + } + failedLogins = updatedLogins + + // At least 3 failed attempts in last 100 seconds -> likely brute-force + if len(failedLogins) >= 3 && config.Cfg.Scram { + scram() + } +} + +// notifyTelegram attempts to send a message to the user through Telegram. +func notifyTelegram(msg string) { + if config.Cfg.TelegramChat == "" || config.Cfg.TelegramToken == "" { + slog.Debug("ignoring telegram request due to lack of credentials") + return + } + client := &http.Client{} + data := "chat_id=" + config.Cfg.TelegramChat + "&text=" + msg + if config.Cfg.TelegramTopic != "" { + data += "&message_thread_id=" + config.Cfg.TelegramTopic + } + req, err := http.NewRequest("POST", "https://api.telegram.org/bot"+config.Cfg.TelegramToken+"/sendMessage", strings.NewReader(data)) + if err != nil { + slog.Error("failed telegram request", "error", err) + return + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := client.Do(req) + if err != nil { + slog.Error("failed telegram request", "error", err) + return + } + + if resp.StatusCode != 200 { + slog.Error("failed telegram request", "status", resp.Status) + } +} + +// scram shuts down the service, useful in case of suspected attack. +func scram() { + slog.Warn("SCRAM triggered, shutting down") + notifyTelegram(lang.Translate("info.telegram.scram")) + os.Exit(0) +} diff --git a/internal/server/config.go b/internal/server/config.go deleted file mode 100644 index 2a421a6..0000000 --- a/internal/server/config.go +++ /dev/null @@ -1,31 +0,0 @@ -package server - -import ( - "log/slog" - "net/http" - - "git.a71.su/Andrew71/hibiscus-txt/internal/config" -) - -// PostConfig calls PostEntry for config file, then reloads the config. -func PostConfig(w http.ResponseWriter, r *http.Request) { - PostEntry(config.ConfigFile, w, r) - err := config.Cfg.Reload() - if err != nil { - slog.Error("error reloading config", "error", err) - } -} - -// ConfigReloadApi reloads the config. It then redirects back if Referer field is present. -func ConfigReloadApi(w http.ResponseWriter, r *http.Request) { - err := config.Cfg.Reload() - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - HandleWrite(w.Write([]byte(err.Error()))) - } - if r.Referer() != "" { - http.Redirect(w, r, r.Header.Get("Referer"), http.StatusFound) - return - } - w.WriteHeader(http.StatusOK) -} diff --git a/internal/server/info.go b/internal/server/info.go deleted file mode 100644 index b451ac0..0000000 --- a/internal/server/info.go +++ /dev/null @@ -1,25 +0,0 @@ -package server - -import ( - "log/slog" - "net/http" - - "git.a71.su/Andrew71/hibiscus-txt/internal/config" - "git.a71.su/Andrew71/hibiscus-txt/internal/templates" -) - -// GetInfo renders the info page. -func GetInfo(w http.ResponseWriter, r *http.Request) { - err := templates.Info.ExecuteTemplate(w, "base", config.Info) - if err != nil { - slog.Error("error executing template", "error", err) - InternalError(w, r) - return - } -} - -// GetVersionApi returns current app version. -func GetVersionApi(w http.ResponseWriter, r *http.Request) { - HandleWrite(w.Write([]byte(config.Info.Version()))) - w.WriteHeader(http.StatusOK) -} diff --git a/internal/server/main.go b/internal/server/main.go new file mode 100644 index 0000000..9a27ac7 --- /dev/null +++ b/internal/server/main.go @@ -0,0 +1,38 @@ +package server + +import ( + "embed" + "log" + "log/slog" + "net/http" + "strconv" + + "git.a71.su/Andrew71/hibiscus-txt/internal/config" + "git.a71.su/Andrew71/hibiscus-txt/internal/server/api" + "git.a71.su/Andrew71/hibiscus-txt/internal/server/routes" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +// public contains the static files e.g. CSS, JS. +// +//go:embed public +var public embed.FS + +// Serve starts the app's web server. +func Serve() { + r := chi.NewRouter() + r.Use(middleware.RealIP) + r.Use(middleware.Logger, middleware.CleanPath, middleware.StripSlashes) + r.NotFound(routes.NotFound) + + r.Mount("/", routes.UserRouter) // User-facing routes + r.Mount("/api", api.ApiRouter) // API routes + + // Static files + fs := http.FileServer(http.FS(public)) + r.Handle("/public/*", fs) + + slog.Info("🌺 Website working", "port", config.Cfg.Port) + log.Fatal(http.ListenAndServe(":"+strconv.Itoa(config.Cfg.Port), r)) +} diff --git a/internal/server/days.go b/internal/server/routes/days.go similarity index 79% rename from internal/server/days.go rename to internal/server/routes/days.go index 980c02c..75f4fe4 100644 --- a/internal/server/days.go +++ b/internal/server/routes/days.go @@ -1,4 +1,4 @@ -package server +package routes import ( "html/template" @@ -9,14 +9,15 @@ import ( "git.a71.su/Andrew71/hibiscus-txt/internal/config" "git.a71.su/Andrew71/hibiscus-txt/internal/files" "git.a71.su/Andrew71/hibiscus-txt/internal/lang" + "git.a71.su/Andrew71/hibiscus-txt/internal/server/util" "github.com/go-chi/chi/v5" ) -// GetDays calls GetEntries for previous days' entries. -func GetDays(w http.ResponseWriter, r *http.Request) { +// getDays calls getEntries for previous days' entries. +func getDays(w http.ResponseWriter, r *http.Request) { description := template.HTML( "" + template.HTMLEscapeString(lang.Translate("prompt.days")) + "") - GetEntries(w, r, lang.Translate("title.days"), description, "day", func(files []string) []Entry { + getEntries(w, r, lang.Translate("title.days"), description, "day", func(files []string) []Entry { var filesFormatted []Entry for i := range files { v := files[len(files)-1-i] // This is suboptimal, but reverse order is better here @@ -42,12 +43,12 @@ func GetDays(w http.ResponseWriter, r *http.Request) { }) } -// GetDay calls GetEntry for a day entry. -func GetDay(w http.ResponseWriter, r *http.Request) { +// getDay calls getEntry for a day entry. +func getDay(w http.ResponseWriter, r *http.Request) { dayString := chi.URLParam(r, "day") if dayString == "" { w.WriteHeader(http.StatusBadRequest) - HandleWrite(w.Write([]byte("day not specified"))) + util.HandleWrite(w.Write([]byte("day not specified"))) return } if dayString == config.Cfg.TodayDate() { // Today can still be edited @@ -61,5 +62,5 @@ func GetDay(w http.ResponseWriter, r *http.Request) { title = t.Format("02 Jan 2006") } - GetEntry(w, r, title, files.DataFile("day/"+dayString), false) + getEntry(w, r, title, files.DataFile("day/"+dayString), false) } diff --git a/internal/server/entries.go b/internal/server/routes/entries.go similarity index 82% rename from internal/server/entries.go rename to internal/server/routes/entries.go index ebad8a7..e11b6d5 100644 --- a/internal/server/entries.go +++ b/internal/server/routes/entries.go @@ -1,4 +1,4 @@ -package server +package routes import ( "errors" @@ -25,8 +25,8 @@ type Entry struct { type formatEntries func([]string) []Entry -// GetEntries handles showing a list. -func GetEntries(w http.ResponseWriter, r *http.Request, title string, description template.HTML, dir string, format formatEntries) { +// getEntries handles showing a list. +func getEntries(w http.ResponseWriter, r *http.Request, title string, description template.HTML, dir string, format formatEntries) { filesList, err := files.List(dir) if err != nil { slog.Error("error reading file list", "directory", dir, "error", err) @@ -43,8 +43,8 @@ func GetEntries(w http.ResponseWriter, r *http.Request, title string, descriptio } } -// GetEntry handles showing a single file, editable or otherwise. -func GetEntry(w http.ResponseWriter, r *http.Request, title string, filename string, editable bool) { +// getEntry handles showing a single file, editable or otherwise. +func getEntry(w http.ResponseWriter, r *http.Request, title string, filename string, editable bool) { entry, err := files.Read(filename) if err != nil { if editable && errors.Is(err, os.ErrNotExist) { @@ -67,8 +67,8 @@ func GetEntry(w http.ResponseWriter, r *http.Request, title string, filename str } } -// PostEntry saves value of "text" HTML form component to a file and redirects back to Referer if present. -func PostEntry(filename string, w http.ResponseWriter, r *http.Request) { +// postEntry saves value of "text" HTML form component to a file and redirects back to Referer if present. +func postEntry(filename string, w http.ResponseWriter, r *http.Request) { err := files.Save(filename, []byte(r.FormValue("text"))) if err != nil { slog.Error("error saving file", "error", err, "file", filename) diff --git a/internal/server/errors.go b/internal/server/routes/errors.go similarity index 87% rename from internal/server/errors.go rename to internal/server/routes/errors.go index aab5237..91bf00d 100644 --- a/internal/server/errors.go +++ b/internal/server/routes/errors.go @@ -1,4 +1,4 @@ -package server +package routes import ( "log/slog" @@ -26,7 +26,7 @@ func InternalError(w http.ResponseWriter, r *http.Request) { err := templates.Template500.Execute(w, nil) if err != nil { // Well this is awkward slog.Error("error rendering error 500 page", "error", err) - HandleWrite(w.Write([]byte("500. Something went *very* wrong."))) + http.Error(w, "500. Something went *very* wrong.", http.StatusInternalServerError) return } } diff --git a/internal/server/routes/main.go b/internal/server/routes/main.go new file mode 100644 index 0000000..7fd3236 --- /dev/null +++ b/internal/server/routes/main.go @@ -0,0 +1,38 @@ +package routes + +import ( + "net/http" + + "git.a71.su/Andrew71/hibiscus-txt/internal/config" + "git.a71.su/Andrew71/hibiscus-txt/internal/files" + "git.a71.su/Andrew71/hibiscus-txt/internal/lang" + "git.a71.su/Andrew71/hibiscus-txt/internal/server/auth" + "github.com/go-chi/chi/v5" +) + +var UserRouter *chi.Mux + +func init() { + UserRouter = chi.NewRouter() + UserRouter.Use(auth.BasicAuth) + UserRouter.Get("/", func(w http.ResponseWriter, r *http.Request) { + getEntry(w, r, lang.Translate("title.today"), files.DataFile("day/"+config.Cfg.TodayDate()), true) + }) + UserRouter.Post("/", func(w http.ResponseWriter, r *http.Request) { + postEntry(files.DataFile("day/"+config.Cfg.TodayDate()), w, r) + }) + UserRouter.Get("/day", getDays) + UserRouter.Get("/day/{day}", getDay) + UserRouter.Get("/notes", getNotes) + UserRouter.Get("/notes/{note}", getNote) + UserRouter.Post("/notes/{note}", postNote) + UserRouter.Get("/info", getInfo) + UserRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) { + getEntry(w, r, "readme.txt", files.DataFile("readme"), true) + }) + UserRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { postEntry(files.DataFile("readme"), w, r) }) + UserRouter.Get("/config", func(w http.ResponseWriter, r *http.Request) { + getEntry(w, r, "config.txt", config.ConfigFile, true) + }) + UserRouter.Post("/config", postConfig) +} diff --git a/internal/server/routes/misc.go b/internal/server/routes/misc.go new file mode 100644 index 0000000..553050a --- /dev/null +++ b/internal/server/routes/misc.go @@ -0,0 +1,28 @@ +package routes + +import ( + "log/slog" + "net/http" + + "git.a71.su/Andrew71/hibiscus-txt/internal/config" + "git.a71.su/Andrew71/hibiscus-txt/internal/templates" +) + +// postConfig calls postEntry for config file, then reloads the config. +func postConfig(w http.ResponseWriter, r *http.Request) { + postEntry(config.ConfigFile, w, r) + err := config.Cfg.Reload() + if err != nil { + slog.Error("error reloading config", "error", err) + } +} + +// getInfo renders the info page. +func getInfo(w http.ResponseWriter, r *http.Request) { + err := templates.Info.ExecuteTemplate(w, "base", config.Info) + if err != nil { + slog.Error("error executing template", "error", err) + InternalError(w, r) + return + } +} diff --git a/internal/server/notes.go b/internal/server/routes/notes.go similarity index 64% rename from internal/server/notes.go rename to internal/server/routes/notes.go index 0bad17e..b5b8a19 100644 --- a/internal/server/notes.go +++ b/internal/server/routes/notes.go @@ -1,4 +1,4 @@ -package server +package routes import ( "html/template" @@ -7,16 +7,17 @@ import ( "git.a71.su/Andrew71/hibiscus-txt/internal/files" "git.a71.su/Andrew71/hibiscus-txt/internal/lang" + "git.a71.su/Andrew71/hibiscus-txt/internal/server/util" "github.com/go-chi/chi/v5" ) -// GetNotes calls GetEntries for all notes. -func GetNotes(w http.ResponseWriter, r *http.Request) { +// getNotes calls getEntries for all notes. +func getNotes(w http.ResponseWriter, r *http.Request) { // This is suboptimal, but will do... description := template.HTML( "" + template.HTMLEscapeString(lang.Translate("button.notes")) + "" + " ") - GetEntries(w, r, lang.Translate("title.notes"), description, "notes", func(files []string) []Entry { + getEntries(w, r, lang.Translate("title.notes"), description, "notes", func(files []string) []Entry { var filesFormatted []Entry for _, v := range files { // titleString := strings.Replace(v, "-", " ", -1) // This would be cool, but what if I need a hyphen? @@ -26,12 +27,12 @@ func GetNotes(w http.ResponseWriter, r *http.Request) { }) } -// GetNote calls GetEntry for a note. -func GetNote(w http.ResponseWriter, r *http.Request) { +// getNote calls getEntry for a note. +func getNote(w http.ResponseWriter, r *http.Request) { noteString := chi.URLParam(r, "note") if noteString == "" { w.WriteHeader(http.StatusBadRequest) - HandleWrite(w.Write([]byte("note not specified"))) + util.HandleWrite(w.Write([]byte("note not specified"))) return } // Handle non-latin note names @@ -39,16 +40,16 @@ func GetNote(w http.ResponseWriter, r *http.Request) { noteString = decodedNote } - GetEntry(w, r, noteString, files.DataFile("notes/"+noteString), true) + getEntry(w, r, noteString, files.DataFile("notes/"+noteString), true) } -// PostNote calls PostEntry for a note. -func PostNote(w http.ResponseWriter, r *http.Request) { +// postNote calls postEntry for a note. +func postNote(w http.ResponseWriter, r *http.Request) { noteString := chi.URLParam(r, "note") if noteString == "" { w.WriteHeader(http.StatusBadRequest) - HandleWrite(w.Write([]byte("note not specified"))) + util.HandleWrite(w.Write([]byte("note not specified"))) return } - PostEntry(files.DataFile("notes/"+noteString), w, r) + postEntry(files.DataFile("notes/"+noteString), w, r) } diff --git a/internal/server/serve.go b/internal/server/serve.go deleted file mode 100644 index 7ad8c25..0000000 --- a/internal/server/serve.go +++ /dev/null @@ -1,78 +0,0 @@ -package server - -import ( - "embed" - "log" - "log/slog" - "net/http" - "strconv" - - "git.a71.su/Andrew71/hibiscus-txt/internal/config" - "git.a71.su/Andrew71/hibiscus-txt/internal/files" - "git.a71.su/Andrew71/hibiscus-txt/internal/lang" - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" -) - -// public contains the static files e.g. CSS, JS. -// -//go:embed public -var public embed.FS - -// Serve starts the app's web server. -func Serve() { - r := chi.NewRouter() - r.Use(middleware.RealIP) - r.Use(middleware.Logger, middleware.CleanPath, middleware.StripSlashes) - r.NotFound(NotFound) - - // Routes ========== - userRouter := chi.NewRouter() - userRouter.Use(BasicAuth) - userRouter.Get("/", func(w http.ResponseWriter, r *http.Request) { - GetEntry(w, r, lang.Translate("title.today"), files.DataFile("day/"+config.Cfg.TodayDate()), true) - }) - userRouter.Post("/", func(w http.ResponseWriter, r *http.Request) { PostEntry(files.DataFile("day/"+config.Cfg.TodayDate()), w, r) }) - userRouter.Get("/day", GetDays) - userRouter.Get("/day/{day}", GetDay) - userRouter.Get("/notes", GetNotes) - userRouter.Get("/notes/{note}", GetNote) - userRouter.Post("/notes/{note}", PostNote) - userRouter.Get("/info", GetInfo) - userRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) { - GetEntry(w, r, "readme.txt", files.DataFile("readme"), true) - }) - userRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostEntry(files.DataFile("readme"), w, r) }) - userRouter.Get("/config", func(w http.ResponseWriter, r *http.Request) { GetEntry(w, r, "config.txt", config.ConfigFile, true) }) - userRouter.Post("/config", PostConfig) - r.Mount("/", userRouter) - - // API ============= - apiRouter := chi.NewRouter() - apiRouter.Use(BasicAuth) - apiRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) { GetFileApi("readme", w) }) - apiRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostFileApi("readme", w, r) }) - apiRouter.Get("/day", func(w http.ResponseWriter, r *http.Request) { GetFileList("day", w) }) - apiRouter.Get("/day/{day}", GetDayApi) - apiRouter.Get("/notes", func(w http.ResponseWriter, r *http.Request) { GetFileList("notes", w) }) - apiRouter.Get("/notes/{note}", GetNoteApi) - apiRouter.Post("/notes/{note}", PostNoteApi) - apiRouter.Get("/today", func(w http.ResponseWriter, r *http.Request) { - GetFileApi(files.DataFile("day/"+config.Cfg.TodayDate()), w) - }) - apiRouter.Post("/today", func(w http.ResponseWriter, r *http.Request) { - PostEntry(files.DataFile("day/"+config.Cfg.TodayDate()), w, r) - }) - apiRouter.Get("/export", files.GetExport) - apiRouter.Get("/grace", GraceActiveApi) - apiRouter.Get("/version", GetVersionApi) - apiRouter.Get("/reload", ConfigReloadApi) - r.Mount("/api", apiRouter) - - // Static files - fs := http.FileServer(http.FS(public)) - r.Handle("/public/*", fs) - - slog.Info("🌺 Website working", "port", config.Cfg.Port) - log.Fatal(http.ListenAndServe(":"+strconv.Itoa(config.Cfg.Port), r)) -} diff --git a/internal/server/util/handle.go b/internal/server/util/handle.go new file mode 100644 index 0000000..231b4d3 --- /dev/null +++ b/internal/server/util/handle.go @@ -0,0 +1,11 @@ +package util + +import "log/slog" + +// HandleWrite "handles" error in output of ResponseWriter.Write. +// Much useful very wow. +func HandleWrite(_ int, err error) { + if err != nil { + slog.Error("error writing response", "error", err) + } +} diff --git a/internal/templates/main.go b/internal/templates/main.go index 50685bf..e349a19 100644 --- a/internal/templates/main.go +++ b/internal/templates/main.go @@ -19,6 +19,7 @@ func EmbeddedPage(name string) []byte { data, err := pages.ReadFile(name) if err != nil { slog.Error("error reading embedded file", "err", err) + return []byte("") } return data }