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
}